编程

API 设计:可选参数

535 2023-05-23 10:24:00

当我们编写函数时,通常希望为用户提供适合一系列用例的选项。有好的方法也有坏的方法,这篇文章将对此进行探讨。

› 指导原则

API 设计是有难度的。好的 API 需要满足以下这些条件:

  1. 易于修改而不破坏用户代码。
  2. 对通用用例简单
  3. 无需阅读文档即可轻松理解
  4. 出现问题时有帮助

在我们的案例研究中,我们使用这些原则来帮我们理解什么是好的设计决策,什么是坏的设计决策。

› 案例研究

假设我们用 Go 编写一个 HTTP 客户端库。

package http
struct Response {
  StatusCode int
  Headers map[string]string
  Body string
}
func Get(url string) (Response, error) {
  // ...
}

函数的主体不太重要。我们的目标是为用户提供简单的 API,目前为止我们以及做得挺好,只是还缺乏灵活性。我们不能设置 header,我们对超时没有概念,最好可以很便捷地进行重定向。简言之,我们缺少了很多重要的功能。

› Go:  用错误的方式添加参数

让我们用拙劣的方式来实现。

func Get(url string, followRedirects bool, headers map[string]string) (Response, error) {
  // ...
}

这不是最佳的供调用的函数:

rsp, err := http.Get("http://example.com", false, nil)

如果你在不知道函数签名的情况下遇到这种情况,你会想知道false和nil代表什么。如果你必须调用这个函数,你会对不得不传递你不关心的参数感到不满。需要传递的参数越多,常见用例就越难。

修改是一场噩梦。添加或删除参数将破坏用户代码。添加更多的函数来实现相同的事情,但使用不同的参数集会让人感到困惑。

简而言之,这是一团糟。

› Go: 用好的方式添加参数

假如我们将参数放入到结构体呢?

struct Options {
  FollowRedirects bool
  Headers map[string]string
}
func Get(url string, options Options) (Response, error) {
  // ...
}
rsp, err := Get("http://example.com", Options{
  FollowRedirects: true,
  Header: map[string]string{
    "Accept-Encoding": "gzip",
  },
})

› 优点

  • 容易添加新参数
  • 普通读者很容易知道发生了什么
  • 用户只需要设置他们关心的参数

› 缺点

  • 如果你不想设置任何参数,你仍然需要传入一个空的结构。常见的用例仍然比它需要的更加困难。
  • 如果你想设置很多参数,它可能会变得很难处理。
  • 在Go中,很难区分未设置或已专门设置为零值的内容。其他语言也有同样的空值问题。

› Go: 以更好的方式添加参数

这个有点复杂,需要点耐心。

type options struct {
  followRedirects bool
  headers map[string]string
}
type Option = func(*options)
var (
  FollowRedirects = func(o *options) {
    o.followRedirects = true
  }
  Header = func(key, value string) func(*options) {
    return func(o *options) {
      o.headers[key] = value
    }
  }
)
func Get(url string, os ...Option) (Response, error) {
  o := options{}
  for _, option := range os {
    option(&o)
  }
  // ...
}

一些用例:

rsp, err := http.Get("http://example.com")

如果你需要指定参数:

rsp, err := http.Get("http://example.com",
  http.FollowRedirects,
  http.Header("Accept-Encoding", "gzip"))

› 优点

  • 易于添加新参数。
  • 易于弃用旧参数,只需在使用这些旧参数时输出警告。
  • 普通读者很容易知道发生了什么。
  • 函数可以做任何事情,因此参数可以超出指定值。例如,您可以从磁盘加载配置。

› 缺点

  • 如果API作者不注意,可能会创建相互干扰的参数,或做一些不安全的操作,比如修改全局状态。
  • 对API作者而言,设置比普通的旧函数参数稍微复杂一点。

利大于弊。如果您不断检查Option函数内部的操作,那么灵活性和干净的API表面将自己买单。

这是我对那些没有first-class支持的语言进行的可选参数维护。

› 其他语言呢?

你可能会想,JAVA 有方法重载,它是否完美适配可选参数?

这是一个很好的问题,当我们不想更改任何内容时,我们可以通过指定默认Options结构去绕过该需求。让我们探讨一下在Java中使用方法重载会是什么样子。

› Java 的案例

下面用Java重写上面的功能

public final class Http {
  public static final class Response {
    public final int statusCode;
    public final InputStream body;
    public final Map<String, String> headers;
  }
  public static Response get(String url) throws IOException {
    // ...
  }
}

像这样调用:

try {
  Response res = Http.get("https://example.com");
} catch (IOException e) {
  // ...
}

› Java: 方法重载方式(坏的实现)

现在让我们使用方法重载来避免必须指定默认的Options结构。

public final class Http {
  public static Response get(String url) throws IOException {
    return get(url, null);
  }
  public static Response get(String url, Map<String, String> headers) throws IOException {
    return get(url, headers, false);
  }
  public static Response get(String url, boolean followRedirects, Map<String, String> headers) throws IOException {
    // ...
  }
}

这通常被称为”telescopic function“,因为当你添加新的参数时,整个会变得更长,就像扩展望远镜一样。

下面是一个使用它的示例:

public final class Main {
  public static final void main(String... args) throws IOException {
    Response res;
    res = Http.get("https://example.com");
    res = Http.get("https://example.com", true);
    res = Http.get("https://example.com", true, Map.of("Accept-Encoding", "gzip"));
  }
}

› 优点

  • 用户无需操心所有指定参数。通用情况简单
  • 易于添加新参数

› 缺点

  • 要设置大量样板模板
  • 虽然常见的用例很简单,但如果你想调整一个参数,你可能必须指定其他你不关心的参数的负载。

这不是一个理想的解决方案。它在前面的基础上有所改进,但不是我建议您在自己的代码中使用的。

› Java: 好的方案

public final class Http {
  private static final class Options {
    boolean followRedirects;
    Map<String, String> headers;
    Options() {
      this.followRedirects = false;
      this.headers = new HashMap<>();
    }
  }
  public static Consumer<Options> followRedirects() {
    return o -> o.followRedirects = true;
  }
  public static Consumer<Options> header(String key, String value) {
    return o -> o.headers.put(key, value);
  }
  public static Response get(String url, Consumer<Options>... os) throws IOException {
    Options options = new Options();
    for (Consumer<Options> o : os) {
      o.accept(options);
    }
    // ...
  }
}

及使用此API的示例:

public final class Main {
  public static final void main(String... args) throws IOException {
    Response res;
    res = Http.get("https://example.com");
    res = Http.get("https://example.com", Http.followRedirects());
    res = Http.get("https://example.com", Http.followRedirects(), Http.header("Accept-Encoding", "gzip"));
  }
}

这具有我们在Go中看到它时的所有优点,并且在Java中仍然很好看。它还解决了我们在上一节中看到的用户可能只想指定一个参数的问题。

虽然上面的内容很好,但在使用Java时,这并不太常见。你更有可能看到的是用“builders”实现

› Java: 惯用的解决方案 - 使用 builder

public final class HttpClient {
  private final Map<String, String> headers;
  private final boolean followRedirects;
  private HttpClient(Map<String, String> headers, boolean followRedirects) {
    this.headers = headers;
    this.followRedirects = followRedirects;
  }
  public static final class Builder {
    private Map<String, String> headers = new HashMap<>();
    private boolean followRedirects = false;
    private Builder() {}
    public Builder withHeader(String key, String value) {
      this.headers.put(key, value);
      return this;
    }
    public Builder followRedirects() {
      this.followRedirects = true;
      return this;
    }
    public Client build() {
      return new Client(headers, followRedirects);
    }
  }
  public static Builder builder() {
    return new Builder();
  }
  public Response get(String url) throws IOException {
    // ...
  }
}

使用它的示例:

public final class Main {
  public static final void main(String... args) {
    HttpClient client =
      HttpClient.builder()
        .withHeader("Accept-Encoding", "gzip")
        .followRedirects()
        .build();
    Response res = client.get("https://example.com");
  }
}

这可能感觉有点偏离了向方法传递可选参数。用Java创建新对象成本低廉,而且比长方法签名更受欢迎。

› 优点

  • 可以添加新参数而不用破坏用户代码。
  • 易于让普通读者理解发生了什么。
  • builder上的函数补全告诉我们哪些参数可用。

› 缺点

  • 库作者需要许多样板代码

这是我建议您在 Java 中支持可选参数的方式。这可能感觉工作量很大,但有些库可以帮助减少样板。此外,像IntelliJ这样的IDE也有一些工具可以帮助您生成大多数无聊的东西。

› 为什么这种方法比其他方法更惯用?

在 Java 中 Builders 先于 lambdas。

› 其他支持可选参数的语言呢?

又一个好问题。在这些情况下,使用语言提供给你的东西是最有意义的。

› Python

class Http:
  @staticmethod
  def get(url, follow_redirects=False, headers=None):
    pass
res = Http.get("https://example.com")
res = Http.get("https://example.com", follow_redirects=True)
res = Http.get("https://example.com", headers={"Accept-Encoding": "gzip"})

› Ruby

class Http
  def self.get(string, follow_redirects: false, headers: nil)
  end
end
res = Http.get("https://example.com")
res = Http.get("https://example.com", headers: {"Accept-Encoding": "gzip"})
res = Http.get("https://example.com", follow_redirects: true, headers: {"Accept-Encoding": "gzip"})

› 优点

  • 易于添加新参数。
  • 易于让普通读者理解发生了什么。
  • 对API作者,几乎没有样板代码

› 缺点

  • 关于设置默认参数值的一些奇怪之处,我们稍后将讨论。

› 动态语言的注意词

› 当心 **kwargs

Ruby和Python都能够将它们的命名参数“glob”到字典中:

class Http
  def self.get(string, **kwargs)
    # kwargs[:headers]
    # kwargs[:follow_redirects]
  end
end
res = Http.get("https://example.com")
res = Http.get("https://example.com", headers: {"Accept-Encoding": "gzip"})
res = Http.get("https://example.com", follow_redirects: true, headers: {"Accept-Encoding": "gzip"})
class Http:
  @staticmethod
  def get(url, **kwargs):
    # kwargs["headers"]
    # kwargs["follow_redirects"]
    pass
res = Http.get("https://example.com")
res = Http.get("https://example.com", follow_redirects=True)
res = Http.get("https://example.com", headers={"Accept-Encoding": "gzip"})

我不推荐这样使用。显式可以让读者更明确了解他们需要传入什么,也让作者有机会设置合理的默认值。

› 注意可变的默认值

你可能在几节之前会想这么写:

class Http:
  @staticmethod
  def get(url, follow_redirects=False, headers={}):
    pass

注意与上面的headers={}不同,我们在Python中写了headers=None,在Ruby中写了headers=nil。

Python中的问题是,并不是每次调用该方法时都会创建空字典。它在定义类时创建一次,因此在调用之间共享。这与Ruby中不一样。

下面是一个例子:

class Http:
  @staticmethod
  def get(url, follow_redirects=False, headers={}):
    if "counter" not in headers:
        headers["counter"] = 0
    headers["counter"] += 1
    print(headers)
Http.get("https://example.com")
Http.get("https://example.com", headers={"counter": 100})
Http.get("https://example.com")

输出:

{'counter': 1}
{'counter': 101}
{'counter': 2}

相当于Ruby :

class Http
  def self.get(url, follow_redirects: false, headers: {})
    headers[:counter] ||= 0
    headers[:counter] += 1
    puts headers
  end
end
Http.get("https://example.com")
Http.get("https://example.com", headers: { counter: 100 })
Http.get("https://example.com")

输出:

{:counter=>1}
{:counter=>101}
{:counter=>1}

即使这个问题不会在Ruby中出现,我也想避免。

› 结语

我希望这篇文章给了你一些思考的东西,并向你展示了一些你没有考虑过的好技巧,以及为什么它们很好。

如果你知道实现这一目标的其他方法,而我在这里还没有探索过,我很乐意阅读。