关于编码的一点“思考”

发布于: 2020 年 06 月 28 日
关于编码的一点“思考”

上学的时候觉得学够了各种规则、定律、解题模板什么的,就总是对这些东西没什么兴趣。所以刚开始写代码的时候触到 MVC 模式,尝试过几次之后就觉得 MVC 什么的并不是好的实践,一个人写个小网站啥的干嘛搞这么复杂,添加一个小功能到处都要改动累不累啊。后面自学 Python 也是热衷于搞各种黑魔法和奇技淫巧,直到后面看到 The Zen of Python 和各路大佬的翻译,才懵懂的感受到那么一点味道。说起来也算是一个喜欢扣细节的人,所以后面尝试翻译过 PEP 8 -- Style Guide for Python Code 就在写代码的过程中去应用这些细节上的规则,后面也是得到了正反馈,所以学习一门新语言和工具的时候总是会先去看看相关风格指南什么的文档。

看过了很多风格指南啥的(当然更多的是依赖工具),也尽量在写代码的过程中去写一段一段“高效有美感”的代码,但后面在工作中开始负责一个比较大的模块从零撸一个项目,不断重构添加新功能以及阅读代码的时候才深刻的感受到这些东西还不够,有种感觉是在局部细节上发力过猛而忽视了他们之间的关系。

接下来要各种偷换概念和胡扯了,初中思想品德还是社会科学课的老师说过一句让我记忆犹新也比较绝对的话:

判断题里面只要是绝对的说法都是错误的。

拆分

我觉得往小了说拆分就是要理清各个数据结构的关系,往大了说就是软件构架,最重要的就是理清关系,定义边界。

水平拆分

很多模式和套路其实都是在讲水平拆分,比如 MVC。水平分层的好处就在于各个层面具体的实现能被灵活替换。所以我觉得水平拆分是最考验抽象能力的,整个软件水平分层做职责分离后,每一层之间接口上的依赖是无法避免的,接口抽象设计的好不好是关键,搞不好就耦合严重互相掐架而且不能做到灵活的用不同的实现来替换不同的水平层。对于应用场景比较单一的项目似乎没有必要做水平分层上的设计,但一些情况下分层其实是能让程序逻辑、数据流向更清晰的,也可以从一定程度上减少耦合。

比如最常见的服务的客户端 SDK,一般情况下都分为用户接口层、逻辑层、协议层和传输层,当然大部分情况下我们只需要做用户接口层和逻辑层,下层直接被 HTTP、GRPC 等协议的工具包屏蔽了,也没有了拆分的必要。而更复杂和特殊的场景下只是将 HTTP、GRPC 等协议作为传输层的一个可选项,会使用 TCP 甚至 UDP 来作为传输层,这种情况下往往涉及复杂的编解码将字节流映射成内存中的数据结构(协议层),如果把逻辑层、协议层、和传输层糅合在一起,如下:

type Request struct {
X int32
Y int32
Z int32
}
type Response struct {
R int32
}
type Client struct {
conn *TCPConn
}
func (c *Client) Call(req Request) (Response, error) {
// a lot of logic ...
c.conn.Write([]byte("->"))
c.conn.Write(itob(req.X)) // itob: int32 -> bytes (big endian)
// c.conn.Write([]byte(","))
c.conn.Write(itob(req.Y))
// c.conn.Write([]byte(","))
c.conn.Write(itob(req.Z))
c.conn.Write([]byte("#"))
resp := Response{}
buf := [4]byte{}
c.conn.Read(buf[:2])
if string(buf[:2]) != "<-" {
return resp, fmt.Errorf("xxx")
}
c.conn.Read(buf[:4])
resp.R = btoi(buf[:4]) // btoi: bytes -> int32 (big endian)
n := c.conn.Read(buf[:1])
if string(buf[:1]) != "#" {
return resp, fmt.Errorf("yyy")
}
// a lot of logic ...
return resp, nil
}

如果我们要改变 Request/Response 的编解码方式,我们要在一大坨逻辑中改写一大坨 Write/Read,那么把 Write/Read 相关的代码拆分出来,这样改动就和其它逻辑分离了,但是如果不同的 Write/Read 方式适用于不同场景(性能/资源利用率)同时存在,我们可能这样做:

func (c *Client) CallV1(req Request) (Response, error) {
return c.call(req, false)
}
func (c *Client) CallV2(req Request) (Response, error) {
return c.call(req, true)
}
func (c *Client) call(req Request, useV2 bool) (Response, error) {
// a lot of common logic ...
var resp Response
if useV2 {
err := c.writeRequestV2(req)
err = c.readResponseV2(&resp)
} else {
err := c.writeRequestV1(req)
err = c.readResponseV1(&resp)
}
// a lot of common logic ...
}
func (c *Client) writeRequestV1(req Request) error {
// V1...
}
func (c *Client) writeRequestV2(req Request) {
// V2...
}
func (c *Client) readResponseV1(resp *Response) error {
// V1...
}
func (c *Client) readResponseV2(resp *Response) {
// V2...
}

到后面我们可能变成这样(其中 WriteRequestVn/ReadResponseVn 之间还会互相依赖一些公共的可重用的方法):

func (c *Client) call(req Request, version Version) (Response, error) {
// a lot of common logic ...
var resp Response
switch version {
case V1:
case V2:
case V3:
case Vn:
}
// a lot of common logic ...
}
func (c *Client) writeRequestVn(req Request) {
// Vn...
c.writeCommonFromV1()
c.writeCommonFromV5()
// ...
}
func (c *Client) readResponseVn(resp *Response) error {
// Vn...
c.readCommonFromV3()
// ...
}

实际上更好的方式是将 WriteRequest/ReadResponse 抽象出来,对于不同的版本做组合:

type Protocol interface {
WriteRequest(Request) error
ReadResponse(*Response) error
}
type Client struct {
proto Protocol
}
func NewClient(proto Protocol) *Client {}
func (c *Client) Call(req Request) (Response, error) {
// a lot of logic ...
err := c.proto.WriteRequest(req)
var resp Response
err = c.proto.readResponse(&resp)
// a lot of logic ...
}
// protocol/v1/protocol.go: Implementation of V1
type protocolV1 struct {}
// protocol/vn/protocol.go: Implementation of Vn
type protocolVn struct {}

这样一来只需要使用一个不同协议的实现 NewClient(NewProtocolV3()) ,协议之间通用的编解码方法可以再做抽象没有直接的相互依赖,更加内聚,那么接下来如果我们要替换 TCPConn 的实现或者在其上做一层 Buffer,我们很高兴的发现 Golang 里面的 net.Conn 是一个抽象拿过来用就好了,但是标准库虽然足够稳定但可能还是有我们不需要的方法,还有可能我们并不使用网络栈来收发数据,那这个语义在一些场景下有点不对,再声明一个抽象如下:

type Transport interface {
Write([]byte) (int, error)
Read([]byte) (int, error)
Close() error
}

所以最终我们会有如下的使用方式:

NewClient( NewProtocolV1( NewTransportV5() ) )

分层抽象屏蔽了下层的实现细节,有良好的可替换性,大多数软件项目都用了分层的思想比如网络协议栈等。

垂直拆分

垂直拆分貌似没什么好说的,大概是本身很直观又没那么容易察觉的一些东西?尴尬了。

垂直拆分的高层视角应该更多的是和业务场景相关的,需要对业务场景有深刻的理解,拆分之后每个单元都可以有最大的独立性,更加利于多人之间或者不同团队之间的协作。而之前水平拆分在各层的接口抽象都有良好的规范和定义的情况下才利于多人友好协作。

组件/模块

我很多时候比较偏执于可以将任何功能模块都能当做一个工具库来使用,最后在一个高的层次组合起来变成一个完整的软件,而一个工具库肯定是有比较高的内聚性的,其中透明可控是最重要的。

源码文件变更数目

限于我有限注意力和脑容量,写代码的时候会直接粗暴的认为一次变更牵涉到的文件数目越少则功能模块的内聚性越高(除开重构和新增文件,特别是涉及到的功能模块之外的文件数),举个简单的例子,有很多开源项目的代码都喜欢这样写:

// handlers.go
func registerHandlers() {
router.GET("/users/:id", handleUserGET)
router.POST("/users", handleUserPOST)
// Hundreds of lines of similar code
router.GET("/posts/:id", handlePostGET)
router.POST("/posts", handlePostPOST)
// Hundreds of lines of similar code
}
// users.go
func handleUserGET() {}
func handleUserPOST() {}
// posts.go
func handlePostGET() {}
func handlePostPOST() {}

这样有一定的好处,就是阅读源代码的时候知道整体有哪些 API 接口,但阅读源码更多的时候是有目的很少有这样一行行过的,当这些 API 接口非常多的时候需要更多额外的操作比如搜索,特别是当比如 posts 这块添加新 API 时为了放在一起鼠标得滑好一会或者需要搜索。所以我更倾向于下面这样,改动的时候只需要动一个文件,而且阅读源码的时候能更快更清楚的看到某个方法对应了哪个 API 接口,而额外生成的文档是了解整体有哪些 API 接口的最好方式:

// xxx.go
func registerHandlers() {
registerUserHandlers(router)
registerPostsHandlers(router)
// ...
}
// users.go
func registerUserHandlers(router Router) {
router.GET("/users/:id", handleUserGET)
router.POST("/users", handleUserPOST)
}
func handleUserGET() {}
func handleUserPOST() {}
// posts.go
func registerPostsHandlers(router Router) {
router.GET("/posts/:id", handlePostGET)
router.POST("/posts", handlePostPOST)
}
func handlePostGET() {}
func handlePostPOST() {}

全局变量

已经有足够多的关于为什么要少用全局变量的论述,甚至还有直接不支持全局变量的语言,我觉得挺好。

全局变量虽然是全局的,但是它的一大缺点我却觉得是其生命周期的不确定性,对于其它模块而言最好的假设是全局变量已经初始化过的不会销毁,开发的时候慢慢加上去可能还挺爽,但是读代码的时候就是灾难级别的存在,再者软件变得复杂随着新旧功能的交替需要做兼容的时候给穿插在代码各处的全局变量加上条件检查做测试什么的可能要崩溃。

还有一类全局变量我觉得是那种一路到底通过参数传递并共享给多个模块的方式(包括平级和嵌套级别的模块),比如全局的配置对象,这种至少是透明的,缺点是被所有模块依赖了,删除一个字段提心吊胆,更改重命名会波及多个组件模块,所以还是别偷懒锻炼下手部力量各个模块组件单独定义仅与自己相关的配置对象,提高内聚。

日志

我觉得一个比较低层的组件模块是否足够透明可控就看是否需要额外的日志信息输出。

作为一个库而言,需要日志的地方一般都是内部逻辑太复杂,导致无法将必要的状态充分暴露给上层使用者。日志本质上是一种信息反馈方式,缺点在于它是隐式的,上层的代码调用方无法直接根据这些反馈来做一些处理逻辑。比如:

// lib
func Run() {
// ...
go func() {
if err := work(); err != nil {
LOG("exit with error: %v", err)
}
}()
}
// caller
{
sigc := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGTERM, syscall.SIGINT)
lib.Run()
LOG("exit with signal: %v", <-sigc)
}

又或者这样:

// lib
func Run(ctx context.Context) {
// ...
go func() {
if err := work(ctx); err != nil {
LOG("exit with error: %v", err)
}
}()
}
// caller
{
sigc := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGTERM, syscall.SIGINT)
ctx, cancel := context.WithCancel(context.Background())
lib.Run(ctx)
LOG("exit with signal: %v", <-sigc)
cancel()
}

上面两者都存在的问题是 Run(内部的 goroutine)可能已经异常退出了,caller 还阻塞在那里而没有感知到这个状态。下面这样看起来是繁琐点但是更透明可控:

// lib
func Run(ctx context.Context) error {
// ...
return work(ctx)
}
// caller
{
ctx, cancel := context.WithCancel(context.Background())
errc := make(chan error, 1)
go func() {
if err := lib.Run(ctx); err != nil {
errc <- err
}
}()
sigc := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGTERM, syscall.SIGINT)
select {
case err := <-errc:
LOG("exit with error: %v", err)
case sig := <-sigc:
LOG("exit with signal: %v", sig)
}
cancel()
}

但有的时候没有更好的方式来简化逻辑了,也有可能有些信息对于排障是很必要的,也没有更优雅的方式吐露给上层,这个时候日志的吐露方式应该是可控的,由上层决定是否需要打印相关日志以及日志的格式应该是怎么样的。

日志是必要的,但使用的时候应该是克制的,已经见过太多的代码在不同的层面上重复输出形式不同但是意义相同的日志信息。更合适的方式就如我前面所述,应该在一个比较高层的模块中输出必要日志信息,而低层的模块应该尽量做到透明可控。

封装

过度封装可能会带来更差的控制能力,更差的控制能力进而会导致更多畸形的封装。

比如有些代码库通过开关参数什么的来决定是否开启某项功能,将一些更精细的控制权屏蔽掉了,当关掉这个功能后,上层却无法优雅的用一个更好的实现来做替代,只能去改源码。

而有时候不做封装又可能会给上层调用者带来心智负担,挺难的。

比如下面这个有点奇怪的例子:

type X struct {
// xxx
}
func (x *X) GetA(x string) (string, error) {
x.l.RLock()
// A...
x.l.RUnlock()
return
}
func (x *X) PutA(x, y string) error {
x.l.Lock()
// A...
x.l.Unlock()
return
}
func (x *X) GetB(y string) (string, error) {
x.l.RLock()
// B...
x.l.RUnlock()
return
}
func (x *X) PutB(x, y string) error {
x.l.Lock()
// B...
x.l.Unlock()
return
}

看起来没什么问题,但是假设我们在调用 GetA 并且参数是 "only me" 出现错误的时候需要做一次 PutA("only me", "special one"),这个时候的问题在于两次调用的间隔是没有锁保护的可能会被其它线程调用 PutA 导致其它线程的写被覆盖而丢失。那么我们可能添加另一个函数:

func (x *X) GetOrPutA(x, y string) (string, error) {
x.l.Lock()
// A...
x.l.Unlock()
return
}

到后面再假设我们要同时 GetA 和 GetB (或者 PutA&PutB)并保证这两者是同一时刻发生的,于是:

func (x *X) GetAandB(x, y string) (string, string, error) {
x.l.RLock()
// A,B...
x.l.RUnlock()
return
}
func (x *X) PutAandB(x1 y1, x2, y2 string) error {
x.l.Lock()
// A,B...
x.l.Unlock()
return
}

到后面这种组合变多,封装之后各种方法会变得越来越奇怪,那直接把锁抛给上层吧,这就给上层带来了一定的心智负担:

func (x *X) RLock() {}
func (x *X) RUnlock() {}
func (x *X) Lock() {}
func (x *X) Unlock() {}
func (x *X) GetX(x string) (string, error) {
// X...
return
}
func (x *X) PutX(x, y string) error {
// X...
return
}

一个单独的工具库最理想的形态应该是既有高阶功能的封装,又暴露了足够的低层接口和控制权,这样就能一定程度上解决是不是要封装的问题。

依赖

一定要搞清楚依赖是不是稳定可控的、有没有解释权,不然再弄一层吧..

The Zen of Python

The Zen of Python 写得是真好!

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

原文链接

发布于: 2020 年 06 月 28 日 阅读数: 15
用户头像

damnever

关注

这个人有点.. 2017.10.22 加入

about.html

评论

发布
暂无评论
关于编码的一点“思考”