作者丨Bystroushaak
译者 | 马可薇
策划 | 赵钰莹
五十年来,程序员可选的操作系统无非是 Windows、Mac OS 和 Linux,类似且陈旧,不知道是优胜略汰的选择还是历史发展的遗留产物。放弃所有奇怪的字符串格式,不管是程序启动时传递的命令行参数还是程序间通信,然后用简洁且易于编写的语言取代结构定义,这样的想法不香吗?
我是一名后端程序员。近年来,我为多家软件制造公司工作,并多次与其他程序员一起从零开始搭建系统。在没有现成架构的情况下,我也会参与设计系统体系结构。
在捷克国家图书馆工作时,我搭建了可以处理电子刊物的系统,独自完成了几乎所有后端代码,从数据存储到与现有系统的通信。在另一家不能被提及名字的公司,我负责设计 DDoS 防护系统,基本写完了大部分通信后端。我还做过 Nubium 的后端开发,为一家公司重新设计并编写了部分软件来处理不同服务器之间的文件存储和重新分发,还写了一个可以处理使用过数据的软件。
我写这些倒不是为了证明我的能力有多强,而是为了说明因为见的多,所以我能够看到大部分软件的异曲同工之处。
目前,我们对操作系统的选择有三大业界标杆:Windows、Mac OS、Linux,还有其他大约占 0.03% 的衍生小系统,例如 Plan9、BeOS 等。
出于某些原因,几乎所有现存的操作系统,除了个例,都几乎一模一样。我很好奇,这究竟是优胜略汰的选择还是历史发展的遗留产物。
过去五十年,操作系统虽然发生了很多变化,但都只是在迭代开发,没有加入任何的新鲜血液。一切都在向美好的方向发展,但最初的蓝本并没有改变。
我对操作系统作为硬件抽象这一概念没有任何问题,我反对的是操作系统作为用户界面的概念。我指的当然不是图形用户界面,而是可交互的其他所有带“形状”的事物。
文件系统到底是什么?答案是:一个有限制的分层键值数据库。
说它有限制的原因在于:文件系统不仅限制了键被允许的子集和大小(如果情况允许则会以 UTF 的格式存储),它还限制了值本身,而开发者能存储的只有字节流。
听起来很合理,但不要忘了,文件系统还是一个不允许直接存储任何结构化数据的树状结构分层数据库。索引节点也就是树状结构中的分支数量最大只有数万个,但一个目录中最多却可以有数百万个文件。
在我之前的工作中,为解决 inode 限制不得不求助于 BalancedDiscStorage 模块,或者是将文件存到三个以原文件 MD5 哈希的首字母命名的子目录中。
与必备 ACID 四要素的正常数据库相比,文件系统可以说是非常不称职。对大多数操作而言,它既不支持原子性也不支持事务,并行写入和读取在不同的操作系统中也不尽相同,当然前提是这个操作系统支持并行,它可以说是无法保证任何事情。
过去,我们解决了底层的问题,磁盘扇区、日志记录硬盘及其分区,还有 RAID,从数据存储上来说,我们和过去相比进步巨大,但如果从用户界面的角度来看,这难道不是退步吗?
最初的文件系统只是一种比喻,将文件存储到文件夹中,是为帮助当时习惯使用纸笔工作的人们更好地理解其工作原理。然而即使是在现在,在文件系统许多技术的限制上,人们仍然无法摆脱这个比喻带来的思维定势。
纯粹从物理意义上来讲,程序不过是存储字节的序列。而操作系统基本上也就只剩下程序了,OS 的文件数据库除了控制程序,什么都做不了。
通常,程序是用某种编程语言编写的,在编译之后就会被链接到某个数据块中。然后,它会在打孔卡上打孔(二进制数据),并插入文件柜正确区域的相应盒子(文件)中。运行时,堆叠的打孔卡从存储在文件柜某个位置的盒子中取出并载入到内存中。
程序同样也可以从命令行中读取参数,通过使用共享库调用操作系统的 API、文件系统,向其他程序发送数字信号,并回应这类信号,返回(数字)值或打开 socket。
我不想说这个概念本身就是错的。但是,这不过是五十年前那套想法迭代发展后的版本,整个系统在这四十年间只是改进了最细枝末节的部分,我们不是在完善这套思路,我们是钻了牛角尖。
Lisp 机、Smalltalk 以及 Self 环境给我们指了一条不同的路。程序不一定要是字节的集合,它们还可以作为单独的对象被系统的其他部分调用,可以根据需要动态编译。
Unix 哲学提出过“多使用小型应用程序的组合来解决问题”,我们为什么不将这个想法带到新的高度,并根据程序的不同功能和方法来编写小型程序呢?这会允许它们以与微服务相同的工作方式,与其他系统进行通信。
二进制数据,乍一看整个主意简直不能更好,文本才是最普遍的存在。
那么问题出在了哪里?
几乎所有数据都有其独特的内部结构。当开发者要处理数据而不是四处搬运的时候,分析数据是避无可避的。这就意味着要对数据进行切片,并从中构建结构树。即使是诸如音频或视频数据的字节流也有其自身的迭代结构。
同样的事情一遍遍发生,每一个程序都要获取原始数据、构建结构、进行处理,然后再将这些数据折叠回原始二进制数据。
现在的计算机文化沉迷于编译器和对以元数据的形式承载结构本身的数据的外部描述,在不断地将原始字节转换为结构然后再转换回原始字节时,浪费了大量的 CPU 周期。
作为程序员,我有相当一部分时间都花在解析和转换数据上,这一切不光可笑甚至还很难受。
过去几十年,我们见证了 XML、JSON 和 YAML 等格式的快速发展,但这并不是我想在这里说的。格式不是重点,重点是结构本身。如果能将所有数据结构化岂不是很好?
我并不是在指用 XML 语法分析器分析数据,而是以 MessagePack、SBE、FlatBuffers 或者是 Cap'n Proto 的形式直接加载到内存中;不需要对文本进行评估或者是解决转义序列和 unicode 格式;不需要分析 WAV 文件,因为可以直接遍历块;数据可以通过其结构进行自我描述,而不用通过外部描述或者是语法分析器;“对象”可以直接序列化,而且是所有编程语言都支持的统一系统,哪怕没有对象只有结构。
这种系统真的有可能存在吗?
尽管我对这些事情有着不满,但最重要的是人类的潜意识。人们并不会将文件系统看作是数据库,也不会意识到它们的结构是分层式键值数据。我们理所当然地认为程序是二进制的 blob(Binary Large Object),而不是一堆相互联系的函数、结构和对象。人们对数据的理解是原始字节的死序列,而不是树形结构或是图结构。
除此之外,通信也应该有结构,用于在电脑和程序之间进行沟通。
Plan9 的出现可以说是朝着结构化计算机通信方法迈进了一大步。然而在我研究之后发现,它的作者虽然知道自己要做什么,但却还没有达成更完整的认知。这也许是 Unix 以及字节流通信概念过于深入的原因。
仅执行某些特殊文件就可以进行系统互动,听起来非常美好,甚至可以说是革命性的进步。就像仅使用 Linux 一半的特性,比如 /sys 的子系统和 FUSE(用户态文件系统)。
但是它缺少映射和结构化数据。没有读过用户手册就永远不知道自己该写什么以及运行后该期待什么,一切都没有明确的语义。
描述和期望存在不同的地方,可能是帮助页面(man page),也可能是在某处的模块描述中。而真正的期望只是机器代码或是源代码的形式存储于完全不同的位置。一切都是面向字符串的,同样也没有标准化。数据类型从未被指定,仔细想想,这只是在文件之上构建的、丝毫没有现代通信框架优点的原始通信框架。
/sys 的结构和对象非常类似,sys.class.gpio.diode 与该文本协议的区别在于,文件的实现是一对未描述的键值对,和 JSON 有点类似。但文件也没有明确指定结构、属性集、更复杂消息的格式、帮助或者是异常引发的格式和机制。
我可以理解 Sockets 的创建原因和工作方式,毕竟在当时,这应该是最佳且合理的选项。然而,在所有通信都已经结构化的今天,我们仍然用着非结构化的二进制数据传输格式?
在 Sockets 发明了五十多年后的今天,我们仍然以字节流的形式传输数据并不断提出新的文本协议,我们难道不值得更好的吗?
人们认为新建结构并使用某些临时发明出的序列化协议对其进行序列化是件再正常不过的事情,当我们将它传输到需要分析器以进行反序列化和原始结构重构的系统时,这个系统基本也是以代码的形式对数据进行外部描述。幸运的话,我们可以通过反序列化得到足够接近最初序列化的格式而没有太多的丢失信息。何必多此一举呢,直接传输结构不好吗?
ZeroMQ 朝着正确的方向迈进了一步,但就目前看来反响不佳。
如果无视可以通过鼠标点击就运行的程序,那么通常情况下,命令行参数是打开程序的唯一选择。然而几乎所有程序都有自己独有的语法来分析这些参数。
理论上来说,存在标准答案和最佳答案两种,但事实上除非翻 man 页,否则永远不知道到底该用什么语法。如果通过 bash shell 调用程序,那么就会得到 shell 脚本语言外加其自定义的字符串、变量等一系列乱七八糟的大礼包,我能想到的就有转义序列、函数的保留字、随机字符等。所有人都随心所欲地乱写一气,然后不考虑任何内部逻辑或统一性的解码,最终造成了一团乱麻的局面。
别忘了还有在程序中调用另一个程序的操作。我已经记不清有多少次被迫使用下面这种代码了:
import subprocess
sp = subprocess.Popen(
['7z', 'a', 'Test.7z', 'Test', '-mx9'],
stderr=subprocess.STDOUT,
stdout=subprocess.PIPE
)
stdout, stderr = sp.communicate()
如果你认为参数过于复杂,那么很快就会退化到字符串的拼接版本,而这时它的安全性也就大大降低,甚至还可能导致命令注入,没有字符串编码支持的保证,pipe 和 tty 的的行为将会比你想象的更难受,一切都会变得无比复杂。
我很不能理解这堆东西。命令行参数通常只具有嵌套结构的列表或字典,用统一且简介的方式编写这类结构的规范用语不好吗?比 JSON 写起来还要简单,同时也更具表现力。
有一点被我忽略了,如果可以将结构化程序发送到程序,就像是编程语言调用函数一样,那么命令行参数就没必要了。毕竟它的作用不过是通过特定的参数调用特定的函数或是方法,既然如此,为何不选择一种更直接且方便的方法呢?
Env 变量是字典,他们所作的就是将数据映射到结构上,并且可以像字典一样使用。但由于缺少结构,它们只是使用字符串作为键值的一维字典。在 D 语言(译者注:一种通用计算机程序语言)中,env 变量通过 string [string] env; 声明。但这样通常不够,还需要转义嵌套或者是使用比 string 更复杂的结构。这就让人不得不选择使用序列化。
令人崩溃的是,“还需要给 env 变量存储结构?直接用 JSON 或者指向个文件不就好了?”为什么不能找到统一的方式来传递并存储数据,这样就不用将 bash 中的 env 变量的语法和 JSON 混合使用了。
计算机上几乎所有不怎么重要的程序都需要通过配置文件进行配置。至于配置文件在哪里,在 Linux 里习惯会放在 /etc 下,但也有可能会在 $HOME,或者 $HOME/.config 下,或者在某些随机的子文件夹中,例如 $HOME/.thunderbird/。
至于格式,基本就是个人发挥。可以是(伪)INI 文件、XML、JSON、YAML。甚至可以是某种编程语言,例如 LUA,也可以是几种语言的混杂,比如 postfix。只要你想,什么都可以用。
曾经有人调侃表示:每个配置文件都会随着时间的增加而变得更加复杂,直到有 lisp 的一半糟糕为止。我能理解这种现象的出现的原因,但为什么不能在系统中统一标准呢?理想情况下,同种数据格式就像是编程语言一样,那么为什么不能直接将对象本身存储在配置命名空间中合适的位置?
所有的生产服务都需要在某些地方记录其日志,而每一份日志都要能满足一些条件,比如有结构地记录、并行写访问、日志旋转以及真实记录等。
然而,目前的解决方案大多各不相同。人们经常会不经思考地使用任何看上去合理的方案:
日志结构混乱。虽然都会或多或少的使用可解码的格式,但其结构和语法几乎完全不同。很少有人设定多行日志信息应如何被存储和转义,而解码通常是由正则表达式处理的,这种形式通常经不起考验,很容易被破坏。
并行日志记录的问题通常会利用单独的日志记录服务器解决。与几乎其他所有的数据库不同,文件系统通常并不能保证原子性操作更改(译者注:指不会被线程调度机制打断的操作)。如果日志服务器挂掉了,那么日志记录就没了。
日志旋转一般也是由外部应用程序处理的,基本也就是定期触发的任务。我至今还没有见过任何一个日志记录程序智能到在存储空间不足的情况下依然能够记录,不知道有多少的生产服务都是因为这个小问题而停止了工作。另一个难点在于,程序通常会让日志文件保持打开状态,而如果文件在这种时候突然被旋转,那么整体结构很有可能被破坏。这一点的解决方案非常粗暴,直接发送信号到程序,而程序必须能够进行异步响应。我同事近期的骚操作让我嘲笑了他好久,当时他想要禁用文件日志,只使用 stdout,于是他将日志文件的路径更改为了 /dev/null。问题得到了完美的解决,直到 Python 的 RotatingFileHandler 决定旋转文件,毫无疑问,整个应用都崩溃了。
至于传送机制,有些程序会直接打开文件并开始记录日志,有些会将数据发送到 UDP(syslog),还有些则会把 JSON 消息发送到 Sentry。具体怎么做,完全取决于当时的流行方法,或者是开发者脑袋里想到了哪个。
日志对我来说就是个绝佳的例子,几乎所有的人都被迫来处理这个问题,操作系统还不能做到完全支持。这也是为什么我们需要转向发送结构化消息的原因之一。
类似 Kibana、Graylog 这样的大型软件包通常是基于 Elasticsearch 的,这可以说是朝着这个方向迈出的重要一步,但他们也仅仅只是强调了这种需求的存在。
放弃所有奇怪的字符串格式,不管是程序启动时传递的命令行参数还是程序间的通信,然后用简洁且易于编写的语言取代结构定义,这样的想法不香吗?
一种可以在描述数据的同时利用数据类型,诸如 dict、list、int、string、委托或者是继承。不管用户还是程序都不用再担心对结构的大部分解析和猜测,因为操作系统已经帮你把这些都做完了,OS 也就可以直接以其本来的格式获取数据。
当然,我所说的对象(object)并不是编程语言 C++ 或是 Java 中的对象,有些人对此很是敏感。
我所指的是根据其所操作的数据对函数进行分组的一般性概念。不需要使用类(class),不需要写类的代码,甚至不需要继承(inheritance),尽管有些委派(delegation),但用起来很方便。
/sys 文件系统之中的 GPIO 子目录,包括指定数据写入方向控制文件,以及一个读取或写入数据的数据文件。当然在理想情况下,常规方法复制和实例化对象仍然是可行的,将其传递给其他对象或方法,并在内部进行检查。这里使用的仍然是原始的对象系统,其中对象由目录表示,数据由文件表示,方法由控制文件及其对应操作表示。
对象位于最底层的键值数据上,如果该键存储的是方法名,那么就可以执行代码,否则返回数据。对象与数据库中的 /key-value/ 条目的区别不是很大,主要在于存储代码以及委派的能力,委派表现在如果在 child 中找不到该 key,则会搜索其 parent。
因此,我这里所说的对象,都是允许委派、引用其他键值结构或对象、可以反射,并在理想情况下具有同像性的一般性键值结构。
网络协议最底层能够保持结构化消息格式统一这一点令我很是惊叹,每一个 TCP/IP 的数据包都有明确的头部(header)和地址,可以在全球范围内大规模流通。既然如此,为什么这种统一不能存在于计算机或者更高层的结构之间呢?
在单个计算机或是在互联网范围内处理单个对象的方法(method)或者是对象本身,它们的本质是一样的吗?
经过思考,我认为文件系统彻底被数据库取代是无法避免的。我所指的数据库并不是某个特定的 SQL 数据库,而是支持存储数据类型、原子性、索引、事务、日志以及任意结构化数据,包括大块纯二进制数据存储的通用结构化系统。
可以自动保存任意结构的数据输入,而不需要经过无用的序列化和反序列化过程。这样的最终结果会是一串字节流,而我们也不必纠结这串字节流到底是存在于内存中还是磁盘中。
一旦有了允许本地存储结构化信息的文件系统,那么程序以大量连续字节序列的形式与世隔绝地存储就不再有意义,相反,人们会选择从中搭建类似微服务架构的东西。
如果以一种足够抽象的角度来看,那么程序就是一个对象,是一个数据的集合,是由其所包含的函数所操控。它只会以某些标准形式进行封装与通信(stdin/out/err,套接字,信号,环境,错误代码,文件写入等等)。如果一个文件系统可以直接存储这些对象,那么为何不让该对象的个体或是方法同样可以被从外部访问?
数据被发布之后,基于数据流的旧方法(套接字、文件等等)就可以不用了,你只需返回结构化数据即可。代码仍然可编译,也可用各种编程语言, 不同之处在于结果。与其直接将二进制 blob 发送到 CPU 并将其与其他进程和系统隔离开,我们将直接得到 API 调用和其输出的定义。有点类似于共享库,只不过它是系统本机元素的最终形式。
配置文件以及其多种多样的格式存世是因为文件系统不能存储其结构。因为如果结构可以被存储,那么人们就可以直接存储对象或所有的键值对,当下次需要使用的时候直接调用。这样就可以不用在 [configuration] 区域加上字符 RUN = 1 ,然后解析,反序列化,然后再次序列化。相反,我们可以直接在配置对象中将 RUN 保存为 True。
因为格式上的区别没有了,有的只是结构上的不同,这样我们就不再需要和 JSON 和 CSV 打交道了。
简单来说,我们需要可以保留数据类型而不是文件系统的层次结构数据库:既包含了存储为可以互相调用的程序的集合,又包括外部可调用函数、配置文件和用户数据,这些都是可访问以及可寻址结构的形式;简化用于在用户输入编码的某种结构时所使用的轻量级文本格式的读写;程序直接交换结构,而并非使用文本协议进行通信。
总之,对五十年前设计的迭代改进已经足够了,现在是时候提出新方案了。
免责声明:
这篇文章是我个人的想法,我并没想影响或者改变谁。可能你会觉得这些想法很奇怪或者荒谬,那么也请保留你的意见。
如果你认为这篇文章都是没深度的废话,那么请去读一读 Genera 上面的文章:http://bitsavers.trailing-edge.com/pdf/symbolics/software/genera_8/Genera_Concepts.pdf
你会发现几十年前就有能够实现我所写内容的图形系统。而这个系统如此出色以至于现在都还有爱好者群体存在。
参考链接:
http://blog.rfox.eu/en/Programmer_s_critique_of_missing_structure_of_oper.html
InfoQ Pro 是 InfoQ 专为技术早期开拓者和乐于钻研的技术探险者全新打造的专业媒体服务平台。
现在扫描二维码关注InfoQ Pro公众号,回复“抽奖”,即可参与服务号新粉抽奖活动,小 Q 将抽取 5 位粉丝获得 InfoQ 定制卫衣一件~