探讨Unix Pipe对高质量软件的启示(下)

29 阅读5分钟

上篇探讨Unix Pipe对高质量软件的启示(上 - 掘金 (juejin.cn)中我讨论了从Unix管道模式中所得到的启示,本篇中将进一步讨论其实践应用。

互联网行业中需求迭代速度非常快,当源源不断的新业务涌来,在DDL的压力下,很多程序员往往会随手找个可以用的地方把业务代码塞进去。或随手加一个判断语句来使这段代码“只对这个业务需求生效”。最终代码就变成一个屎山。

第一个版本的代码长这样:

if a {
    aaa(1)
} else {
    aaa(2)
}

半年后,变成了这样:

if user.group.id in (a,b,c) {
   aaa(a,b,c,d)
} else if user.id = 'xx' and isTest {
   if date > time.Now() { // TODO delete it 
        aaa(b,c,d,e)
   } else {
       aaa(c,c,c,c)
   }
} else {
   aaa(a,a,a,a)
}

这样会有什么问题呢?

最大的问题就是对业务逻辑的修改难以维护。比如对同一个字段的修改、赋值操作分别分布在不同的业务逻辑、不同的代码文件和函数中,且在整个变量的生命周期中被多次改动。最终谁都不知道这个字段在哪里又会被突然改成另一个奇怪的值。慢慢就会演变成如果想要修改这个值,就把新的业务逻辑尽可能往后塞,而这可能又会影响到其它逻辑里的取值。

业务逻辑零散,新人学习成本巨大。同样一个类似的逻辑,因为是在不同时期,由不同的人做的,所以可能代码放的位置也完全不同。如果一个新人想要完整了解某一个具体的业务逻辑,他需要先对完整的项目代码有足够深的了解,并知道这个业务逻辑在整个项目代码中的分布情况。

人天生是懒惰的。除了在制度和激励上的措施外,我们也要反思,通过更好的软件架构能不能避免或减缓上述现象的发生。

我们再次回到文章正题“管道模式”上来。用管道模式能解决这个问题吗?

思考下面的实现方式:

首先将整个接口从接收到请求到最终返回,中间所有的流程梳理出来。比如对购买接口的调用,其代码流程可能如下: (流量治理在外面的中间件里统一做了,这里忽略) (仅是一个示例,不代表真实业务)

请求参数处理 - 用户鉴权 - 字段预处理 - 商品预锁库存 - 价格计算 - 下单

假如整个流程就是一个管道,而每个流程都是管道模式中的一个独立程序,就变成了这样:
httpReqProcess | auth | preProcess | lockStock | calcCost | order

看起来是不是清晰多了。它最大的意义在于,把整个复杂的、长链路的业务逻辑切割成了短的、相对独立 的模块。当有新的业务逻辑出现时,我们可以快速确定到这段业务逻辑属于哪个模块,并将所有相关逻辑都只堆在这一个模块里。

即使有代码质量比较差的新人,这样的设计也可以把屎山的规模缩小到单个模块内。而这个模块的职责仍然是清晰的。

或许有人会说,这跟普通的面向过程不是差不多吗?

从只实现功能的角度出发,所有架构设计都没有意义,因为所有架构最终都能满足实现功能的需要。但架构设计的意义就在于让整个代码的维护成本更低、学习成本更低、更稳定不易出错。

实现

下面给出本例的一个简单实现:

整个管道模式中提炼出三个抽象: 管道Pipe、命令Command、上下文PipeCtx。

如下是代码示例 (仅是示例,纯手写,未经严格测试)

type Pipe struct {
    Name string
    Commands []Command
}
func (p *Pipe) Run(startCtx PipeCtx) error {
    currCtx := startCtx
    for _, cmd := range this.Commands {
        ctx, err := cmd.Run(currCtx)
        if err != nil { return err }
        currCtx = ctx
    }
}
type Command interface {
    Run(ctx PipeCtx) (PipeCtx, error)
}
type PipeCtx interface

// 其中,第一个Command长这样:
type HttpReqCmd struct {
    ...
}
func (c *HttpReqCmd) Run(pCtx PipeCtx) (PipeCtx, error) {
    ctx := pCtx.(*HttpReqCtx)
    request := ctx.HttpReq
    headers := request.Header
    if len(headers) == 0 {
        return nil, errors.New("no http header")
    }
    .....
    return ctx, nil
}
type HttpReqCtx struct {
   HttpReq http.Request
   UserId int64
   ProductId int64
   OrderNum int
   ....
}

下面是一个表示购买商品流程的管道:

buyPipe := Pipe{
    Name: "buy",
    Commands: []Command{httpReqCmd, authCmd, buyPreProcessCmd, productLockStockCmd,
        calcCostCmd, orderAndSettleCmd, ..... 
    }
}
// 使用时,构建好初始的ctx, 然后buyPipe.Run(ctx)

如果有新的业务逻辑,相对独立,放在哪个里面从语义上都不太合适,就实现成新的Command​, 加入到Pipe​中。

扩展

上面提到的管道模式是最简单版本的。可以在它的基础上加更多功能。

条件分支

一个最简单版本的管道相当于一个链表,很自然就可以想到,如果在上面加一些条件分支,就可以变成一个有向无环图了。

实现起来也不是特别麻烦,从链表到有向无环图是怎么升级的,照搬即可。

管道嵌套

Pipe中的一个Command也是一个Pipe, 这样一直嵌套下去。

也是可以实现的。

但是,不建议在管道模式上实现太复杂的逻辑。如果管道自身的逻辑太复杂了就失去通过它来简化业务逻辑的本意了。

可以把这些条件分支之类的要求实现到业务代码中。

如果各种条件分支确实过于复杂,请使用专门的工作流引擎。