IT虾米网

编程是门手艺,做一名优秀的将军

developer 2022年05月14日 程序员 173 0

编程是门手艺,NGINX社区的经验分享 一文提过,专业的程序员擅长整体设计和细节处理能力。本文探讨整体设计,尤其是模块化这个技能。

全能天才,Fabrice Bellard

FFmpeg,最强大的流媒体库
QEMU,硬件虚拟化的虚拟机
TCC,迷你CC编译器
QuickJS,100%支持JS语法的C引擎
等等,以上皆出自一人之手,法国天才。
去年QuickJS曾一度刷爆技术圈,NGINX社区的哥们第一时间推荐给我看,并以天才称他。
这软件开拓了我的视野。本文以它为引子探讨我认为非常重要的技能:如何组织代码。

NJS,实现语言引擎真难

私下问过Fabrice Bellard(给QJS提过patch)开发QJS的历程,答案令人惊叹,他只用了两年的业余时间。参与NJS这几年,才深知实现语言引擎有多复杂。
NJS从17年开始,现在差不多完成40%。但基础已经非常良好,后续功能开发会快速很多。而且常用功能都已经支持,其中我支持了模块化,箭头函数,等常用的。语言解析引入了LL(k)。
看似做了些不错的工作。然而跟QJS比,以台球打个比方。一个长距离很准的选手,90%的球都能打进,看似很厉害。但对一个发力非常厉害的人来说,他可能只需80%的准度,再加良好的走位,就能轻松一杆清台。
提QJS不是妄自菲薄,这样对比很不公平。QJS作者本身就是个JS专家,他都能用JS实现虚拟机。参与NJS的人员,包括Igor都不是真正的JS语法行家,JS的语法着实太庞大。我们平时开发过程中,有个社区外的JS行家对我们帮助非常大,简直就是JS活字典。因此在前期,只能靠着语法手册,然后实现,有些实现跟语法的本质有出入的话,又得重头再来。举个例子,早期实现的apply和call两个语法真是让人吃尽了苦头,这也是我最早参与的,因为修复它的bug,做了重构,然后发现社区的人非常接受这种重构的做法,有种碰到知音的感觉。

QuickJS,五万行代码一个文件的软件

我会解释这种做法是合理的。此时必须提出来,后面再详加解释。

模块化,最好的代码组织方式

我在参与NJS时,第一件事就是让它支持模块化编程。NJS刚出来时我就开始关注,后面挺长一段时间,用NJS写代码只能放在一个文件里,这对代码组织是极不友好的。先看下JS的模块化用法:
main.js

/* 自定义模块 */ 
import foo from 'foo.js'; 
foo.inc(); 
 
/* 内置模块 */ 
import crypto from 'crypto'; 
var h = crypto.createHash('md5'); 
var hash = h.update('AB').digest('hex');

foo.js

var state = {count:0} 
 
function inc() { 
    state.count++; 
} 
 
function get() { 
    return state.count; 
} 
 
export default {inc, get}

支持模块化之后,变得非常好用。这个大功能也是NGINX作者Igor亲自帮review和调整的,收获良多。客观讲,JS语法比Lua实在好用太多,NJS目前已经非常稳定,只是功能没那么繁多,推荐轻量应用考虑用NJS,而且社区非常活跃,相信未来可期。
现在轻瞥一下QuickJS的源码。

JSContext *JS_NewContext(JSRuntime *rt) 
{ 
    JSContext *ctx; 
 
    ctx = JS_NewContextRaw(rt); 
    if (!ctx) 
        return NULL; 
 
    JS_AddIntrinsicBaseObjects(ctx); 
    JS_AddIntrinsicDate(ctx); 
    JS_AddIntrinsicEval(ctx); 
    JS_AddIntrinsicStringNormalize(ctx); 
    JS_AddIntrinsicRegExp(ctx); 
    JS_AddIntrinsicJSON(ctx); 
    JS_AddIntrinsicProxy(ctx); 
    JS_AddIntrinsicMapSet(ctx); 
    JS_AddIntrinsicTypedArrays(ctx); 
    JS_AddIntrinsicPromise(ctx); 
    return ctx; 
} 
 
void *JS_GetContextOpaque(JSContext *ctx) 
{ 
    return ctx->user_opaque; 
} 
 
void JS_SetContextOpaque(JSContext *ctx, void *opaque) 
{ 
    ctx->user_opaque = opaque; 
}

所有源代码扔进一个文件里,我看过不少软件的源码,而且是比较完整的。NGINX, Unit, NJS, Lua等,以个人感观而言,QuickJS是最好的。初看有点凌乱,但细看的话(可能需要很熟悉JS语法),绝对的大师之作。
假如想删除某个语法功能,在QuickJS里可以连续的从某行一直删除到另一行,连续的一块。这在其它软件是不可能做到的,要么多个文件都要删除,要么在一个文件也要删除多个不同的地方。我认为这就是模块化的精髓:高内聚。
学过设计原则的同学想必都知道软件要高内聚,低耦合。我的理解是只要做到了高内聚,低耦合就是自然而然的事情。
举个例子,要实现nginx lua模块。有两个重要的功能:nginx模块相关函数,lua封装相关函数。
过度设计方式:

ngx_http_lua_module.c 
/* nginx模块相关函数 */ 
 
ngx_http_lua_request.c 
/* lua封装相关函数 */

合理方式

ngx_http_lua_module.c 
/* nginx模块相关函数 */ 
/* lua封装相关函数 */

CSDN
过度设计是一种很容易踩进去的陷井。
讨论1:
如果有更多的功能,比如http subrequest这种功能进来时怎么办?
建议还是放在同一个文件里,不要被代码行数影响。
讨论2:
又有更多的功能,比如http share memory这种功能进来时怎么办?
是可以考虑独立到另一个文件了,原则就是要找到一个信服的理由,新的功能能独立成一个高内聚的模块。有个特征是它往往会有专门的API,比如共享内存操作的get, set等。
换另一个角度看,一个文件的引入本身也是一种成本,而且比函数级别更高。每次的重构都应该带来实质的价值。这是我坚持尽量放同一个文件的原因。我早期提过几次建议,想对njs做类似的事情,后来证明有些是过度设计的。而有些是正确的,比如把njs_vm.c分成njs_vm.c和njs_vmcode.c。一个负责虚拟机,一个负责字节码处理。

总结一下:
高内聚是最高准则。
引入新文件成本高于函数,要有实质的价值才做。
不要被代码行数影响。
协作只是一种分工,不能做为破坏高内聚的理由。

再谈设计

前面说QuickJS的代码质量非常高,是因为他的设计令人折服。整个QJS的代码行数不到5万,实现了100%的语法,其中还包括非常硬核的大数和正则,都自己造轮子。从整个引擎的实现方面它就做了高度的抽象,而且用的算法非常简单有效。举个例子,JS里对象的属性操作应该是最常用的,比如 a['name']。a和name在语法解析时都是字符串,术语叫token。QJS用一个非常高效的hash实现,将所有JS用到字符串的都包括进去了,代码也很少。

typedef struct JSShapeProperty { 
    uint32_t hash_next : 26; /* 0 if last in list */ 
    uint32_t flags : 6;   /* JS_PROP_XXX */ 
    JSAtom atom; /* JS_ATOM_NULL = free property entry */ 
} JSShapeProperty; 
 
struct JSShape { 
    uint32_t prop_hash_end[0]; /* hash table of size hash_mask + 1 
                                  before the start of the structure. */ 
    JSGCObjectHeader header; 
    /* true if the shape is inserted in the shape hash table. If not, 
       JSShape.hash is not valid */ 
    uint8_t is_hashed; 
    /* If true, the shape may have small array index properties 'n' with 0 
       <= n <= 2^31-1. If false, the shape is guaranteed not to have 
       small array index properties */ 
    uint8_t has_small_array_index; 
    uint32_t hash; /* current hash value */ 
    uint32_t prop_hash_mask; 
    int prop_size; /* allocated properties */ 
    int prop_count; 
    JSShape *shape_hash_next; /* in JSRuntime.shape_hash[h] list */ 
    JSObject *proto; 
    JSShapeProperty prop[0]; /* prop_size elements */ 
};

里面指针还用到负操作, 他是数学行家玩的转。
为什么NJS不能这样呢?依赖,各细节之间相互引用。软件开发中没办法的事情。
还以打球为例,那些走位和发力非常老道的球手,打法往往是简单有效的,不要奇怪为什么有些球不先击打进去,而选择更不好打的,一切在掌握之中。

设计重于实现

这是我这两年比较大的体会。以前会觉得有这设计的功夫,早把东西实现好了,而且认为重构能解决一切的设计不足。这是没错的,问题是花了更多的时间在走弯路。
write some code, think, write more, meditate, write a meaningful commit log, take a sleep, think again, and re-read, split/fold/re-write, think, become happy with the final result.
以上是Unit的负责人给的建议,个人觉得这是一种可行有效的方式。NGINX的http2实现就出自他的手笔。对了,NGINX的http3即将完成。

有方法才有可行

本系列文章都会有实操方法。实践对想提升代码的同学是很有效的方式,我个人觉得学习或写项目是一种方式。
utopia是我写的一个API网关框架,只有一千行代码。里面的一些设计就参考 Unit,尤其是路由部分。我了解他们的设计历程,非常优秀。这是一个非常适合学习的项目。
设计可以聊的实在太多,远不止一文可以讲完,以后会不断的夹杂在其它章节。

评论关闭
IT虾米网

微信公众号号:IT虾米 (左侧二维码扫一扫)欢迎添加!

7. Jackson用树模型处理JSON是必备技能,不信你看