likes
comments
collection
share

一些实用的编程模式 | Options模式

作者站长头像
站长
· 阅读数 5

今天开个新系列,讲一些实用的编程模式,每个编程模式学完后,都能马上在实战中应用起来,让我们写出更富表达力、易维护、好扩展、优雅亿点点的代码。

这些编程模式的示例我会用Go来演示,但其实这些模式大多与语言无关,无论你平时主攻Go、Java还是JavaScript 我觉得都能用上。

为避免贴长篇代码,我会适当用一些伪代码,大家理解思路后,可以在我的GitHub仓库 gocookbook 找到完整可运行的代码

访问页面后记得ctrl + F 搜索"Options"关键字。

系列第一篇要分享的编程模式是函数式编程里的Options模式

Options模式解决什么问题

Options模式可以让具有多个可选参数的函数或者方法更整洁和好扩展,当一个函数具有五六个甚至十个以上的可选参数时使用这种模式的优势会体现的很明显,我们还是通过一些例子慢慢感受一下。

比如我们要在项目里封装一个通用的发Http请求的工具函数,它的参数可能会有哪些呢?因为是工具函数,要做到通用就必然需要定义很多能配置HTTP客户端的参数,比如:

func HttpRequest(method string, url string, body []byte, headers map[string]string, timeout time.Duration) ...

函数签名里的返回值这里就省略了,太宽影响阅读,这里大家注意一下。

上面这个工具函数,如果只是做GET请求的话,很多HTTP客户端的设置是不需要设置的,而且超时时间我们一般都会设置一个默认的。如果还按普通定义函数的方法来实现的话,函数逻辑里势必会有不少判断空值的逻辑。


if body != nil {
   // 设置请求体Data
  ......
}

if headers != nil {
  // 设置请求头
  ......
}

调用的时候,调用者的代码也不得不传一些零值给不需要自定义的配置参数。

HttpRequest('GET', 'https://www.baidu.com', nil, nil, 2 * time.Second)

如果是Java的话,其实是可以通过方法的重载解决这个问题,但是如果可选的参数是十几个,各个调用方对可选参数的顺序要求不一样的话,定义这个多重载方法显然不是一个好的解决方案。

另外一种常用的解决方案是,工具函数的签名定义时,不再定义各个可能需要配置的可选参数,转而定义一个配置对象。

type HttpClientConfig struct {
  timeout time.Duration
  headers map[string]string
  body    []byte
}

func HttpRequest(method string, url string, config *HttpClientConfig) ...

配置对象方案的问题

函数签名里通过传递一个配置对象来聚合各种可能的可选参数这个方案,对调用者来说,比上一种方法看起来简洁了不少,如果全都是默认选项只需要给配置对象这个参数传递一个零值即可。

HttpRequest('GET', 'https://www.baidu.com', nil)

但是对于函数的实现方来说,仍然少不了那些选项参数非零只的判断,而且因为配置对象在函数外部可以改变,这就有一定几率配置对象在函数内部未被使用前被外部程序改变,真正发生了相关的BUG,排查起来会比较头疼。

可变参数方案的问题

与配置对象方案类似,如果单纯通过可变参数来解决这个问题,也会有不少问题

func HttpRequest(method string, url string, options ...interface{}) ...

虽然参数是可变的,但是实现方需要通过遍历设置HTTP客户端的不同选项,这就让可变参数固定了传递顺序,调用方如果想要设置某个可选项还得记住参数顺序,切无法直接通过函数签名就确定参数顺序,貌似还不如咱们最原始的解决方案。

使用Options模式的方案

最后,我们来说一下使用Options模式怎么解决这个问题,其实如果你如果使用过gRPC的话,会发现gRPC的SDK里Options模式出现的几率相当高,比如它的客户端方法可以传递不少以with开头的闭包函数方法.

client.cc, err = grpc.Dial(
	"127.0.0.1:12305",
	grpc.WithInsecure(),
	grpc.WithUnaryInterceptor(...),
  grpc.WithStreamInterceptor(...),
  grpc.WithAuthority(...)
)

这些配置方法返回的都是一个名为DialOptioninterface

type DialOption interface {
	apply(*dialOptions)
}

func WithInsecure() DialOption {
	...
}

现在我们就使用Options模式对我们的工具函数进行一下改造,首先定义一个契约和配置对象。

// 针对可选的HTTP请求配置项,模仿gRPC使用的Options设计模式实现
type requestOption struct {
	timeout time.Duration
	data    string
	headers map[string]string
}

type Option struct {
	apply func(option *requestOption)
}

func defaultRequestOptions() *requestOption {
	return &requestOption{ // 默认请求选项
		timeout: 5 * time.Second,
		data:    "",
		headers: nil,
	}
}

接下来我们要定义的配置函数,每个都会设置请求配置对象里的某一个配置

func WithTimeout(timeout time.Duration) *Option {
	return &Option{
		apply: func(option *requestOption) {
			option.timeout = timeout
		},
	}
}

func WithData(data string) *Option {
	return &Option{
		apply: func(option *requestOption) {
			option.data = data
		},
	}
}

那么此时我们的工具函数的签名就应用上上面定义的接口契约

func HttpRequest(method string, url string, options ...*Option) ...

在其实现里我们只需要遍历options这个可变参数,调用每个Option对象的apply方法对配置对象进行配置即可,不用在担心可变参数的顺序。

func httpRequest(method string, url string, options ...*Option) {
	reqOpts := defaultRequestOptions() // 默认的请求选项
	for _, opt := range options {      // 在reqOpts上应用通过options设置的选项
		opt.apply(reqOpts)
	}
	// 创建请求对象
	req, err := http.NewRequest(method, url, strings.NewReader(reqOpts.data))

  // 设置请求头
	for key, value := range reqOpts.headers {
			req.Header.Add(key, value)
	}
	// 发起请求
  ......

	return
}

总结

最后我们的HTTP工具函数的调用方式就变成了,下面这种更灵活更富表达力的方式。

HttpRequest("GET", url)

HttpRequest("POST", url, WithHeaders(headers)
            
HttpRequest("POST", url, WithTimeout(timeout), WithHeaders(headers), WithData(data))

从实现方来看呢?如果后面要给配置对象里增加其他配置项,只需要扩充类型的字段,在定义一个对应的With方法即可,扩展性完全在可接受范围内。

好了Options模式你学会没,想不想赶快用起来,可以在我的GitHub仓库 gocookbook 找到完整可运行的代码

访问页面后记得ctrl + F 搜索"Options"关键字, 就能获得完整可运行的代码示例。下次再遇到类似的场景后记得把今天学到的用上呀。