65.9K
CodeProject 正在变化。 阅读更多。
Home

API 设计原则:单例、多例、空值与无值

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.72/5 (55投票s)

2013年9月5日

CPOL

10分钟阅读

viewsIcon

81786

如何创建能够随着系统发展而扩展的 API。

概述

在设计 API 时,有无数因素需要考虑。安全性、一致性、状态管理、风格;这个列表似乎永无止境。然而,一个经常被忽视的因素是可扩展性。从一开始就将可扩展性纳入 API 设计中,可以在系统发展过程中节省数百小时的开发时间。

引言

应用程序编程接口 (API) 的定义有时难以确定。严格来说,任何被其他程序员的代码调用的函数都可以符合该定义。争论哪些代码符合 API 的定义超出了本文的范围,因此,为了我们的目的,我们将假设基本函数也符合。

本文中的示例为了说明主要观点而故意保持简单。使用了 C# 函数,但核心原则几乎可以应用于任何语言、框架或系统。示例中的数据结构是根据许多行业标准数据库使用的熟悉关系风格建模的。同样,这仅用于说明目的,不应被视为应用这些原则的要求。

要求

假设我们正在为一个客户创建一个基本的订单处理系统,并且三个主要类(或您喜欢的“数据结构”)已定义。下面是一个非常基础的关系类结构。Customer 类有一个指向 Address 的“外键”(借用数据库术语),而 Order 类有一个指向 AddressCustomer 的外键。您被要求创建一个可用于处理订单的库。要实现的第一个业务规则是 CustomerHomeAddressState 必须与 OrderBillingAddressState 相同(别问为什么,业务规则很少有意义)。;-)

public class Address
{
    public int AddressId { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string State { get; set; }
    public string Zipcode { get; set; }
}

public class Customer
{
    public Address HomeAddress { get; set; }
    public int CustomerId { get; set; }
    public int HomeAddressId { get; set; }
    public string CustomerName { get; set; }
}

public class Order
{
    public Customer MainCustomer { get; set; }
    public Address ShippingAddress { get; set; }
    public Address BillingAddress { get; set; }

    public int OrderId { get; set; }
    public int CustomerId { get; set; }
    public int ShippingAddressId { get; set; }
    public int BillingAddressId { get; set; }
    public decimal OrderAmount { get; set; }
    public DateTime OrderDate { get; set; }
} 

实现

检查两个字段是否匹配当然是一项简单的任务。为了给您的老板留下深刻印象,您不到十分钟就完成了解决方案。VerifyStatesMatch 函数返回一个布尔值,该布尔值将指示调用者是否遵循了业务规则。您对库进行了一些基本测试,并确定代码平均只需要 50 毫秒即可执行,并且没有任何缺陷。老板非常 impressed,并将您的库交给其他开发人员在其应用程序中使用。

public bool VerifyStatesMatch(Order order)
{
    bool retVal = false;
    try
    {
        // Assume this operation takes 25 ms.
        Customer customer = SomeDataSource.GetCustomer(order.CustomerId);
        // Assume this operation takes 25 ms.
        Address shippingAddress = SomeDataSource.GetAddress(order.ShippingAddressId);
        retVal = customer.HomeAddress.State == shippingAddress.State;
    }
    catch (Exception ex)
    {
        SomeLogger.LogError(ex);
    }
    return retVal;
}  

问题

第二天,您来到公司,发现显示器上有一张便利贴:“尽快来见我 - 老板”。您认为您昨天在库方面做得如此出色,以至于老板今天必须给您一项更艰巨的任务。然而,您很快就会发现您的代码存在一些严重问题。

:老板,有什么事?

老板:你的库在软件中引起了各种各样的问题!

:什么?怎么回事?

老板:Bob 说你的算法太慢了,John 说它工作不正常,Steve 说了一些关于“对象引用未设置为对象实例”的事情。

:我不明白,我昨天测试过,一切都很好。

老板:我不想听借口。去和其他人谈谈,弄清楚!

这不是您想开始一天的方式,对吧?我敢说大多数开发人员以前都面临过这种情况。您认为您已经“完美”地编写了您的库,但似乎存在各种各样的问题。通过应用“单例、多例、空值与无值”的原则,您将能够看到 API 在何处未能达到他人的期望。

单例 (The One)

http://en.wikipedia.org/wiki/The_Matrix

要遵循的第一个原则是正确处理“单例”。所谓单例,我的意思是您的 API 应该处理一个预期的输入实例,而不会出现您没有明确告知调用者可能发生的任何错误。您可能在想:“这不是很明显吗?”,但让我们看看我们的示例,展示我们可能没有正确处理一个订单。

Customer customer = SomeDataSource.GetCustomer(order.CustomerId);
Address shippingAddress = SomeDataSource.GetAddress(order.ShippingAddressId);
// What if customer.HomeAddress didn't load properly or was null?     
retVal = customer.HomeAddress.State == shippingAddress.State; 

正如上面的注释所述,我们假设 HomeAddress 属性已从数据源正确加载。虽然 99.99% 的时间它可能都会成功,但一个万无一失的 API 必须考虑到那种千分之一的几率它不会成功。此外,取决于语言,如果任一属性未正确加载,则两个 State 属性的比较可能会失败。这里的重点是,您不能对您收到的输入或您从不控制的代码中获得的数据做任何假设。

这是最容易理解的原则,所以让我们修复我们的示例,然后继续。

Customer customer = SomeDataSource.GetCustomer(order.CustomerId);
Address shippingAddress = SomeDataSource.GetAddress(order.ShippingAddressId);
if(customer.HomeAddress != null)
{
    retVal = customer.HomeAddress.State == shippingAddress.State;
} 

多例 (The Many)

http://msdn.microsoft.com/en-us/library/w5zay9db.aspx

回到上面的场景,我们需要和 Bob 谈谈。Bob 说代码太慢了,但考虑到系统的架构,50 毫秒在可接受的执行时间内。好吧,原来 Bob 需要在一个批处理中处理您最大客户的 100 个订单,所以他在使用的循环中调用您的方法总共花费了 5 秒钟。

Bobs code:
foreach(Order order in bobsOrders)
{
    ...
    bool success = OrderProcess.VerifyStatesMatch(order);
    ....
}

:Bob,你为什么觉得我的代码太慢了?处理一个订单只需要 50 毫秒。

Bob:Acme 公司要求尽可能快的批量订单处理性能。我需要处理 100 个订单,所以 5 秒太慢了。

:哦,我不知道我们需要批量处理订单。

Bob:嗯,这只是针对 Acme,因为他们是我们最大的客户。

:哦,我没有被告知任何关于 Acme 或批量订单的信息。

Bob:嗯,你的代码难道不应该能够高效地处理一个以上的订单吗?

:哦……是的,当然。

到底发生了什么,为什么 Bob 认为代码“太慢”了,这一点非常明显。您没有被告知 Acme 的事情,也没有人提到批量处理。Bob 的循环正在加载同一个 Customer,很可能,同一个 Address 记录 100 次。通过接受一个订单数组而不是一个订单,并添加一些简单的缓存,可以轻松地修复这个问题。C# 的 params 关键字就是为这种情况而设计的。

 public bool VerifyStatesMatch(params Order[] orders)
{
    bool retVal = false;
    try
    {
        var customerMap = new Dictionary<int, Customer>();
        var addressMap = new Dictionary<int, Address>();
        foreach (Orderorder in orders)
        {
            Customer customer = null;
            if(customerMap.ContainsKey(order.CustomerId))
            {
               customer = customerMap[order.CustomerId];
            }
            else
            {
               customer = SomeDataSource.GetCustomer(order.CustomerId);
               customerMap.Add(order.CustomerId, customer);
            }
            Address shippingAddress = null;
            if(addressMap.ContainsKey(order.ShippingAddressId))
            {
               shippingAddress = addressMap[order.ShippingAddressId];
            }
            else
            {
               shippingAddress = SomeDataSource.GetAddress(order.ShippingAddressId);
               addressMap.Add(order.ShippingAddressId,shippingAddress);
            }
            retVal = customer.HomeAddress.State == shippingAddress.State;
            if(!retVal)
            {
                break;
            }
        }
    }
    catch (Exception ex)
    {
       SomeLogger.LogError(ex);
    }
    return retVal; 
} 

此版本的函数将大大加快 Bob 的批量处理速度。大多数数据调用已被消除,因为我们可以简单地在临时缓存 (Dictionary) 中按 ID 查找记录。

一旦您将 API 向“多例”开放,就必须进行一些范围检查。例如,如果有人向您的方法发送一百万个订单怎么办?如此大的数字是否超出了架构的范围?这就是理解系统架构和业务流程发挥作用的地方。如果您知道处理订单的最大使用案例是 10,000 个,那么您可以自信地添加一个检查,例如 50,000 条记录。这将确保没有人会意外地通过一个大型无效调用拖垮系统。

虽然这些不是可以进行的唯一优化,但它应该能说明从一开始就为“多例”做计划可以节省后续的返工。

空值 (The Null)

http://en.wikipedia.org/wiki/Null_pointer#Null_pointer

:Steve,你是在给我的代码传 null 吗?

Steve:我不确定,为什么?

:老板说你遇到了“对象引用……”错误。

Steve:哦,那一定是来自遗留系统。我不控制那个系统的输出,我们只是照原样将其传入新系统。

:这似乎很愚蠢,为什么我们不处理这些 null 值呢?

Steve:我处理了;我在我的代码中检查 null;你没有吗?

:哦……是的,当然。

“对象引用未设置为对象实例。”我需要解释那个错误吗?对我们中的许多人来说,它已经耗费了我们生命中的许多小时。在大多数语言中,null、空集等是任何非值类型的完全有效状态。这意味着任何稳健的 API 都必须考虑“空值”,即使调用者传递它在技术上是“错误的”。

当然,检查每个引用是否为 null 可能非常耗时,而且很可能有些过度。但是,您永远不应该信任来自您不控制的源的输入,因此我们必须检查我们的“orders”参数以及其中的 Orders 是否为 null。

public bool VerifyStatesMatch(params Order[] orders)
{
    bool retVal = false;
    try
    {
        if (orders != null)
        {
            var customerMap = new Dictionary<int, Customer>();
            var addressMap = new Dictionary<int, Address>();
            foreach (Order order in orders)
            {
                if (order != null)
                {
                    Customer customer = null;
                    if (customerMap.ContainsKey(order.CustomerId))
                    {
                        customer = customerMap[order.CustomerId];
                    }
                    else
                    {
                        customer = SomeDataSource.GetCustomer(order.CustomerId);
                        customerMap.Add(order.CustomerId, customer);
                    }

                    Address shippingAddress = null;
                    if (addressMap.ContainsKey(order.ShippingAddressId))
                    {
                        shippingAddress = addressMap[order.ShippingAddressId];
                    }
                    else
                    {
                        shippingAddress = SomeDataSource.GetAddress(order.ShippingAddressId);
                        addressMap.Add(order.ShippingAddressId, shippingAddress);
                    }
                    retVal = customer.HomeAddress.State == shippingAddress.State;

                    if (!retVal)
                    {
                        break;
                    }
                }
            }
        }
    }
    catch (Exception ex)
    {
        SomeLogger.LogError(ex);
    }
    return retVal;
}  

通过仔细检查 null,您可以避免客户询问“对象实例”是什么的令人尴尬的支持电话。我总是倾向于谨慎;我宁愿让我的函数返回默认值并记录一条消息(或发送一个警报),也不愿抛出有些无用的 null 引用错误。当然,这个决定完全取决于系统的类型、代码是在客户端还是服务器上运行等等。这里的教训是,您只能忽略 null 这么久,否则它就会咬到您。

更新:需要明确的是,我并不是说函数在遇到无效状态时应该“什么都不做”。如果 null 参数在您的系统中不可接受,请抛出一个异常(例如 .NET 中的 ArgumentNull)。但是,在某些情况下,返回有意义的默认值是完全可以接受的,而抛出异常则没有必要。例如,流畅方法通常会返回传入它们的值,如果它们无法处理该值的话。有太多的因素,无法对遇到 null 时应该做什么做出任何笼统的陈述。

无值 (The Nothing)

http://youtu.be/CrG-lsrXKRM

:John,你往我代码里传的是什么?看起来像一个不完整的订单。

John:哦,抱歉。我其实不需要使用你的方法,但其中一个库要求我传递一个 Order 参数。我想是那个库在调用你的代码。我不处理订单,但我必须使用那个库。

:那个库需要停止这样做;这是糟糕的设计。

John:嗯,那个库随着业务的变化而有机地发展。而且,它是 Matt 写的,他这周不在;我不太确定如何更改它。你的代码难道不应该检查不良输入吗?

:哦……是的,当然。

在四个原则中,“无值”可能是最难描述的。尽管表示“无”或“空”,null 实际上有一个定义并且可以量化。是的,大多数语言都有一个内置的关键字来表示它;null 肯定不是无。通过处理“无”,我的意思是您的 API 必须处理本质上是垃圾的输入。在我们的示例中,这将转化为处理一个没有 CustomerIdOrderDate 是 500 年前的订单。一个更好的例子是收集中没有任何项目的集合。该集合不是 null,并且应该属于“多例”类别,但调用者未能向集合填充任何数据。您必须始终确保处理这种“无值”场景。让我们调整我们的示例,以确保调用者不能只传递一个看起来像订单的东西;它必须满足最低的通用要求。否则,我们就会将其视为无值。

...
// Yes, I cheated.  ;-) 
if (order != null && order.IsValid)
...  

结论

如果本文有一点我希望已经证明了,那就是任何接受输入的代码都不是“完美”的,任何函数或 API 的实现都必须考虑 API 将如何被使用。在我们的示例中,我们 12 行的函数增长到 50 行,而核心功能没有任何改变。我们添加的所有代码都是为了处理我们接受和正确、高效地处理的数据的规模或范围。

多年来,存储的数据量呈指数级增长,因此输入数据的规模只会增加,而数据质量也没有下降的空间。从一开始就正确地编写 API,可以在赢得业务、随着客户增长而扩展以及降低未来维护成本(和您的头疼)方面产生巨大影响。

© . All rights reserved.