写扩展性好的代码:函数

如何写出扩展性好的代码?这是我工作最近半年来一直在考虑的问题。不管自己做一套系统还是接手别人的项目,只要你的项目需要和别人交互,这个问题都是需要考虑的。我们今天只说说如何写出扩展性好的函数代码。代码都以golang示例。

函数声明

函数声明首先是函数名字要具有自解释性,这个要说到代码注释了,这里就不赘述了。除了函数声明外,还有函数的形参定义。这里以一个例子来说一下扩展性好的函数的参数应该如何定义。

1. 普通函数

假设我们需要一个简单的server,我们可以像下面这样定义,addr表示server启动在哪个端口上。

1
func NewServer(addr string)

第一期的需求很简单,就上面这些足够满足了。项目上线跑了一段时间发现,由于连接没有设置超时,很多连接一直得不到释放(异常情况),严重影响服务器性能。好,那第二期我们加个timeout。

1
func NewServer(addr string, timeout time.Duration)

这个时候尴尬的情况出现了,调用你代码的所有人都需要改动代码。而且这只是一个改动,之后如果要支持tls,那么又得改动一次。

2. 不定参数

解决上面的窘境的一种方法是使用不定参数。下面先简单介绍一下不定参数。第一次接触不定参数是学习C语言中的Hello World的代码中printf,声明如下

1
static int printf(const char *fmt, ...)

C的函数调用可以简单看成call/retcall的时候会把当前的IP保存起来,然后将函数地址以及函数参数入栈。printf的fmt中保存了参数的类型(%d表示int,%s表示string)并能计算出个数,这样就能找到每个具体的参数是什么了。golang也是支持不定参数的,比如我要实现一个整数加法。

1
2
3
4
5
6
7
8
9
10
11
func Add(list ...int) int {
sum := 0
for _, x := range list {
sum += x
}
return sum
}

func main() {
fmt.Println(Add(1,2,3)) //6
}

上面是所有的变参都是同一种类型,如果是不同的类型可以使用interface,使用反射来判断其类型。

1
2
3
4
5
6
7
func Varargs(list ...interface{}) {
for _, x := range list {
if reflect.ValueOf(x).Kind() == int {
//
}
}
}

但是如果是我们自己定义的函数的话,类型通常是知道的,也就不需要上面那么麻烦地再去判断一次,可以直接进行类型转换。

1
2
3
4
5
func Varargs(list ...interface{}) {
//通过interface.(type)将interface类型转换成type类型
fmt.Println(list[0].(int))
fmt.Println(list[1].(string))
}

但是这么做比较危险,使用的时候必须严格按照说明进行传参,任何一种类型不正确,程序将panic。还有一个问题就是不定参数不能为空,或者说传入的实参必须是形参的一个严格前缀。

3. 封装成 struct

相比于上面两种方法更好一点的是把所有参数封装成struct,这样函数声明看起来很简单。

1
2
3
4
5
6
7
type Param struct {
x int
y string
...
}

func Varargs(p *Param) {}

封装成struct的方式应该是一种对参数比较好的组织形式,之后函数不管怎么扩张,只需要增加struct成员就好,而不需要改变函数声明了。而struct的坏处在什么地方呢?比如上面的Param.x是int型,如果我们不设置x,也就是下面这样传参。

1
2
3
4
5
p := &Param{
y: "hello",
}

Varargs(p)

这个时候Varargs看到的Param.x的0。你让Varargs怎么想?用户没有设置x(忘记设置?想使用默认值?)?用户把x设置成0?这真的有点尴尬。但是这个问题还是有解决方案的?1.避开默认值,int型不使用0,string类型不使用””。2.使用指针,用户没有设置的时候x==nil,设置的时候对x解引用(*x)取得值。这两种方式不管怎么来看,都是十分的反人类,一点也不simple。

4. option

option的方式的最早是由Rob Pike 提出,Rob Pike就不做介绍了,感兴趣的可以看他的wiki连接。我们把option参数封装成一个函数传给我们的目标函数,所有相关的工作由函数来做。举个栗子,我们现在要写个Server,timeout和tls都是可选项,那么可以像下面这么来写(所有error handle都省去)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func NewServer(addr string, options ...func(*Server)) (*Server, error) {
srv := &Server{
Addr: addr,
}

for _, option := range options {
option(srv)
}

return srv
}

func timeout(d time.Duration) func(*Server) {
return func(srv *Server) {
srv.timeout = d
}
}

func tls(c *config) func(*Server) {
return func(srv *Server) {
Tls := loadConfig(c)
srv.tls = Tls
}
}

//使用
src, err = NewServer("localhost:8080", timeout(1), tls(path/to/cert))

这么写的好处一目了然,横向扩展起来特别方便,而且解决上面的提到的基本所有的问题。

函数实现

正常单一功能的函数实现没有什么好说的。如果需要根据不同的条件来执行不同的行为的话,这个应该怎么做的?举个例子,我现在在公司做一个优惠券的项目,用户领券和使用券的时候有一些规则,比如每人每日限领3张等。这些规则肯定不会一成不变,也许第一期是2个规则,第二期就变成4个规则了。正常可能会像下面这么写。

1
2
3
4
5
6
7
8
9
func ruleVerify() {
//process
if cond1 {
//
} else if cond2 {
//
}
...
}

或者用switch-case。虽然很多人说switch-case写起来要比if-else更好看或者高端一点,其实我并不这么觉得。if-else和switch-case本质上并没有什么区别,扩展的时候如果需要多加一个条件分支,这两种方法改动起来都比较丑。下面说说我的解决方案。

1. 类工厂模式

熟悉设计模式的肯定对工厂模式肯定不会陌生。工厂模式的意思是通过参数来决定生成什么样的对象实例。我这里并不是说直接使用工厂模式而是使用工厂模式这种思想来编程。举个典型的例子,webserver的router实现方式:根据不同的路由(/foo,/bar)对应到不同的handler。光这么说,可能很多人还是不明白这种方式的扩展性好在什么地方。下面从0到1来感受一下。
首先根据不同的条件对应不同的handler,这个最简单的是使用Map来实现,没有问题,但是map里面存什么呢?如果我要增加一个条件以及对应的处理函数的时候怎么做呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//存放 <cond, handler> 对应关系
var mux map[string]func(option *Option) error

//注册handler
func register(key string, f func(option *Option) error) {
if mux == nil {
mux = make(map[string]func(option *Option) error)
}
if _, exist := mux[key]; exist {
return errors.New("handler exist")
}
mux[key] = f
}

//factory
func factory(option *Option) error {
return mux[option.Key](option)
}

代码主要分三个部分:1.mux用来存放cond和handler的对应关系;2.register用来注册新的handler; 3.提供给外部的代码入口。下面到了最核心的问题了,如果某一天PM和你说:大神,我们现在要新加一个用户用券规则。这个时候你就可以和她说:没问题。代码上的改动只需要实现一个新增规则的实现函数,同时调用一下register即可。

总结

踏出校门一年多了,我经常在想什么样的代码才是好的代码?我相信每个人都会有不同的答案。从我个人角度来看,扩展性确实是衡量好代码的一个很重要的指标。在做业务系统的时候经常为了赶进度写的代码而忽略扩展性,最后随着版本迭代发现之前的代码框架越来越臃肿,不得不进行重构。重构,从某种意义上来说,就是填坑。

参考