前言
Unix是开源运动历史上最著名的代表之一,而如今前端是开源运动中最活跃的一支队伍,我相信它们两者之间一定有共通之处。读懂此书,可以让我们以后看待软件开发时角度变得不那么狭隘。
内容
前面三章精要的概括了Unix的哲学思想、历史发展、以及和其他操作系统的对比。
第一章:哲学
第一章开门见山, 鞭辟入里地诠释了Unix的灵魂所在:一门技术的编程艺术和设计哲学。
为什么它的生命力如此长久?作者列举了两个原因:一是伴生的C语言的庞大影响力,二是带目录节点的树形文件名字空间和用于通信的管道机制。
支持者认为Unix有什么优点呢?主要如下:
- 开源软件,同僚复审
- 跨平台可移植性和开放标准
- 与Internet的融合
- 开源社区
- 简洁灵活
- 让编程变得有趣
- 设计思想经典,广泛被借鉴
这些优点同样值得前端项目借鉴,大家想想jquery、vue、react、angular等是不是或多或少地符合以上几点?
当然Unix也不全是优点,它在商业上的不成功和追随者的过于狂热成了反对者的诟病之处。
另外在设计理念上,由机制而不是策略主导设计,认为最终用户比设计人员更清楚自己想要什么也导致了一些问题。不过,时间证明,这才是保持生命力长久的秘诀。
至于具体优点有哪些,简直是罗列不过来,四个大前提,五个原则,十七点小的概况。不过总的来说就是开发人员需要分清轻重缓急、怀疑一切,并以幽默乐观的态度面对一切。一言蔽之:Keep It Simple,Stupid。
本章的最后描述了这些设计哲学是如何应用在Unix中的,我们前端同样也可以应用,比如著名的前后端分离思想,协议文本化(http/json)等。
- 依赖转置
- 数据流文本化
- 数据库和应用协议文本化
- 前后端分离
- 尽可能先写原型
- 恰当混用编程语言
- 宽收严发
- 不需要丢的信息绝对不丢
- 小就是美
- 追求软件设计的卓越化
第二章:历史
Unix的发展,作者使用了三重境界来描述,和王国维的“昨夜西风凋碧树。独上高楼,望尽天涯路。衣带渐宽终不悔,为伊消得人憔悴。众里寻他千百度,蓦然回首,那人却在,灯火阑珊处。”三重境界颇为相似,值得深思。
简单评价一下Unix的历史 :起于兼容分时系统,以简单好用走遍江湖,广纳各高校贡献,虽失势于商业,终借Linux重获新生,涅槃腾飞。
那推动Unix发展的英雄们都是谁呢?自然是hacker们,他们是计算机世界的江湖和侠客,虽然在社会和高校中各成一派,不过在经历过互联网大融合(Unix和TCP/IP、ARPANET融合)和自由软件运动后Unix文化已经上升到一种意识形态的层面,协同开发也成为一种趋势,分权、公开、同僚复审的特点在其中体现的淋漓尽致,最终导致了开源运动的兴起。
老派Unix阵营在商业上是吃过亏的,而开源运动以一种更亲和市场、更少对抗性的方式把软件介绍给外部世界,从而也弥补了商业上的不足。
可以这样说,软件离开源越近越繁荣,保持灵活性,别和低价而灵活的方案较劲,就可以得到长久而旺盛的发展。
第三章:对比
这一章作者对比了Unix和其他操作系统的设计和编程习俗,虽然其中举的很多例子已经随风而逝了,但是其核心理念的对比还是非常具有参考价值的。
首先,作者对操作系统的风格元素进行了对比, 因为它反映了操作系统设计者的意图,体现了成本和编程环境的限制对设计的均衡影响,更重要的是这种文化会随机漂移,影响其他软件。
具体又从下面几个角度进行详细说明:
- 统一性理念
Unix一切皆文件以及管道概念。
- 多任务能力
即多进程并发能力,Unix有抢占式多任务能力。
其他操作系统有协作式多任务能力。
- 协作进程
Unix具有低价的进程生成成本、简便的IPC、以及能组合各种管道过滤器小工具,这就避免了多线程的坑,使系统的各个部分容易合作。
- 内部边界
以程序员最清楚一切作为准绳,从而采取多用户控制权限,在系统内部设置三层内部边界:内存管理、多用户权限组、涉及安全的可信代码块。
- 文件属性和记录结构
Unix没有,因为觉得记录结构是一种鸡肋的存在。文件属性可以帮助理解文件,但在面向字节流工具和管道的世界中会有语义问题。
- 二进制文件格式
Unix采用文本格式,而二进制的兼容性和灵活性不好。
7.首选界面风格
Unix采用CLI风格,而其他操作系统有GUI风格,虽然GUI或者CLI没做好都有问题,但CLI更易于程序员得到自己想要的东西。
- 目标受众
Unix为技术用户而设计,其他操作系统有为服务端、客户端、最终用户、单机、联网等受众设计的。
- 开发的门槛
开发门槛是由开发工具的金钱成本、成为熟练开发者的时间成本、甚至文化门槛组成,Unix支持轻松编程、玩家文化和精英编程文化。
- 具体操作系统比较
- VMS大全肿,商业领域尚可容忍
- MacOS的统一性理念是Mac界面方针,边界脆弱,MacOS X融合Unix特点
- OS/2单用户系统,不灵活
- Windows辣鸡但胜在商业
- BeOS深入线程化、专攻大量数据处理,死在商业
- MVS死板,用于大型机
- VM/CMS是Unix祖先,在大型机领域如鱼得水
- Linux继承Unix思想,更进一步连接世界和人
最后作者总结,种瓜得瓜,种豆得豆,一个好的操作系统必须可移植、支持网络、把握客户端的力量。从这里我突发奇想,会不会操作系统的未来就是浏览器呢?
设计思想
从第四章到第十三章都是展开描述Unix各个设计思想的精髓,配有大量的例子,虽然这些例子大部分已经湮没在历史的长河里,不过对当今的软件开发仍然具有重要借鉴意义。
第四章:模块性(保持清晰,保持简洁)
随着代码的复杂度日益增加,我们需要使用子程序、库、进程等来划分代码。比如可以用定义清晰的接口把若干简单模块组合起来,这样把程序划分开来,并且让它们易于协作,这是良好的设计方式。好的软件满足模块化、正交性、紧凑性三个特点。
而模块化最突出的特点就是封装。模块间通过一组严密、定义良好的程序调用和数据结构来进行通信。
有几点需要注意:
- API是实现和设计间的滞塞点
- 简洁清晰的API表明设计是良好的
- 模块不宜过多也不宜多少
模块化另一处特点是紧凑性和正交性,一个软件,设计和人类使用习惯相符就是紧凑的,任何操作没有副作用就是正交的。
但紧凑意味着精炼,实际上的软件基本上是半紧凑型的,因为如果编程者需要记忆的条目数大于七说明API不是紧凑型的。比如C和Python是半紧凑的、C++是反紧凑的,造成这种现象的原因是有时候为了性能和适应范围等要牺牲紧凑性。
正交性代码在源头上减少了bug的可能性,更容易文档化和复用,重构根本的目标就是提高正交性。
实践当中如何体现紧凑型和正交性呢?作者告诉我们需要遵循SPOT原则、围绕强单一中心、时刻考虑分离的价值。
SPOT原则即真理的单点性,比如通过重构去除重复代码保留真理:
- 去除重复数据->让代码生成程序
- 去除重复代码知识点->让代码生成部分文档
- 去除重复头文件和接口声明->让代码生成头文件和接口声明
- 让数据结构“无垃圾,无混淆”
强单一中心即围绕“解决一个定义明确的问题”设计程序,例如diff算法、grep模式匹配、yacc生成语法解析器等都是专门为了解决某个问题才出现的。
分离的价值指从零开始,先去想尽量少的能做的事情,有点类似前端的渐进增强思想。
在进行模块化之前,我们还需要考虑一个问题:那就是软件的层次。
大体可以分为自顶向下和自底向上的层次,自顶向下适用于精确、稳定、底层自由的软件,自底向上适用于探索性的编程,同时抛弃的代码也比另外一种少。我们需要根据情况去设计,要么抽象化细节,要么围绕某个模型组织代码,而实际情况往往是结合两种方式设计程序,这其中需要胶合层用来协调。
但胶合层要尽量薄,可以看做是分离原则的升华,C语言就是一个很好的胶合层。
胶合层的应用之一就是各个共享库。虽然理论上胶合层不应存在,只有多变的策略和不变的机制。
另外作者讨论了OO的思想,他指出OO只在某些领域适用,Unix程序员们大多是持怀疑态度的,因为堆砌抽象层是很累人的事情,容易陷入过度分层,而且过早设计丧失了优化的机会,以至于说:“过早优化就是万恶之源。”
这章的最后,作者设计了几个问题供读者自测,看自己的代码是否遵循了模块化的原则:
- 全局变量有多少?
- 单个模块的大小是否合适?
- 模块内的单个函数是否太大?
- 代码是否有内部API?
- API的入口是否超过七个?
- 模块的入口点分布怎么样?
第五章:文本化(好协议产生好实践)
上一章讲了如何把程序拆分成若干个模块,这章就关于这些模块间如何通信给出了答案。比如用于协作通信的应用协议如何设计,当然还有一种文本化的应用场景:数据存储,也会提到。
- 为了便于数据传输,通常需要序列化(列集)和反序列化(散集),比如前端中JSON的parse和stringify方法。
- 设计协议时需要考虑互用性、透明性、可拓展性和经济性。
- 性能不需要最先考虑,不然可能会是一种过早优化。
- 数据文件和控制文件的信息流方向是不一样的。
文本化最重要的特点是透明、可拓展。
比如Unix口令格式就是牺牲性能换透明和可拓展性的例子,.newsrc格式同样是舍经济性而取透明性和可操作性。但也有适用于二进制的情景,比如PNG格式。
随后作者又道出了数据存储文本化非常重要的一个特点:拥有一套句法和词法约定的数据文件元格式。具体给了几个例子,JSON是我加上去的。
格式名 | 特点 |
---|---|
DSV(分隔符分隔值) | 使用冒号分隔值,通过反斜杠转义符处理特殊情况,和CSV形成鲜明对比 |
RFC 822 | 使用Tab或者Space来延续,空行解释为结束。应用于邮件和HTTP协议,有多个记录的时候边界可能不明显 |
Cookie-Jar | fortune使用的格式,使用%分隔,适用于结构不易区别的文本段 |
Record-Jar | 前两个格式的混合体,使用%%\n分隔,适用于可变字段的集合 |
XML | 类似于HTML、需要专门的解析器,适用于复杂的问题(比如递归嵌套) |
Windows INI | 使用名称-属性对分隔,比较鸡肋,复杂数据不及XML,简单不如DSV |
JSON | 使用键值对分隔,如今比XML流行 |
那Unix对文本化格式的约定是什么样的呢?
- 如果可能,以新行符结束的每一行只存一个记录。
- 如果可能,每行不超过80个字符。
- 使用#引入注释。
- 支持反斜杠转义约定。
- 在每行一条记录的格式中,使用冒号或者任何连续的空白作为分隔符。
- 不要纠结tab和space。
- 优先使用十六进制而不是八进制。
- 对于复杂的记录,使用“节”,如果有多行,使用%%\n等作为分隔符。
- 节格式中,支持连续行。每行一个记录字段或者用冒号终止字段名关键字作为引导字段。
- 格式可以自描述,不然就设立一个版本号。
- 注意浮点数取整问题。
- 不要压缩一部分。
前面花了这么多力气描述数据存储文本化,当然是给描述协议文本化做铺垫。因为适用于数据文件格式的好处同样也适用于协议。
协议设计另外需要注意的点就是要牢记端对端设计原则,包括安全、认证、性能。
同样举了例子,关于邮件协议的:
- SMTP发邮件,设计良好。
- POP3收邮件,和SMTP如出一辙。
- IMAP收邮件,希望取代POP3,但是加重了服务端的载荷减轻了客户端的压力。
最后话题一收,开始阐述应用协议元格式:文本格式、使用单行请求和响应,有效载荷数据多行,可以随时拓展。
需要解决的问题是简化网络间事务处理的序列化操作,因为网络的带宽昂贵的多。比如HTTP,非常简单和通用,需要在安全和便利间做出抉择,基于HTTP协议的其他协议很方便,但是缺点也很明显:完全客户端驱动,要额外接收HTTP报警信息。
还有其他的例子,比如BEEP使用二进制包序列,且支持服务端推送信息,XML-RPC、SOAP、Jabber,XML...虽然它们已经随着历史逐渐没落了,但它们的优点应该会被未来的HTTP2吸收,到时候给我们一个更强大的HTTP。
第六章:透明性(来点光)
优雅软件的第三点特点是什么呢?可显和透明性。
透明性指可以预测到程序行为的全部或者大部分情况,可显性指帮助人们对软件建立正确的“做什么、怎样做”的观念。
可以从用户(UI)和程序员(代码)的角度来看待可显性和透明性。
举几个例子:
实例名 | 特点 |
---|---|
audacity | 音频软件,UI透明 |
fetchmail | 邮件软件,-v选项使得程序可显,获得“防弹程序”荣誉 |
GCC | 编译器,一系列处理阶段都是为了可显而设计的,例如dif |
kmail | 邮件程序,UI可显透明,可以访问具体细节但是又不显眼 |
SNG | 图形文本转换软件,可显但不是很透明 |
Terminfo | 数据库,可显透明,使用文件系统作为数据库 |
Freeciv | 游戏,数据文件以文本格式编写 |
那怎样设计软件才能实现透明性和可显性呢?这就需要我们专注代码同其他人交流的方式了。
- 不要叠放太多抽象层,增加透明性。让其他人能够预测程序行为。
- 编码要求:调用深度要浅、代码要有明显的不变性、API正交、全局设置唯一的记录器、实体一对一映射、容易找到给定部分、避免增加特殊情况、避免意义含糊的常量。
- 避免过度保护,对于错误调试应该是透明的。
- 使用可编辑的表现形式:编写文本化器或者浏览器。
- 便于故障诊断和故障恢复,实现健壮性。
除此之外,还要为可维护性而设计,维护性就是其他开发者能够顺利地理解和修改软件:
- 宁愿重构,都要抛弃辣鸡代码。
- 努力让代码成为活代码而不是睡代码和死代码,吸引别人来开发。
- 包含开发者手册比用户手册更有效。
第七章:多道程序设计(分离进程为独立的功能)
前面三章告诉我们软件需要有模块性、文本化、透明性、可显性、可维护性。这一章讲述的部分更加接近实现:如何将大型程序分解成多个协作进程,又称多道程序设计。
首先,我们需要遵循做单件事并做好的原则,提倡分解程序。
有三种方法:降低进程生成的开销、简化进程间通信、使用简单透明的文本数据格式。
但协议真正的设计挑战是协议逻辑,必须看得出很有表现力并可防范死锁。
另外,不要过早关注性能,我们需要从性能调整中分离复杂度控制,它们是两码事。于是作者强烈建议我们尽量不要使用线程,还有模块化可以达到更好的安全性。
接着作者介绍了Unix中的几种IPC(进程间通信)方法,从最简的逐步介绍到难的。
- 把任务转给专门程序
最简单的程序协作方法。专门程序运行时不需要和父进程进行交流。
例如mutt邮件用户代理,将所有的编辑动作统一到单独的emacs进程中。
- 管道、重定向和过滤
各种IPC方法的诞生的源泉,管道约定每个程序一开始至少有两个I/O数据流可用。
管道操作把一个程序的标准输入连接到另一个程序的标准输入,一系列管道被称为管线,管道和重定向的组合威力很大,不需要单独再写一个完成某个功能的程序(类似链式调用),但它的缺点是单向性,不能让数据双向流动。
实例是分页程序的管道/制作单词表、pic2graph、bc(1)和dc(1)、以及fetchmail为什么不用管线。
- 包装器
它隐藏了shell管线的细节,被调用程序专用化。
实例是备份脚本,只需要传参调用就好。
- 安全性包装器和Bernstein链
它是高级版包装器,需要先认证再选择性地调用。
实例是Bernstein链,每个继发程序都取代了前一阶段的程序,并且可以在程序链中插入另一个程序来修改系统的行为
- 从进程
父子进程通信,只在两种情况下使用:两者使用的协议不重要或者那个协议是根据第五章原则设计的。(前端的mv*的父子组件通信也是这样的)
实例是scp和ssh,scp从ssh中获取信息制作进度条。
- 对等进程间通信
对等通道,双向数据流。(让我想起了angular1.x的双向数据绑定和其他前端框架的状态管理)
种类 | 特点 |
---|---|
临时文件 | 简单,易冲突,有安全性风险 |
信号 | 一种软中断形式,信号间可能会竞争 |
系统守护程序和常规信号 | 杀进程信号和常规信号 |
套接字 | 从封装网络数据访问的行为引申出来,需要指定协议族(前后端分开) |
共享内存 | 最快,但是必须提供硬件,自己处理竞争和死锁 |
在使用这些IPC方法的时候,我们也需要注意一些问题:
- 不要让IPC和API耦合在一起
- 尽量别使用二进制信息交换协议
- 避免混乱的流
- 避免远程过程调用,它虽然会提升性能但延迟高
- 别用线程(线程恐吓),因为作者认为它标准薄弱规范模糊
最后,我们在设计层次上的进程划分无非就是完成程序在生命期内交换数据的任务。
设计的过程中,我们需要想清楚:
- 程序前后端分离,何时建立通信?
- 何时何地完成信息的列集和散集?
- 何时产生缓冲问题?
- 怎样保证获取信息的原子性?
第八章:微型语言(寻找歌唱的乐符)
语言本身越精简,出bug的可能性越低,当需要一个特定规则来完成任务时,微型语言就诞生了。
它相对通用语言,体积小复杂度低。那什么情况下需要微型语言呢?当预先认识到设计一门微型语言,注意到规格说明文件像微型语言,或者老是打补丁的时候。
然后让我们理解一下语言分类法,由简单到复杂,是文件->微型语言->通用语言的。图8.1很好地对当时主要语言进行了分类,它们的范畴从声明型到命令型都有。
微型语言举例
案例 | 特点 |
---|---|
sng | 图文转换,便于用通用工具编辑图片 |
regexp | 描述文本模型,简单地表达了识别行为 |
glade | 界面创建,专门生成XML代码 |
m4 | 宏处理,便于把文本拓展成其他字符串,但要慎用 |
XSLT | 描述文本流变换,具有有限的类型分类和对外接口 |
DWB | 公式排版,声明性语义 |
fetchmail的运行控制语法 | 既有声明性特点也有命令式特点,有语法糖 |
awk | shell语言,既不紧凑也不通用所以被废弃 |
PostScript | 专门用于设备排版,堆栈式语言 |
bc和dc | 命令性语言,任意精度。dc是逆波兰标记,bc是代数标记 |
Emacs Lisp | 前端语言,可以描述编辑动作 |
JS | 客户端语言,逐渐变得通用 |
设计微型语言
当需要把问题说明规格提升一个层次时,且应用领域的域原语简单而固定不变时,我们就需要设计一门微型语言了。
- 选择正确的复杂度
尽量简单,自顶向下设计,符合美学。
了解嵌入型微型语言容易被滥用,宏的安全性差。
把语言一样的特性加进去作为事后补救措施是饮鸩止渴,一个偶尔图灵完备的语言是其他开发者的噩梦。
- 扩展和嵌入语言
通过别的语言扩展或者嵌入进别的语言。这对实现命令式语言来说很好,但这个选择决定于设计本身。
嵌入的话还需要考虑错误语法检查。
- 编写自定义语法
参考XML作为语法基础。
功能简单的微型语言就不要杀鸡用牛刀了,遵循最小立异原则。
确实需要自定义语法的话使用yacc和lex帮助。
- 慎用宏
宏带来的问题大于好处,因为预处理器遇到表达式而不是期望的值的时候会发生意想不到的结果。并且宏难以阅读和调试,扰乱了错误诊断。
C预处理器有相应的解决办法,但m4没有。
- 考虑清楚设计的是微型语言还是协议
考虑语言引擎是否可被其他程序交互调用,关键在于事务边界的标定程度。
第九章:生成(提升规格说明的层次)
这章的核心观点就只有一个:把程序的逻辑转移到数据中去。也就是数据驱动编程,让开发者只关心数据结构而不是代码。
数据驱动编程和OO的区别是数据实际上定义了程序的控制流,OO是封装和固定的。数据驱动和状态机也是有区别的,一个是自动生成代码,一个是手工写。
概括起来就是始终把问题层次往上推。
案例 | 特点 |
---|---|
ascii | 不维护代码,只维护数据 |
垃圾邮件统计 | 统计数据比精巧的模式匹配奏效 |
fetchmailconf | 通过配置文件生成代码 |
所以我们需要代码生成代码。建设性的懒惰是大师级程序员的美德。
案例 | 特点 |
---|---|
生成ascii | 发布的源码包含一个文件,里面是数据,通过它们生成代码 |
生成HTML | 数据+模板 |
第十章:配置(迈出正确的第一步)
配置是开发前启动环境的重要步骤之一,另一个是交互通道。
什么可以配置呢?理论上一切可配置,但配置项太多会爆炸。(对新手不友好)
不如搞清楚什么不可配置:比如可以自动检测的东西。用户也不应该看到优化开关,0.7秒以下藏起来。用其他程序能完成的任务就不要配置。
一般来说可以在五个地方配置:运行控制文件->系统环境变量->用户运行控制文件->用户自定义环境变量->命令行选项。
这五个地方后面会覆盖前面的,而且越到后面变化的几率越大,好的Unix实践要求使用同参数选项预期寿命最匹配的机制。
- 运行控制文件
以rc结尾(比如eslintrc),存放与程序相关的声明或命令,在程序启动时解析。
它分系统的和用户的。语法有一套通用风格,比如支持注释、不区别空白符等。可以减少用户阅读和编辑时要接触的新鲜事物,实例是.netrc文件:对用户透明且遵循最小立异原则,但很难移植映射到其他操作系统里去。
- 环境变量
用来配置程序访问的环境。如搜索路径、系统默认值、uid、pid等关键信息。
也分系统的和用户的。使用环境变量的时机是:变量值会根据上下文而变化或随点文件不同而改变、不能以改变命令行调用来表述,操作系统间的移植同样非常困难。
- 命令行选项
可以由脚本控制程序(如node脚本),一般以-开头,有Unix(推荐)、GNU、X toolkit风格。
从-a到-z都被赋予了特殊的含义,某些大写字母也是。尽一切办法遵循最小立异原则和复用它们。
有命令行的地方就好移植。
那应该如何挑选方法呢?根据从运行控制文件->环境变量->命令行选项,是最不易改变到最易改变的原则挑选。
因为后者会覆盖前者,并且依赖于程序在调用间隙需要保持多久的配置状态。
实例是fetchmail,设置rc文件和环境变量,最后用命令行脚本化。
但描述的约定不是绝对的,当明白自己想要什么并且想好了出错后怎么补救,可以让收益大于代价,是可以放手一搏的。
第十一章:接口(Unix环境下的用户接口设计模式)
接口是程序和程序,程序和人类通讯的媒介。在设计时要遵循与其他程序通讯的前瞻性和最小立异原则。
比如I/O有三种方式:程序、IPC、已知文件或设备。所有存在的接口风格,存在即合理。
那如何应用最小立异原则来减少用户学习负担呢?
让用户对接口产生熟悉感,能共生和委派就弄,不能就效仿。
从Unix接口设计的历史:打字机->命令行->可视化,我们可以知道Unix接口鼓励机制而非策略。
接口设计评估有五种度量标准:简洁、表现力、易用、透明和脚本化能力。
- 简洁是指操作起来容易程度
- 表现力指接口可以表现出没有预见到的行为组合
- 易用指学习成本低
- 透明说明用户容易理解问题域
- 脚本化是指接口能被其他程序使用
在接口发展历史上,CLI和GNU接口之间的争论一直存在,它们分别面向专家级用户和初学者用户,但我们需要权衡看待。
CLI表现力强、简洁、透明、脚本化但不易用,GNU表现力差、易用,其他不确定(看开发者)。
比如计算器程序是一个很好的GNU程序,因为它考虑到了用户的未来行为,这是值得的。
不过Unix程序员的编程喜好还是透明、表现力和可配置,所以不用说,CLI是他们的最爱。
Unix接口模式
模式 | 特点 |
---|---|
过滤器 | 标准输入输出,宽进严出,不丢不增,例如sort |
Cantrip | 一次性,例如clear |
源 | 只出,例如ls |
接收器 | 只进,例如打印程序 |
编译器 | 转换信息,比如gcc |
ed | 有交互能力,例如gdb |
Roguelike | 比GNU效率高,但难以脚本化,例如Roguelike游戏 |
引擎和接口分离 | 机制策略分离,遵循MVC模式,有配置者/行动者、假脱机/守护进程、驱动/引擎、C/S类型 |
CLI服务器 | 统一掌控程序启动服务器进程,例如CLI服务器 |
基于语言的接口 | 使用微型语言来做专门的事,例如shell |
那我们非Unix程序员怎么应用Unix接口模式呢?
答案是促进脚本化和管道线能力,接口要尽量简单。
首先交互要分三种情况:和初级用户、专家用户、其他程序使用。
而且通常有几种接口模式混合,首先封装API逻辑,然后产生一个多价程序。
最重要的是cantrip、GUI、脚本接口模式,可选Roguelike模式。
然后作者阐述了网页浏览器作为通用前端的好处,它统一了前端,让前后端彻底分离。
优点有很多,比如CGI公用网关接口和ajax助力前后端通信。缺点是网页强迫以批处理风格处理交互操作、使用无状态协议管理持久会话。还有语言的兼容性成为一个问题(现在已经不是问题,js统一了)。难以脚本化或将事务自动化到后端:三层架构,前端->CGI->命令脚本。
最后作者告诫我们接口没什么可说的就闭嘴:遵循缄默原则。
因为无用信息会干扰合作、用户、带宽消耗,还有长时间的操作要提供进度条而不是废话。确认提示最好是“不”而不是“是”。
调试模式和开发模式的消息可以区别对待。
第十二章:优化
最有效的优化是优化之外的事情,例如清晰干净的设计。
作者对于优化这件事提出了自己的特有观点:最好啥也别做,时间会给我们答案。不写代码就没有bug,这点真是无力反驳。
摩尔定律告诉我们付出得不到回报,软件优化的那一点点很快就被硬件升级所抵消。要做也是做降维复杂度优化而不是常数级的,比如从O(n^2)降到O(nlogn)
真要优化的话,先估量再优化,找到瓶颈再谈优化。
一般来说造成瓶颈有三个原因:
- 工具误差,根本性的问题。检测工具的代码执行有误差,可以统计它们的调用次数。
- 外部延迟,也是根本的,不能随机检测,要多次检测。
- 过度调用,把子程序的时间开销算到了调用程序中。
所以衡量性能时不要只收集孤立的性能数字,更应该综合多个参数,如问题规模、CPU速度、磁盘速度等,最后建模得出结论。
还需要考虑非定域性(不确定性)之害,保持代码短小简单,核心数据结构必须留在最快的缓存里可以尽量避免。
某些优化是不值得的,比如循环展开。
缓存越大,缓存的开销越大,这点也需要注意。
本章后面一部分是针对协议优化的内容,关注点主要在吞吐量和延迟上。
总的来说要设计出良好的网络协议,需要尽量避免协议的往返。
实际上尽可能使用低的时延设计和忽略带宽成本。作者提供了三种策略减少时延:对事务批处理、允许事务重叠、缓存。
1.批处理
先把更新累积起来,最后一次性处理,比如DOM操作和DOMFragment。
2.重叠
将好几条更新一起发送出去,阻塞和等待中间结果都是致命的,比如IMAP协议对请求做了标记。
3.缓存
兼得鱼和熊掌的策略,但必须考虑更新缓存的问题,更新模式越复杂,bug越容易产生。
而且作者认为缓存对于SPOT原则来说是不好的,因为它纯粹为了性能优化。他建议转用加速文件系统或者虚拟内存实现会比缓存好。所以我们在使用缓存时也应该问问自己为什么要用缓存。
第十三章:复杂度(尽可能简单,但别简单过了头)
真实世界的编程就是管理复杂度的问题,我们应尽量降低复杂度。
首先,我们需要理解复杂度是什么。作者分别从横向纵向的角度进行了比较。
横向的复杂度有三个来源:程序员、用户、代码。
- 程序员-接口复杂度,可能会陷进硬撑陷阱(极端晦涩的技法)。
- 用户-实现复杂度,可能会造成人力尺度陷阱(将许多底层任务抛给用户)。
- 代码-代码量,可能会陷入过专用陷阱(重复代码)。
现实中可以对接口复杂度和实现复杂度折中,做出一方面是简洁的接口,一方面是便于传播的简单软件。
RG的文章认为MIT哲学(简洁的接口)虽然让软件抽象地更好,但是New Jersey模型(简单软件)更具传播特性,这两种方法的平衡就在于可以拿此换彼,例如404的出现。
纵向的复杂度有本质的、选择的和偶然的复杂度。
- 本质的-有些问题天生就是复杂的,比如设计火箭程序。
- 选择的-由工程目标决定。
- 偶然的-没有找到实现规定功能集合的最简方法。
我们必须要注意选择和偶然复杂度的区别,偶然的可以由良好的设计去除,选择的只能改变工程目标了。
映射复杂度
- 本质接口复杂度通常无法去除,但可以调整代码库规模来减少代码复杂度。
- 选择复杂度边界模糊,工程所涉及的任何方面都可能产生实现复杂度。
- 偶然复杂度可以通过良好的设计避免。
- 代码复杂度可以采用更好的工具解决,实现复杂度可以选择更好的算法解决,接口复杂度着眼于更好的交互设计。
- 处理复杂度依赖于见识而非方法。
有的时候由于本质复杂度的存在,简洁不能胜任,我们只能保证功能,牺牲简洁了。
为了让我们对复杂度的理解更加深刻,作者讲了五个编辑器的故事。(字处理器不在讨论范围内,因为过于专用)
种类 | 特点 |
---|---|
纯文本 | 编辑器只知道其字节或者行结构 |
富文本 | 文本带有属性,如字体大小颜色等 |
句法感知 | 高亮,自动缩进 |
批命令输出解析 | 可以编译并捕捉错误 |
同辅助子进程交互 | 可以调试/版本控制/和其他程序通信 |
编辑器 | 特点 |
---|---|
ed | 纯文本编辑 |
vi | 四不像 |
Sam | ed的进化版,新增了功能 |
Emacs | 大而全 |
Wily | 鼠标控制 |
由此作者总结了一下,编辑器的适当规模应该是什么样的。
首先甄别它们的复杂度:ed最简单,Emacs最复杂,vi是折中派,Sam继承了ed的简洁,Wily优雅但有过于依赖鼠标的代价。接着批判了vi:折中无用。(虽然现在vi还是很火)
不过最后一句话说的还是好:少吃多干还是多吃多干取决于时代。(vscode应该是多吃多干,sublime应该是少吃多干)
结尾的时候引申到如何构建软件的适度规模:选择需要管理的上下文环境,并且按照边界所允许的最小化方式构建程序。先证明其他方法行不通时再编写大型程序。
具体实现
第十四章到第十六章介绍了Unix中涉及到的语言、工具以及轮子,我们前端也需要考虑自身领域的相关问题,这对我们真正编码的时候提升效率是非常有帮助的。
第十四章:语言(C还是非C)
Unix下的语言是丰饶的,并且鼓励专门领域语言的设计。一方面,C语言是Unix的伴生语言,另一方面,各种脚本语言在动态存储管理的自动化上有巨大优势。
C和Unix的关系是巧妙的,没有Unix就没有C,没有C就没有如今Unix文化的繁荣,C和C++取代了汇编语言在工业界的地位,重新掀起了一波技术浪潮。
虽然C和C++对要求极高的程序有意义,但损耗了程序员的精力。
随之而来急剧下降的成本又改变了编程的经济含义。软件的复杂化说明自动化内存管理越来越重要,而且真正性能的损失往往来自外界。(网络延迟、事件等待等)
到当今这个年代,混合策略才有可能使得效率最大化。即一种在主语言中嵌入其他语言的策略。
比如可以嵌入内存管理器完成内存管理,嵌入脚本胶合逻辑。高级shell编程甚至可以自由混合语言编程。
接着作者对当时的主流语言进行了一番评估,因为熟悉语言才能更好地使用和组合它们。
语言 | 特点 |
---|---|
C | 资源效率最接近机器语言,但资源管理非常困难 |
C++ | 效率高,支持OO和泛型编程,但非常难用,鼓励过于复杂的设计 |
Shell | 完成小型任务自然快捷,但大型脚本必须依赖大量辅助命令造成兼容性问题 |
Perl | 强大的工具语言以及正则匹配,但大型项目不优雅、难以维护 |
Tcl | 节俭紧凑的设计和作为解释器语言的可拓展性,但数据结构和命名空间等很怪异以至于难以用于大型项目 |
Python | 为嵌入而生的胶水语言,代码清晰优雅,但效率不高 |
Java | 自动管理内存并且支持OO,但设计的有些复杂而且没达到一次编写处处运行的目的 |
Emacs Lisp | 结合了Lisp,优雅、自动管理内存,但难以移植、性能差 |
作者还对这几种语言的未来趋势做了预测:C/C++/Java不变、Tcl/Perl衰退、Python增长,事实证明他基本上是对的。
前面分析了那么多,该到自己动手选择编程工具包的时候了,因为GUI工具包是会影响编程状态的,而且某些语言和工具包的绑定有特定要求,比如Qt屹立不倒,但我用vsc。
第十五章:工具(开发的技术)
语言选好了,工欲善其事必先利其器,接着就是选工具了。
首先,我们需要一个对开发者友好的操作系统,像Unix就没有固定的IDE,需要自己组合工具完成IDE的功能。这样可以让程序员更加专注于设计,以编辑/编译/调试为中心,其他细节用工具完成。前端也需要自己组合。
- 编辑器
作者主要对比了vi和Emacs,但我认为vi类似于sublime,可以灵活拓展,Emacs类似于vsc,大而全,不过两者兼用,用于不同的场景才是最佳策略。
- 专用代码生成器
作者以lex和yacc这两款生成语言词法分析器的工具为例,介绍了lex是从输入流中获取标记符号,而yacc是解析一系列标记符号来检查是否符合语法。
但lex意外地被用于各种模式识别,输入一堆,最后找出某个模式。
工具生成的代码还是比手工正确高效。
- 自动化编译
把源码进行装配打包发布才是最重要的,以make为例
它会寻找代码间的依赖关系从而生成正确的打包版本(类似webpack),但要注意不能非常复杂,比如递归make。
一些脚本语言生成任务所需的文件也是很方便的,有all、test、clean、install等命令,类似npm。
makefile的可移植性和分析依赖能力靠几个工具完成:makedepend、Imake、autoconf、automake等。
- 版本控制系统
为了追踪变化,特别是bug,查看作者、时间、内容等,我们需要版本控制系统,而计算机更加擅长这些细节。
手工版本控制隐性成本非常高,自动化的版本控制能够保存项目的历史评注并避免修改冲突。
举例为VCS,SVN是CVS的衍生版本(基于文件),GIT是现代版本控制系统(基于变化)。
- 运行期调试工具
能够打断点,检查程序状态,可控执行某个单一语句层次的部分,这是透明性设计的另一帮手。
- 性能分析工具
程序90%的执行时间都耗费在10%的代码上,性能分析软件帮助定位问题,这样就可以优化关键的10%的代码并遵循之前的优化原则。
- 整合工具
编辑/编译/测试/调试/版本控制...一体的工具,对前端来说vsc+chrome可以完成95%的工作,不是IDE胜似IDE。
第十六章:重用(论不要重新发明轮子)
无为代码,天下希及。这个无为是指最经济的行为,对无论是人员资本还是经济收益都有好处。
而让代码无为就是重用代码,重用代码又是避免发明轮子的最有效方法。Unix里里外外都支持重用,组合优先于独立。
作者还特意讲了一个猪小兵的故事,这是千千万万程序员的缩影:
我们在工作中重用的代码可能会有问题,不得逼我们重造一个轮子。但重用代码是技术问题、知识产权壁垒、行政问题以及个人自我意识的综合,所以代码专用化还是开放化让程序员们纠结。
不过,决定重用了,就必须透明。比如用源码和注释帮助使用者理解代码,牢记只有变化才是永恒的,源码可以延续而二进制码不行。
那重用和开源的关系又是什么样呢?作者说开源和重用就像爱情和繁殖的关系一样,开源也是为了重用自然而然发展而成保护透明性优势的策略。对开发者来说,保证了经验的价值,这也是职业发展的动力。
开放源码是从意识形态上解决这些所有问题的优先方法。
另外一点是,开源质量通常大于闭源。因为同行复议保证了标准,评估开源代码的方法是阅读其文档和它的部分代码,如果有一定年头、反馈、协同作者数、社区,这份代码就是质量高的。
去哪找呢?代码库和专用开源网站。作者推荐了SourceForge、Freshmeat等网站,现在应该是Github。
找到重用代码就是节约自己的编码时间,阅读代码的元数据并且试一试对自己是有好处的,并且阅读代码的细节也是为未来投资。
但使用开源软件还需要注意几个问题:考虑质量、文档、许可证。
文档的话,专用文档不如How To&FAQ等搜索来理解的快。
许可证相关的我们需要知道版权和许可证是两码事,谁是版权所有者不重要,关键是许可证条款。它让我们使用、修改代码的权利有限制,标准许可证有MIT、BSD等,GPL带有病毒性质,LGPL和MPL则削弱了这一点。另外记得,找律师只有1%的帮助...
社区的力量
最后几章揭示了Unix为何生命力如此长久的原因,在人和技术的平衡关系上做了非常仔细而微妙的分析。
第十七章:软件可移植性与遵循标准
软件开源了,你想让更多的人使用你的软件,但是传播的障碍常常来自操作系统和硬件结构。
移植性一直是Unix的主要优势,所以一旦设想软件项目生命周期很短,就容易犯错。
只要在架构、接口和实现上,API是稳定的,其他特殊细节都是无关紧要的。
比如,C和Unix紧密关联,是硬件和操作系统间的薄胶合层。它是在1971年诞生的,后期逐步引入typedef、union等操作符,版本7引入了枚举,并且将结构体和union作为一等公民。C语言标准造成了“K&R C”和“ANSIC”的区别,并且产生了一个很好的实践:在标准化之前,先实现各种pollify。
再延伸到Unix标准,同样使用公开标准作为API说明。虽然经过了分裂和内战,但Unix的标准在实践中得以奠定下来。
开源社区为了标准化,也需要确保源码的兼容性很强。
举个例子:IETF和RFC标准化过程,里面就体现了互联网工程任务组的思维方式:标准必须来自于一个可用原型实现的经验。
当然也有理想化的标准,比如臭名昭著的七层OSI模型。我们要考虑这点:在成为标准之前,实现的要求是越来越高的。所以只有当草案标准经过了实现的广泛测试并且达到了普遍接受的程度,就真正成为标准了。
对此作者打了个形象的比方:规格是DNA,代码是RNA。因为代码是可弃的,标准才是应该保留完善的。
代码从属于标准,先做一个原型再不断地测试和演进才是好办法,生成半自动化的测试套件也是一个主要优势,可以稳步迭代。至于相关的系统行为争论可以在规则功能层面解决,非规格(功能)即bug。
话题顺着到可移植性编程上,这个问题看似是准空间问题,实际上时间上的持久性同样重要。
首要问题是选择语言,作者对当时流行的语言做了移植性分析:
语言 | 移植性特点 |
---|---|
C | 高,但对于IPC、线程和GUI接口有困难 |
C++ | 类似C |
Shell | 差,大部分shell使用了其他可移植性差的工具 |
Perl | 良,看情况 |
Python | 优秀 |
Tcl | 一般,随项目复杂度有差异(看依赖) |
Java | 出色,但几个版本间的GUI有兼容问题 |
Emacs Lisp | 相当好,问题出在使用C接口的地方 |
总的来说就是避免系统依赖性,发布源码胜过二进制码,不要想着帮助不大的移植工具。
所以现在js成了可移植性语言之王。JavaScript is everywhere。
另外一点和移植化有关的是国际化,实现它我们需要分离信息库和代码,并且尽量使用UTF8字符集,使用正则时注意字符范围就好了。
那可移植性/开放标准和开放源码有什么关系呢?可移植性需要标准,而开源促进了标准化。另外,不要依赖专有技术,哪天作者跳坑就GG。
第十八章:文档(向网络世界阐释代码)
Unix最初的目的就是整理文档,troff格式器是始祖,现在的趋势是朝着html和url链接发展。
首先让我们区分一下标记型和可视型的文档:一种是面向程序员的,一种是面向初级用户的。
标记型又分表现型和结构型的,而大多数以标记为中心的文档系统都支持宏。
Unix风格的文档具备几个文化和技术特征:
- 偏爱大文档
- 写给技术人员看,手册页往往包含一个BUGS部分
- 擅长编写参考书籍
各种Unix文档格式
文档类型 | 特征 |
---|---|
troff和DWT | 表示层语言不如结构层语言,大量用于技术文档 |
TEX | 使用辅助程序比如LETEX编写,大量用于数学和科学领域 |
Texinfo | 可以生成HTML |
POD | Perl的标记系统,可以生成手册但不能生成HTML |
HTML | 未来趋势,在生成索引上有问题 |
DocBook | XML文档类型定义,可以转换成HTML、PDF等格式 |
于是书中大胆的预言未来的出路是XML一统天下...然而现在json横空出世...
对于DocBook,作者还特意描述了一下:有一条转换工具链,先验证是否是符合正确的文档格式,再根据样式单加样式最后输出。
但是最后还是批判了这条又臭又长的工具链,即使优化成FOP了还是不咋地。
最后本章总结了编写文档的最佳实践:就是不要忽悠读者。
- 数量多不会被认为质量高
- 信息密度要适中
- 大项目最好发布手册页/教程/常见问题解答列表
- 文档中要有readme
- 考虑新手用户,技术名词尽量用全称
- 文档格式应该易于传播
第十九章:开源(在社区中编程)
Unix在开放源码上就做了很好的表率,将找/改bug的任务分解成多个并行的子任务,然后众力编程。
而开源有如下几个特点:
- 源码公开
- 尽早发布/经常发布,前提是项目正常运行
- 给贡献给予表扬
- 开源项目管理尽量自动化
之后便是本章的重点:如何与开源开发者协同工作的最佳实践
- 良好的修补实践
1.1 是否换位思考/知道合并的后果
1.2 发送dif部分/针对当前版本/不要包含可生成文件/不要发送系统自动拓展的字段
1.3 在补丁中包含文档/解释/有用的注释
通过代码质量评估补丁
良好的命名实践
2.1 使用GNU风格,例如foobar-1.2.3.tar。gz
2.2 文件名/版本和区分度是最重要的
2.3 尊重适当的本地约定
2.4 选择容易键入的前缀良好的开发实践
3.1 不要依赖专有代码
3.2 使用GNU自动工具管理项目
3.3 先测试再发布代码
3.4 发布前对代码进行健全检查(能够捕捉到错误)
3.5 对readme进行拼写检查
3.6 考虑移植性良好的发布制作实践
4.1 确保打包文件总是解包到单一新目录下
4.2 包含README文件(项目介绍、项目demo演示、环境问题、关键架构、编译安装指令、维护者光荣榜、项目新闻、项目邮件列表地址等)
4.3 尊重和遵从标准文件命名实践(看社区的习俗)
4.4 为可升级性设计
4.5 提供RPM(类似npm)
4.6 提供校验和
供他人更好地下载、获取和使用
- 良好的交流实践
5.1 在社区和社交平台发公告
5.2 建立一个网站
5.3 提供项目邮件列表
5.4 发布到主要的档案站点
便于招揽用户与合作者
本章最后分析了许可证如何挑选,毕竟它会对软件施加限制。
虽然可以直接放在公共域,但使用某个标准许可证可以避免很多争论。
有MIT、BSD、GPL等许可证,具体可以看阮一峰的如何选择开源许可证
终章:危机与机遇
本书的结尾章,总结了过去如何应对的设计挑战,以及未来确定需要解决的问题和有待开拓的机会。
Unix最终要的是什么?当然是它的文化,而从传统来看它有平质和偶然属性,平质属性和偶然属性是可以互相转化的。
在历史的长河中,三个特殊的技术变化驱动了Unix设计风格中的重大变革:网络互联、位图图形显示和PC普及。
在这其中Unix一直保持着独有的设计准则:模块化、透明性、机制同策略分离等。
有人尝试重做Unix(Plan9),但最终失败,不过给予了Unix发展的启迪。这是一个比Unix更Unix的设计,并且还增加了一个概念:私有命名空间,但更优秀解决方案的最危险敌人,就是一个现存的、足够优秀的代码库,没有质变,谁会改变自己的惯性使用新事物呢?
当然,Unix设计中也存在许多问题,这里着重讨论几个存在争论的失败之处:
- Unix文件只有字节
- Unix对GUI的支持孱弱
- 文件删除不可撤销
- 假定文件系统是静态的
- 作业控制设计拙劣
- API的异常处理不好
- 设备中插入钩子的方法(ioctl和fcntl)是个鸡肋
- 安全模型太过原始
- 名字种类太多
- 文件系统的争论
- 朝向全局互联网地址空间
跳出程序员的眼界,来看看整个社会环境下,Unix如何发展:
首先要获得持续的经济支持,提高程序员的社会价值,然后组织终端用户测试,获取良好的反馈,最后要反对微软/好莱坞等巨头,为自由而斗争。
Unix文化中也有问题:内部转型的小问题和克服历史上的优越感的大问题。
比如和Mac之争,但Mac和Unix的设计哲学都有正确的一面,应该互相理解。不要把自己从骑士变成恶龙。
舍得抛弃过去,不再过分依赖那些已经很好地为我们工作过的设想。
胜利也不是全面的,低端市场和非技术用户被忽略了。
最后的最后,作者语重心长的说:
“我们能赢,只要我们想赢。“