《设计数据密集型应用》读完啦

Disign Data-Intensive Application,大名鼎鼎的 DDIA,中文名就是设计数据密集型应用。这本书我买的时间是去年的6月18号,大约一个多星期之前,终于是读完啦,算是整整一年吧。买的是影印版,英文,应该是第一次读完一整本的比较厚的英文技术书籍。

读书的进度也比较奇怪,在去年9月份左右,前两个部分就读完了,当时一直有一种就是想读的感觉。之后又突然懈怠了,停滞了许久。知道今年上半年陆续读了一些,终于在最近达成目标。

做服务端开发,如果想要深入,有些技术就必然绕不过去,就比如本书所涉及的数据存储、数据处理以及分布式系统。之前也想深入了解一下分布式系统,看了些相关文章、博客,但是并没有系统的学习,因此理解层次比较浅,缺乏全局的认识,也容易看过就丢。而这本书,虽然并不是着眼于分布式系统,却从数据的角度,讲了分布式系统涉及的概念和理论。

本书从应用的可靠性、扩展性、可维护性开始,到大数据应用的伦理结束,分为三个部分共十二章。

Part 1. Foundations of Data System

第一个部分是数据系统的基础,包括 4 章,分别是关于数据系统的数据定义以及关注点、数据模型、存储引擎、序列化。

数据系统的三大问题

一个典型的数据系统(Data System)可以由几个基本块构成,

  • 存储数据,因而之后自己或者其他应用可以找到这些数据(数据库)
  • 记住高代价操作的结果,从而可以加速读取(缓存)
  • 允许用户通过关键字搜索,或者其他方式过滤数据(索引)
  • 向其他应用发消息,从而异步处理(流处理)
  • 定期处理大量数据(批处理)

而数据数据系统最关注的三个问题,则是

  • Reliablity,可靠性:即使在故障(adversity)情况(软硬件故障)甚至人为失误的情况下,系统依然能正确地(correctly) 工作(在期望的性能下,正确的执行功能)
  • Scalabiity,扩展性:在系统逐渐庞大(grows)(数据量、流量或者复杂性)的过程中,有合理的方式解决这个过程中出现的问题
  • Maintainability,可维护性:系统的生命周期中,对系统的修改,包括维护当前功能或者增加新功能,都可以高效地()productively)完成。

数据的存储、获取、传输

数据模型不仅仅关系到一个应用如何实现,还表明了我们对问题的思考方式。通常,数据模型都是一层叠一层,上层的数据模型依赖于下一层的数据模型。比如,

  • 通常会有应用模型,使用接口和类表示,用于描述领域问题
  • 这些模型在存储时,可能是 json 或者关系型数据库表、图数据库的图等
  • 底层的存储引擎,则会把这些数据转变为 bytes
  • 在更底层还涉及硬件

数据模型

在第二章,主要涉及的是其中的第二项。

目前,关系模型是使用最广泛的数据模型。然而,关系模型不能和领域级别的抽象一一对应。通常,领域模型使用对象进行描述,而对象和关系模型的表、行、列,并不是一一对应的。对象之间的关联关系,映射到关系模型,就会产生不同的关系,如一对一、多对一、多对多等。这种映射并不直观。很久之前就出现过其他的数据模型,比如网络模型(network model),每个记录之间存在链接,表示相互关系。但是网络模型的查询太复杂,所以败给了关系模型。文档模型又是另外的一种,和网络模型有些类似,都会把嵌套的记录放到父记录中而不是另起一张表,不过文档记录在处理前文的多对一、多对多关系时,存储的 document reference 更像是外键。

文档模型和关系模型的一大区别在于,关系模型的模式(schema)是确定的,而文档模型的 schema 则更灵活,有时被称为 schemaless。实际上,文档模型只是在写入是不强制必须是统一的 schema,在读数据时,会认为数据具有某种特定的结构。与编程语言里的鸭子类型相似。这种模式,称为 schema-on-read,而传统的关系数据库,则时 schmea-on-write。

数据存储在硬盘上,目的是为了日后可以读。而根据不同的应用场景,可能需要数据的不同部分。这就涉及了数据的查询。

数据查询的语言和编程语言类似,可以分为命令时和声明式。常用的编程语言基本都是命令式,需要告诉计算机怎么做。SQL 就是一种声明式的查询语言,只需要表明要干什,不需要说明怎么做。在 Web 开发中,css 选择器以及 xml 的 xpath 选择器,也都是生命式语言。使用这类语言,最大的好处就是不需要关系实现的细节,交给语言的解释器去做优化即可。MongoDB 是文档模型的数据库,支持使用 MapReduce 模式的查询。MapReduce 可以认为是介于命令式和声明式两者之间,因为 map 和 reduce 方法的实现式命令式,而 map 和 reduce 的实现是不会暴露给用户的,对于用户而言,可以认为是声明式。

图模型是区别以上数据模型的一种模型,擅长处理多对多关系。比如社交网络、交通路网等,都可以用图模型很好的描述。

存储和读取

第三章是关闭于存储和读取,重点涉及上面所述的数据在磁盘中的存放。

数据库对于数据的处理需要同时考虑读和写,一般而言,读和写的性能不能同时达到最优,必须由均衡取舍。

最简单的数据库,就是所有数据存到一个文件,写则追加数据,读则从头开始扫。但是很显然,读性能及其糟糕,作为一个数据库,这个方案是不能接受的。

以 key-value 数据库为例。为了优化,可以在内存中记录 key 在文件中的 offset,读数据时,根据 offset 取数据即可,称为哈希表(hash table)。这就是哈希索引。为了防止数据文件过大,一般会把数据文件分段存储。这种模型下,修改和删除数据都是在文件尾部追加数据,造成很大的冗余。因此可以在后台起一个线程,压缩合并数据段文件,只保留 key 在文件中的最后一次出现。这种实现很高效,因为顺序写磁盘非常快。而索引在内存中,读数据也并不是很慢。缺点在于哈希表必须在内存中,否则写道磁盘上,会有随机IO,性能很不好。此外,对于范围查询,则只能对范围内的 key 一个个查询,性能完全不行。

LSM-Tree 是对上述模型的改进。在上述模型中,保证每个数据段文件中,key 是有序的,并且每个 key 在一个数据段文件中只出现一次。这种结构称为 SSTable(Sorted-String Table)。如此,则不需要在内存中存储所有的 key。有 SSTable 组成的存储,就称为 LSM-Tree(Log-structrured Merge-Tree)。由于 SSTable 中 key 有序,则需要在内存中执行写请求,内存中的结构称为 memtable。在 memtable 的大小达到一定阈值,刷盘。再刷盘的同时,新的请求写道新的 memtable。为了防止数据在刷盘之前丢失,需要在每次写数据时,先追加写道日志文件,在数据刷盘后删除。读数据时,则先查内存,再从信道旧,依次查询 SSTable。如果 key 不存在,查询就会比较慢,因此可以使用 bloom filter 来进行优化。

B-Tree 则是和以上方案的路子都不同。读写都是以 page 作为最小单元,并进行原地更新。为了防止丢失,需要写 redo log,相当于上述的 append only log。

数据库基本有两类使用场景,OLTP(online transaction processing)和 OLAP(online analytic processing)。OLTP,基本上是对数据库中的少量数据的操作,可能会与用户数据有交互。OLAP 则是注重数据分析,是对大批量的数据进行统计,以获得对于据的统计性的认知。由于使用场景的巨大不同,其底层存储也有差别。

编码与演进

数据结构在存储时,都需要编码为 bytes。而编码不仅仅影响性能,同时会影响应用的结构以及部署方式。

编码方式有很多,有和特定语言相关的,比如 Java 的 Serializable;有文本方式的编码,比如 json、xml;还有各种二进制编码。

不同的编码方式,对 schema 要求也不同。json 编解码就不需要 schema,因为信息在编码后的数据中都存在。而 ProtoBuf 就需要 schema,因为其编码使用了数字作为 tag,但是编码后的数据中,并不存在某个数字对应的属性的名字、类型等信息,必须通过 schema 才能拿到,因此必须借助 schema 才能解码。

不同的编码方式,还会影响到应用的升级兼容。如果新增、删除、修改字段,会导致只认识旧的 schema 的应用崩溃,那么这种方式就很难进行兼容升级,会给开发带来比较大困难。

Part 2. Distributed Data

第二部分是本书的重头戏,关注数据在多台机器上的存储以及获取。之所以需要考虑在多台机器上保存数据,由三个原因:

  • 扩展性。单机容量不能满足需求
  • 容错性、高可用:单机故障时,不影响整体的可用性
  • 时延:可以靠近用户部署,从而减小访问数据的时间

Replication

同一份数据通过网络保存在多台机器上,就是 Replication(不知道怎么翻译好),存储数据的节点称为 replca。需要数据 replication 的理由是:

  • 可以在地理上靠近用户
  • 部分系统故障时,系统可以工作
  • 允许多台机器处理请求,从而增加吞吐量

单 leader

多个 replica 之间,由 leader/follower,或者 master/salve 之分,其中 leader 处理写请求,follower 只能处理读请求。因此需要 leader 需要把数据复制到其他的节点。

leader 向其他节点复制数据,就存在一个同步异步的问题。leader 处理请求后,什么时候才认为数据已经成功写入。是在 leader 保存后即认为成功,还是所有 follower 都确认才算是成功。

  • 全同步复制:其他所有节点都确认,才返回。会导致严重的不可用,因为任何一个节点失效,都会导致无法写入数据。
  • 半同步:在 n 个节点确认后,认为写入成功。其他的节点异步复制。
  • 全异步:leader 成功即认为写入成功。会导致数据不一致,因为 leader 有可能在自己成功而没有其他节点确认时崩溃,这个数据就不会被其他节点承认。

单 leader 模式下,还必须要处理 leader 不可用的异常,必须进行 failover。此时,可能会导致丢数据(具体要看是同步复制还是异步复制)。

数据怎节点之间的同步,一般时通过复制日志。分为三种

Statement-based。最简单的方式,leader 把其执行的所有写请求(statement)写到日志,并把日志发送给所有 follower。存在几个问题:

  • 必须时确定性的,不能由 random、now 这样的不确定性函数
  • 如果请求依赖数据中的其他内容,或者以来自增ID,那么请求的处理必须保持有序
  • 如果请求由副作用,那么副作用也必须时确定性的

Write-ahread log。leader 把所有对数据的写操作写到日志,然后把日志发送给所有 follower。follower 根据日志恢复数据。这种方式的问题在于,日志里的内容时底层数据,更接近存储引擎。leader 和 follower 必须使用相同的数据格式,也因此在数据格式改变时,leader 和 follower 必须同时升级。这就导致在升级时,会有一段时间不可用。

Logical(row-based) log。使用一种与存储引擎不同的数据格式来写日志,通常时逻辑上的记录。比如新增一行,则记录下一行内所有列的数据。MySQL 的 binlog 就是这种方式。

既然需要从 leader 复制到 follower,就必然存在延迟(lag),继而应该考虑一下几点:

  • 写后读。自己写入的数据,自己也应该能够读出来。
  • 单调读。曾经读过的数据,之后也应该能够读出来。
  • consisent prefix reads。具有因果性的数据,不能读到的顺序也因果相反。比如,问答中,问题一定先于回答先读到。

多 leader

单 leader 的问题在于,如果和 leader 的连接出现问题,那么就没有办法写入。并且 leader 也可能会成为系统的性能瓶颈。

一个自然而然的解决方法就是,允许有多个 leader 同时存在,这也就是多 leader 的模式。多 leader 行能会更好,可以容忍网络问题以及单个机房的宕机。

多个 leader 同时支持写入,就存在数据冲突的可能,从而需要解决数据冲突,而这是非常困难。如果可能,应该尽量避免数据冲突,比如对特定数据的写,都路由到同一个 leader。另外一种方式,是各 leader 的写入都能达到最终一致,方式可以为:

  • 每个写入都有一个唯一的 ID,冲突的写入,选择 ID 大的作为最终的胜者,丢弃其他数据。ID 可以是时间戳,甚至是随机数。如果使用时间戳,就是所谓的 last write wins
  • 每个节点有一个 ID,当数据冲突时,ID 最大的节点的写入作为胜者,丢弃其他数据
  • 合并多个写入
  • 记录下所有冲突的值,以待日后解决(有可能最后就是给最终用户一个提示)

多 leader 之间存在拓扑结构,因为一个 leader 收到的写请求,也发送给其他的 leader。可以分为三类:环形、星型、全连接。

无 leader(leaderless)

前两种模式下,客户端只能向 leader 发送写请求,由 leader 决定写入的顺序,而在 leaderless 模式下,则没有这个限制。最典型的系统时 Amazon 的 DynamoDB,写入时,客户端同时想多个节点发送写请求。读也是同时向多个节点发送读请求。

leaderless 下,由 quorum 的概念:

  • 数据存储在 n 个节点中,称为 home node
  • 需要向 w 个节点写入成功,才算写入成功
  • 读数据时需要同时读 r 个节点的数据
  • 要求 w + r > n,从而保证客户端读到的数据中,一定由最新的数据

即便如此,quorum 仍然存在一致性问题:

  • sloppy quorum
  • 并发写仍然会有顺序问题
  • 并发的读和写,无法确认读到的值是新是旧
  • 写入时,如果部分节点写入失败,从而少于 w 个节点成功,写入成功的节点并不会回滚。那么对于后续的读,不能确认是否会读到这次失败的写入。
  • 有新数据的节点宕机,恢复数据时从一个有旧数据的节点恢复,就可能会导致存有新数据的节点数目不足 w
  • 时序问题

sloppy quorum是指这样的一种情况,在超过 n 个节点的集群中,客户端无法和某些节点通信,在写入时无法保证 w 个写入成功,此时有两种选择:

  1. 写失败
  2. 向不是 home node 的节点写数据

第二种选择即 sloppy quorum,会导致数据不一致。因为客户端认为写入成功了,但是实际上,w + r > n 可并不满足,因为不在 n 中的节点被算在 w 里。但是 sloppy quorum 可以提高写的可用性。

因为允许并发写,因而即使时严格的 quorum,也可能存在冲突。如果每个节点都是简单的覆盖原先的值,肯定会导致数据的永久不一致,这对于一个数据系统来说时不可接受的。即使不能实现强一致性,也至少要保证最终一致性。各个节点最终会得到一致的值。

  • last write wins,丢弃并发写。这里的 LWW 并不一定时真实的最后,因为写操作式并发的,无法确定最后。但是可以人为确定一个最后。显然可以保证最终一致性,但是对于客户端而言,写入成功的数据也可能会丢失,丧失了持久性。在不能接受数据丢失的场景,这个方案不可行

  • happens-before

    • 两个事件 A 和 B 的关系只有三种
    • A happens-before B
    • B happens-before A
    • A、B 并发
    • 对于并发,需要 merge 并发写的值
      • Version vector,用于追踪事件的偏序关系(或者叫做因果关系),但是 version vector 随着写的增多,会呈指数级增长1

结语

这篇文章从 6.26 开始写(所以文章开头都是基于 6.26 的),到现在两周。大约是全书的三分之一吧,文章内容基本是书中内容的重复性复述。原本是想要写全书的,奈何内容确实很多,而我虽然已经把书读了一般,但是书中所讲并没有完全吃透。因而必须要循着读书时记得一些笔记,并且翻书重看,才能进行下去,也的确耗时。书中余下的内容,应该会有续篇。

《Redis深度历险》读后

周末花了两个下午,把之前买的一本书看完了。书名叫做《Redis深度历险:核心原理与应用实践》。书很薄,正文只有230页,全彩印刷,定价79,买的时候有打折,50多块。

全书内容

全书分为5篇,分别是基础和应用篇、原理篇、集群篇、拓展篇和源码篇。

基础应用篇

基础和应用篇,讲了下 Redis 的基础功能,以及 Redis 原生支持的一些功能。读的时候,这一篇原本是想略过的,以为就是要写 Redis 的各种命令的使用。 实际上也确实是讲了各种命令,但是还讲了一些我原本并不了解的功能。这一篇的内容,大致可以分为两种,一是内置的命令以及应用,比如 HyperLogLog、Bitmap等;二是一些功能如何使用 Redis 实现,书中讲了分布式锁、延时队列、限流等。

HyperLogLog

书中给 HyperLogLog 举的以一个应用例子是记录一个网页的UV。计算 uv,需要对不同的用户进行去重,一个很直接的想法就是用一个 set 记录所有访问过的用户。这个方法的问题在于,如果用户数过多,占用的空间会非常大。而 HyperLogLog 可以用很少的空间,完成同样的工作,代价则是计数有误差,大概在 0.81%。Redis 的实现里,这个很少的空间是 12KB,与用户的数目无关。

HyperLogLog 是基于概率基数统计算法。其基本思想是,一个二进制串的集合中,所有元素的第一个为 1 的比特的位置 k,与集合的基数 n 之间,存在n = 2^k 的数学关系,当然,是约等于,并且误差比较大。因此实现上,会进行分桶,用调和平均数减小误差。Redis 的实现里,会使用 2^14 = 16384 个桶。

书中对 HyperLogLog 只讲了大概,背后涉及的伯努利实验以及概率都没有讲,可以自己 google,网上还是有不少文章的。

原理篇

原理篇讲了一些 Redis 的一些功能的基本原理吧。说了下 IO 模型、通信协议、管道、事务等。因为我本身对 Redis 不是很了解,所以对我而言,这一篇还是学到了一些东西,对 Redis 的一些功能的实现有了点了解。但我还是要说,这一篇的内容不够深入,这也是整本书的风格,内容不够深入。

集群篇

集群篇讲了主从、Sentinel、Codis 以及 Redis Clster。看完可以对这几点内容都有些了解,大面上可以了解 Codis 和 Redis Cluster 区别以及不同的技术选型。比如,Codis 有 zk 保存槽位信息,通过 proxy 访问实例;Redis Cluster 去中心化,实例间通过 Gossip 交互,客户端可以直接访问实例,速度更好。这些了解,在使用中可能也够了。不过,这一篇没有深入实现细节。

拓展篇

拓展篇讲了 Redis 5.0 的 Stream,又讲了 Info 命令。然后是过期策略、懒惰删除等。甚至还有如何用 Jedis 以及用 Spiped 来安全传输。总之,这一篇讲的比较杂,设计了 Redis 内置的命令,也有 Redis 的原理以及实现考量,又有如何使用 Redis 的库。算是在源码篇之前,又不适合放到其他篇的一些内容吧。

源码篇

源码篇,顾名思义,是讲源码的。这一篇的源码,主要是关于 Redis 的数据结构的,比如字符串、hash、list、zset等等。这一篇,可以很明显的感觉到,Redis 实现过程中,对于内存的斤斤计较,以及单线程下对于 CPU 占用时长的考量。

总结

整本书看下来,收获不算小,对 Redis 也有了更多的了解。书的语言也是比较通俗易懂,图解也是很清楚的。看得比较顺畅。缺点呢,也有一些。前边也说过,内容讲得不够深入,偏浅。这一点可能也是因为 Redis 的东西本来就不多吧,很多内容应该可以在网上找到的。还有就是,有些地方是可以不用代码的。比如在讲 HyperLogLog 时,为了说明 n 和 k 关系,贴了两段用于实验的代码,Java 和 Python 各一版。说实话,这些完全没有必要,并没有比用文字更清晰,也和讲解的内容关系不大,有点像是凑篇幅一样。如果作者认为确实很重要,可以放到附录里。此外,书中的一些代码的排版是有问题的,尤其原本 Redis 里的注释。源码的注释,在打印到书里,由于太长,会换行,但是换行还不多。比如这样,

    /* If we reached the 1:1 ratio, and we are allowed to resize
the hash
     * table (global setting) or we should avoid it but the ratio
between
     * elemenets/buckets .....
     */ 

真的很影响阅读体验,并且浪费纸。

总体而言,给7分吧。

neovim 插件 denote.nvim

neovim

hyperextensible Vim-based text editor。这是 neovim 官网对其的定义。目前,neovim 仍然在快速迭代,并且现在的版本(v0.3.5)已经可用。neovim 和 vim 在基本使用时兼容的,配置文件也基本是兼容的。因为我不是 vim 重度用 户,所以并没有发现 vim 中可用但是 neovim 中不可用的功能。目前我已经全部切换到 neovim 了。

denite.nvim

denite.nvim 是 neovim 的一个插件,在 vim 8 中也能使用。denite.nvim 利用了异步特性,性能相比上一代的unite.vim 有了很大的提升。denite 作者对其的描述是,“Dark powered asynchronous unite all interfaces for Neovim/Vim8”。说实话,从这句话,我是根本没明白这个插件到底做的是啥。不过看到一些推荐这个插件的地方,是用作 fuzzy finder 的,所以还是装了看了下。

简单地说,denite 是给 neovim 中提供了类似于 VSCode、Atom 中类似于 Ctrl-P 的功能。其实也有其他的插件提供类 似的功能,比如 fzf.vimctrlp.vim。不过我没有用过这些插件,所以本文不会对比。

denite 的配置比较复杂,幸而文档非常完善。denite 中,通过 source 收集不同的可以访问的对象。利用 :Denite 指令,列出 source 收集的对象,进而针对选择的对象,执行不同的操作。

先来个简单的例子,:Denite file/rec,查看当前目录下的所有的文件列表,rec 的意思是递归。所以,也有一个 file 的 source,只会显示当前目录下最顶层的文件。执行命令后,会有一个新的 buffer,列出所有的内容,称为 denite buffer。denite buffer 和普通的 vim buffer 不太一样, 可以认为是一个临时的 buffer。denite buffer 的操作方式和 vim buffer 也是不同的,但是也有两种模式,Insert 和 Normal。执行指令后,默认会进入 Insert 模式,denite 会根据用户的输入,过滤掉不匹配的候选项,默认的匹配方法是 fuzzy match,和 VSCode 中的 Ctrl-P 是一样的。选定候选之后,可以执行不同的操作,比如预览、打开、在另一个 buffer 打开等等。

source

source 是 denite 的核心概念。denite 可选择的候选都是由 source 提供的。简单而言,每个 source 提供了一个候选对象的(有序)集合。比如前边提到的 file/rec,内置的 source 还包括比如 colorscheme、buffer、command 等。source 可以有参数,在执行 :Denite 时,可以传递参数。比如 file/rec,可以接受一个参数作为搜索的目录,如果参数省略,则使用当前目录。除了参数,source 还可以通过 denite#custom#var 自定义一些变量(variable)。比如

call denite#custom#var('file/rec', 'command', ['git', 'ls-files', '-co', '--exclude-standard'])

这行代码自定义了 file/rec 这个 source 的 command 变量,这个变量是 source 获取文件列表的命令。在 Unix 环境下,默认的是使用 find 的。自定义之后,则变成了 git ls-files -co --exclude-standard。不同的 soruce 的参数以及可以自定义的变量都不同,具体的需要查看文档了。

denite buffer

source 收集的对象,都是列在 denite buffer 里,用户可以通过输入来过滤搜索结果。默认的设置下,进入 denite buffer 是处于 Insert 模式,这时用户可以进行输入过滤结果。在 Insert 模式下,输入 <tab>,可以选择一些可用的 action。不同的结果,可用的 action 也不同。结果又不同的类型,比如 file、buffer 等。不同的类型支持不同的 action 集合。

和 vim buffer 一样,denite buffer 也支持自定义 map。当然就不是 vim 默认的定义 map 的方式了。denite 中使用 denite#custom#map 定义 map。来个例子

call denite#custom#map('normal', '<tab>', '<denite:do_action:preview>', 'noremap')

这个例子,把 Normal 模式下的 <tab> 键,映射为预览操作。最后的 noremap 和 vim 中的意义一样,因为默认 denite 也会递归的解析映射的定义。定义了映射后,denite buffer 的操作和 vim 就没有区别了。现在 VSCode 中也是可以使用 vim 的,但是只能在编辑区。使用 Ctrl-P 的时候,可能还是不得不依赖方向键。在 denite 里就没有了这个问题。

预览主题的例子

denite 内置了 colorscheme 的 source,收集了所有已安装的主题。本来,在 vim 中切换主题,不是很方便,因为没有预览功能,只能 :color <theme> 选定主题,不合适就继续重复。而 denite 利用 -auto-action=preview 选项,配合 colorscheme source,可以方便的切换主题。

:Denite -auto-action=preview colorscheme`

-auto-action=preview 的意思是,对选中的结果,自动执行 preivew 的操作。-auto-action 是 denite 的 option。可以通过 denite#custom#option 设置自定义 option。默认设置下,-auto-action 是空的。

call denite#custom#option('default', 'auto_action', 'preview')

option 的名字需要改一下,去掉前缀的 - 并且把中间的 - 替换为 _

我的配置

nnoremap <leader><space> :Denite<space>

call denite#custom#alias('source', 'file/rec/git', 'file/rec')
call denite#custom#var('file/rec/git', 'command', ['git', 'ls-files', '-co', '--exclude-standard'])
nnoremap <silent> <C-p> :<C-u>Denite -auto-action=preview 
            \ `finddir('.git', ';') != '' ? 'file/rec/git' : 'file/rec'`<cr>
nnoremap <leader>c :<C-u>Denite colorscheme -auto-action=preview<cr>
nnoremap <leader>; :<C-u>Denite file_mru<cr>

call denite#custom#map('insert', '<tab>', '<denite:move_to_next_line>', 'noremap')
call denite#custom#map('insert', '<S-tab>', '<denite:move_to_previous_line>', 'noremap')
call denite#custom#map('insert', '<C-cr>', '<denite:choose_action>', 'noremap')
call denite#custom#map('insert', 'jj', '<denite:enter_mode:normal>', 'noremap')

call denite#custom#map('normal', '<tab>', '<denite:do_action:preview>', 'noremap')
call denite#custom#map('normal', '<S-tab>', '<denite:choose_action>', 'noremap')

call denite#custom#option('default', 'winheight', '15')

有两点说明下。其中 file/rec/git 相关,是 denite 的 FAQ 中的方案。因为默认情况下,file/rec 会显示所有目录下的文件,包括 .git 目录,直接用 file/rec 的结果并非期望的结果,所以当存在 .git 目录时,用 git ls-files 获取文案列表。配置中使用了一个 file_mru 的 source,提供的是最近使用的文件的列表,这个 source 不是 denite 内置的,是由插件 neomru 提供的,与 denite 是同一个作者 Shougo。

总结

denite 的配置难度比较高,但是配置好之后,用起来就非常顺手。另外,neovim 一个很重也好的发展方向,就是 embeded everywhere,也就是嵌入各种地方。给 neovim 套一个更现代的一个壳,是我对 neovim 的一个非常大的期待。而 denite 给现代的壳,提供了一个非常好的实现 Ctrl-P 的方式。

在只有类名时使用 Jackson 反序列化 Java 对象

反序列化

Java 里,用 Jackson 序列号和反序列化,非常简单。序列化不是本文关心的内容,不谈。反序列的接口,基本下边这两个接口。

<T> T readValue(String content, Class<T> valueType);
<T> T readValue(String content, TypeReference valueTypeRef);

使用 Class<T> 的接口,返回的是一个类型为 T 的对象。接口的声明很直接,使用也比较舒服。不过当需要反序列的是带有参数的类型,比如 List 等,这个接口提供的信息就缺失了一些,那就是 List 的类型参数。在语法上,readValue(s, List<Type>.class) 是行不通的。而,List<Dog>List<Cat> 显然不同,在反序列化时,我们期望得到元素类型不同的 List

Java 中,不允许 List<Type>.class 的写法,是因为类型擦除。编译之后,只有 List 这个 rawType。为了解决这个问题,Jackson 提供了 TypeRefernece,来保存类型参数信息。既然可以保存,显然,类型擦除并没有完全地把所有的类型参数的信息都丢掉。实际上,可以通过反射来获取类的泛型信息,方法是通过 Class#getGenericSuperclass 获取泛型父类,返回的类型是 Type。如果父类实际上没有类型参数,则实际上是个 Class,否则,是 ParameterizedType。看下 TypeReference 的具体实现,

// 删除了无关代码、注释、空行
public abstract class TypeReference<T> {
    protected final Type _type;
    protected TypeReference() {
        Type superClass = getClass().getGenericSuperclass();
        if (superClass instanceof Class<?>) { // sanity check, should never happen
            throw new IllegalArgumentException("Internal error: TypeReference constructed without actual type information");
        }
        _type = ((ParameterizedType) superClass).getActualTypeArguments()[0];
    }
    public Type getType() { return _type; }
}

注意 TypeReference 是一个抽象类,这意味着实例化时都是要创建一个子类。有了 TypeRerence,反序列化就没有问题了。

没有类型的具体信息

这里其实还有个问题,假如我没有类的实际信息,只有类的名字以及类型参数的名字,怎么来反序列化呢?我碰到这个问题,是在尝试写一个简单的 RPC,序列化方式就是用的 json。在传递参数时,客户端只能把参数的类型通过字符串传递,需要在服务端解析出来。

TypeReference 的构造函数,要求必须有类型参数。然而,即使通过类名,获得了对应的 Class 实例,也没有办法转变成字面量去创建一个 TypeReference。解决办法其实很简单,重写 getType 方法,因为 TypeReference 的目的就只是提供一个 getType 方法。

class CustomeTypeRef<T> extends TypeReference<T> {
        private Class<?> rawType, paramType;
        protected MyTypeRef(Class<?> rawType, Class<?> paramType) {
            this.rawType = rawType;
            this.paramType = paramType;
        }
        @Override
        public Type getType() {
            return new ParameterizedType() {
                @Override
                public Type[] getActualTypeArguments() {
                    return new Type[]{paramType};
                }
                @Override
                public Type getRawType() {
                    return rawType;
                }
                @Override
                public Type getOwnerType() {
                    return null;
                }
            };
        }
    }
}

虽然参数类型 T 已经不需要了,但是必须得有。因为在 TypeReference 的默认构造函数,强制必须有参数类型。

看起来很简单的样子啊。

Default method in interface

Java 中的interface是一组抽象方法的集合,是一组对外的接口。Java 8 之前,在interface中是没有方法体,的,只能在子类实现。如果是所有子类都一致的实现,标准方法是定义一个实现这个接口的抽象类,在抽象类中实现公共方法,子类集成这个抽象类,并实现各子类不同的方法。这样的写法并没有什么问题,只是啰嗦而已。在 Java 8 中,接口内可以实现 defaut method。所有实现改接口的类,如果没有重写这个方法,则使用接口中的实现。针对上面的场景,可以少写一个类。

此外,接口和抽象类的一个重要区别在于,一个类只能继承一个父类,却可以实现多个接口。在没有 default method 的时候,多个接口表示我们在子类中实现的方法变多了。假如一个接口内只有 default method,那么继承这个接口的类可以不用实现接口的方法,而自动的获得了一个方法。在一些场景下,可以利用这个方法给类增加一些 utils 方法。比如下边的代码,Person只需要继承Jsonable就可以自动的获得toJson方法。

interface Jsonable {
    default String toJson() {
        return JsonSerializer.serialize(this);
    }
}
class Person implements Jsonable {
    private int id;
    private String name;
}

一些简单的 utils 方法,就可以不必定义一个 utils 类了。当然这是一种口味的问题,并不是所有人都喜欢这样一种方法的。此外,之所以说简答的 utils 方法可以这样实现,原因在于 java 的 interface 中不能有状态,所以有些是做不了的。比如缓存、log 等。并且现在 java 还是不支持接口中的私有方法,所以写一些复杂的方法在接口中,会暴露不必要的细节。好消息是 Java 9 已经支持了接口中的私有方法。

default method 是一种 mixin,不过功能有限。而一些语言则提供更完善的 mixin 机制,比如 scala 的traittrait是可以有状态的,从而对子类的的侵入会更少。对于 mixin 还不是很了解,可以参考下边的链接。

参考:
traits-java-8-default-methods
what-are-the-differences-and-similarties-between-scala-traits-vs-java-8-interfaces
Mixin是什么概念?

从Jekyll迁移到Hugo

最近把博客从Jekyll迁移到了Hugo,在这里记录一下。

之前一直使用Jekyll,最大的原因是Github Pages原生支持Jekyll, repo里只管理源代码就可以,不需要上传build之后的文件。 不过Jekyll也有许多不尽如人意的地方,主要是一下几点:

  • 本地开发环境不容易配置。 没有直接的可执行文件,需要安装ruby,gem,之后再安装jekyll
  • Github Pages的build配置不能按照自己的需求定制。 各种依赖的版本不能自己选择,也不能根据使用Jekyll的一些插件
  • 编译速度慢。 因为本站的文章太少,这一点倒是不是什么问题

内容迁移

博客从Jekyll迁移到Hugo,在考虑主题迁移的情况下,还是比较简单的。 Hugo的命令行可以直接从Jekyll导入文章,

hugo import jekyll /path/to/jekyll/root /target/path

这样可以直接导入文章,Jekyll中其他的静态文件会被放到static文件夹中。 这个命令比较简单,但并没有把所有事情干完。只是把Jekyll中的文章放到了Hugo中, 并且根据文件名里的时间,放到了front matter中,其他并没有改动。 文件名也仍然需要自己手动修改,去掉时间。

此外,Hugo使用的markdown,在语法上也与Jekyll有些差异,渲染出来的html也是有些不同。 这里可以在Hugo的配置文件里进行修改,也可以修改文章的语法。

自动编译

使用Jekyll的时候其实是不需要这一步的,直接推到github上就行了。 使用hugo,如果每次都是本地编译,然后把编译后的html文件推到github, 那就太不方便了。而且这样也不利于源码文件的管理。

Travis CI

Travis CI是持续集成服务,并且对于Github上的public repo是免费使用的。 利用Travis CI,可以达到每次push到Github上的时候,自动build, 并推送到repo的特定的分支。

首先需要在Travis CI上开启repo的自动集成,然后在repo里添加.travis.yml。 Travis CI会根据这个文件,进行build。根据Travis CI的文档, build过程分为几个阶段。一个静态博客的build,比较简单, 也不用所有的过程都用上。在install阶段安装hugo,script阶段调用hugo进行编译, after_success阶段把生成的文件推到github上。这是本站的使用的配置。

# .traivs.yml
language: go
go:
 - '1.10'
branches:
  only:
  - source # 只有source分支的推送才触发构建
install:
  - wget /path/to/hugo/releases -O /tmp/hugo.tar.gz
  - mkdir -p bin
  - tar -xvf /tmp/hugo.tar.gz -C bin
script:
  - bin/hugo
after_success:
  - sh .travis/push.sh
# push.sh
setup_git() {
    git config --global user.name "[email protected]"
    git config --global user.email "Travis CI"
}

commit_files() {
    git init
    git add .
    git commit -m"Travis build: $TRAVIS_BUILD_NUMBER"
}

push() {
    git remote add origin https://${GH_TOKEN}@github.com/yourname/yourrepo.git
    git push -f -u origin master
}

cd public
setup_git
commit_files
push

由于Travis CI并没有repo的push权限,所以直接推到repo上是会验证失败的。 push.sh里,GH_TOKEN是有public_repo权限的personal access token, 可以在https://github.com/settings/tokens申请,并在Travis CI上设置。

至此,Travis CI的自动build就完成了。

Synchronized的内存屏障

问题

在V2EX上看到这样一个问题,具体来说,就是下面这份代码,注释和不注释,为什么运行会有不同

public class MyRun implements Runnable {

	private boolean stop;

	MyRun(boolean status) {
		this.stop = status;
	}

	@Override
	public void run() {
		while(!stop) {
			// System.out.println("running");
		}
		System.out.println("stop");
	}

	public void setStop(boolean stop) {
		this.stop = stop;
	}
}

// 测试代码
MyRun myRun = new MyRun(false);
new Thread(myRun).start();
Thread.sleep(1000); // 等待线程执行
myRun.setStop(true);

这个代码目的就是通过主线程修改变量,控制子线程的运行。 为了这个目的,很显然stop需要添加volitale关键字,表明stop是多线程可见的。 那么,子线程在读取stop的时候,会从先把主内存的变量同步到自己的工作内存,然后再使用, 因而可以拿到最新的stop的值。

抛开volatile不谈,单独这份代码,注释和不注释下,运行结果也有很大差异。

  • 注释的情况下,子线程没有得到stop的最新值,其工作内存中的stop一直是false,因此程序死循环。 这和预期情况一致。
  • 不注释的情况下,程序会一直输出running,知道1秒后,输出stop。显然子线程获得到了stop的最新值。 这里的我就不太理解了,为什么呢?

syncronized

最开始我以为是IO引起的用户态内核态切换,会导致从主存中同步,不过查了一圈资料,这个猜想是错误的。

println函数在jdk里的实现是这样的

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

里面有个synchronized,估计就是和这个有关了。

手头有本《深入理解Java虚拟机》(简称书),里边关于Java的内存模型, 有这样的说法

同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同不会主内存中(执行store、wirte操作)”这条规则获得的。

但是这个说法和这里用法不一样,因为书中说法,意思是退出同步块之前,要把synchronized的对象同步会主内存。 而本问题中,同步块锁住的对象this,是指System.out这个对象,并不是myRun

JSR 133 FAQ中,有如下说法

Before we can enter a synchronized block, we acquire the monitor, which has the effect of invalidating the local processor cache so that variables will be reloaded from main memory. We will then be able to see all of the writes made visible by the previous release.

这说明synchronzed可以是使本地CPU缓存失效,从而从主内存中读取最新的变量值。 但是后面的有一个Important Note,表明只有释放和获取的是同一把锁,才能保证happen before关系, 又让我对这段胡的理解产生了疑问。 在stackoverflow上,有一个关于这段话的提问,但是并没有让我更明白。

之后又去看Java语言规范中关于内存模型的部分。 在Java语言规范17.1节,关于synchronized块,有如下说明

attempts to perform a lock action on that object's monitor and does not proceed further until the lock action has successfully completed

这里的一个重点是lock action,这章中只说明lock的意思是locking a monitor,并没有具体的解释。 书中写到Java内存模型有8个操作,其中一个就是lock,但是Java语言规范中并没有相关说明。 最后在Java6的虚拟机规范第8章中,才找到对其的说明,并有一个对于本问题的重要的规则

Let T be any thread, let V be any variable, and let L be any lock. There are certain constraints on the operations performed by T with respect to V and L:

  • Between a lock operation by T on L and a subsequent use or store operation by T on a variable V, an assign or load operation on V must intervene; moreover, if it is a load operation, then the read operation corresponding to that load must follow the lock operation, as seen by main memory. (Less formally: a lock operation behaves as if it flushes all variables from the thread's working memory, after which the thread must either assign them itself or load copies anew from main memory.)

这个规则说明,synchronized可以保证其工作内存中的变量都是最新版本。对于本问题,对System.out的锁, 更新了工作内存中的值,从而退出循环。

不过,在Java7和Java8的虚拟机规范中,这一章被移除了,并将相应的内容放到了Java语言规范中, 也就是上文所引用的第17章中。但是我并没有在其中找到与这个规则具有相同意义的规则。 不知道哪里漏了。

变体

把问题中的run方法改一下,变成

public void run() {
    while(!stop) {
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    System.out.println("stop");
}

实际上也是会最后输出stop的。但是Java语言规范中明确表示,

It is important to note that neither Thread.sleep nor Thread.yield have any synchronization semantics. In particular, the compiler does not have to flush writes cached in registers out to shared memory before a call to Thread.sleep or Thread.yield, nor does the compiler have to reload values cached in registers after a call to Thread.sleep or Thread.yield.

也就是说Thread.sleep是不需要刷新工作内存的。 但是这里仍然打印了stop,说明在某种情况下,线程冲主内存同步了变量。 由于这并不是Java的规范,所以这是和JVM的具体实现相关,因此并不能依赖于这一点。

总结

Java的内存模型之前看过,但是并不是非常清楚。这次前后查了好多,也有了更多的理解。 并且还有个问题并没有搞清楚,Java8的规范里,哪条规则能够明确的推导出Java6关于lock的规则。 这个就慢慢再看吧

Updated

原贴下有贴出了一个链接,感觉说得刚靠谱。JVM虚拟机做了优化,会尽可能的保障工作内存与主内存的同步。 这样就解释了synchronizedsleep时,线程能够获取到最新变量。

想想还是太naive了,还是要多学多看啊

JDK源码阅读之String

这几天看了看Java的String的实现。Java中的所有的String字面量都是String类的实例。 文件注释中写到了,字面量生命String s = "abc"

char[] data = new char[]{'a', 'b', 'c'};
String s = new String(data)

效果是一样的。这应该是JVM来实现的。

接口

String实现了三个接口,分别是SerializableComparable<String>CharSequenceSerializable用于序列化,Comparable<String>用于比较, CharSequence则是String的一个更通用的抽象。

属性

String最重要的属性是private final char value[]value中存放着String的实际内容。 此外,Java的char的长度是16bit,两个字节。 另外一个属性是private int hash,是String得哈希值,默认为0。hash在调用hashCode()时计算, 因此不是final

构造方法

String的构造方法分为几类

  • 无参构造方法,得到的就是空的字符串
  • 参数是String的,直接对valuehash赋值
  • char[]相关的,这类方法进行越界检查,由于String是不可变的,还会复制数组
  • int[]相关的,这里的参数是Unicode的codePoint,因为Unicode是4字节的,所以使用了int。 对于基本平面(BMP)的字符,只需要char即可。对于辅助平面的字符,一个codePoint,需要两个char才能存下。
  • byte[]相关的,所有从byte[]转为String的,都需要指明编码格式。 曾经有过不需要指明编码格式的方法,但是现在已经Deprecated了,因为有bug。
  • StringBuilderStringBuffer,具体实现上都是复制数组,StringBuffer加了锁
  • String(char[] value, boolean share),这是一个特殊的构造方法,这个方法可访问性是包内可见。 为了性能上的考量,实现上不做数组复制,只是简单的赋值。调用的时候,share一定是true

方法

所有方法方法中,涉及到可能产生新的字符串的,都会先检查参数,是否可以直接返回自身。

  • length直接返回value.length
  • isEmpty判断value.length == 0
  • hasCode返回hash,如果没有计算过,用times31算法计算,并保存结果
  • equals,不比较hashCode,直接按序比较字符
  • 其他比较相关的方法,
    • 定义了内部静态类CaseInsensitiveCompartator,用于处理大小写不敏感的比较
    • 基本上都是从前向后遍历
    • compareTo方法是按照Unicode字典序比较的,有不同则返回不同的字符的差,否则返回长度的差
  • indexOf(int ch, int fromIndex)
    • indexOf(ch) == indexOf(ch, 0)
    • 先判断ch,如果是负值(非法值)或者BMP字符,从前到后扫一遍
    • 否则是辅助平面字符,从前到后扫,比较前导代理以及后尾代理
  • indexOf(String s, int fromIndex)
    • 实现上,先找到第一个字符,然后比较余下字符。不断循环
    • 效率上比较低下,stackoverflow有个讨论,我觉得还是有道理的
  • contains,判断indexOf() > -1
  • matches,调用Pattern.matches
  • split(String regex, int limit)
    • limit表示分割后的数组的长度,若0,表示不限制结果的个数。默认为0
    • 实现上,如果regex是简单的字符串
      • 单个字符,并且不是正则表达式的元字符
      • 两个字符,第一个是'\\',并且第二个不是ascii字母和数字
      • 从前到后扫,调用indexOf
    • 否则调用Pattern
    • 空的字符串会返回
    • 但是,split方法有个坑,就是最后一个分隔符后面如果没有其他字符,那么是没有最后一个空字符串的
      • "hello,,yes,".split(",") == ["hello", "", "yes"]
  • join,静态方法,调用StringJoiner
    • null会按照"null"处理
  • concat(str),不检查参数,如果为null会报异常,如果str.length != 0,开辟新的数组。
    • 只调用一次数组复制,
  • substring,检查之后复制数组。之前某个版本好像是没有复制数组,导致了内存泄漏
  • trim,实现上是去除了前后的所有ascii码小于等于20的字符
  • replace
    • 字符替换,如果相同或者未发现,直接返回,否则遍历
    • 字符串替换,调用Pattern
  • 大小写转换相关方法的实现,考虑的东西比较多,实现比较复杂。涉及了Locale,未知名则使用系统默认的。 不同的语种,大小写规则不太一样,调用了ConditionalSpecialCasing进行实际的转换
  • toString,返回自己
  • toCharArray,调用System.arraycopy产生新的数组
  • valueOf系列
    • char[],调用构造函数
    • Object"nul"或者toString()
    • 内置类型,直接调用响应的toString()
  • native intern(),将自身添加到字符串池

2017之前

2016年,干了几件事,出去实习了,找到工作了,以及毕设相关。

先说实习,第一次是在腾讯。虽然我进去的之后的岗位也是研发实习生,但这个部门其实并不是一个做研发的部门,是做直播的,腾讯视频的一些的直播的技术支持部门,主要是体育赛事,还会有一些演唱会、新闻等等。由于不是研发部门,其实进去之后首先熟悉了演播室的直播设备,跟着值班了一段时间。除却这些直播技术,部门还负责直播的在线包装、场景设计,使用的广电行业的专用软件,Viz。我感觉Viz还是非常复杂,有各种插件用于实现各种功能,当这些插件无法实现所需要的功能时,Viz还提供脚本支持,可以编写一些复杂的动画、人机交互界面等等。这个脚本还是比较简单的,当时的leader说这个脚本其实就是VB差不多。Viz脚本其实想要招程序员的一个最重要的一点。不过由于我在腾讯的实习其实还是比较短,Viz脚本也没学多少。后来实习快结束的时候,又给我了一个开发一个内部使用的系统的任务。从2015年12月末开始,在腾讯实习了3个月多。由于不是研发部门,对研发的情况不是很了解,就我自己所在的部门,气氛是比较轻松的,也没有特别严格的作息要求。腾讯有专门的内部技术论坛,供工程师交流。

从腾讯离职之后,由师兄内推,又去了微软实习。部门是微软小冰。进去以后,我的座位是由一个会议室改的,是一个单间,与mentor的办公位不在一起,而且做的项目基本上是一个人项目,所以和其他同事的交流不多。实习期间,做了两个项目,一个是内部数据的在线编辑管理的系统吧,另一个是爬虫。第一个项目,实质上是增删改查,不过需要先把原有的文本文件的数据倒进来。而且需要有个界面,我选了Vue做前台,这里其实就是自己的选择,没有过多的比较,而且在腾讯做的项目也是用了Vue,还没忘光。第二个爬虫是mentor自己从头写的,我要做的就是增添一些功能。通过ssh远程在服务器上写,原因是用了一些库,只能在Linux上运行。因为这,把vim又好好学了一下。在微软实习,还是很轻松,没有压力,任务完成就行,mentor人也好。之后由于要开始找工作了,而且也要做毕设,就离职了。

大约8月初,各种内推就开始了,直到10月末签了三方,足足3个月。而且必须吐槽网易带了个好头,内推不免笔试,结果都成这个套路了。这段时间,除了吃饭睡觉,就是笔试面试和准备笔试面试。leetcode刷了绝大部分,没有像同学一样其他第二遍。还有就是各种基础吧,主要是笔试面试啥都问,数据库、网络、操作系统、jvm、Java的多线程、JDK的常用集合的实现各种各种,上到高并发设计,下到内存页表。经常投的Java岗,结果笔试还是一堆的c++、php。

我也也参加了不少面试,给我感觉最好的是Google和AirBnb,两者风格完全不同。Google应该是面试人数比较少,面试前后都会有电话通知,然后我就收到可面试挂了的电话。。面试官是外国人,为了面试专门飞过来的,很有经验,题目就是算法题,白板写。AirBnb 的面试官是中国人,但是面试要求说英语,一轮45分钟,大约15分钟的题目,剩下的30分钟写代码,是可运行的环境。这个是我最喜欢的方式。面试给我感觉不好的事华为和网易。华为是两面很奇怪,根本没有问一些有区分度的问题,结果就跪了。网易的面试过程还可以,但是安排比较乱,面试官和HR之间的信息沟通不畅,中间空等了好长时间。还有滴滴,虽然我没参加,但是今年的面试安排真的是一团shi。

至于论文,目的就是毕业了。自己对做科研还是没有太大的兴趣,写代码做工程更适合我。答辩时间改到明年3月,希望一切顺利!

用Rust写了一个简单的Web服务器

Rust

最近学了一阵Rust,这个语言的目的是系统编程,卖点是无GC的内存安全。为了实现这一点,Rust引入了所有权、借用、生命周期的概念。可以在编译器检查出可能的内存问题,如野指针、局部变量指针等等。不过这也对写程序造成了一定的困扰,对于move、borrow等如果理解的不是很到位,那必然要和编译器做长期的斗争。

Web服务器

骨架

Web服务器,实际上就是对socket的数据流的处理,监听端口,并对每个新的连接,开启一个新的线程进行处理。代码的骨架基本上是

match TcpListener::bind("127.0.0.1", 9999)) {
    Ok(listener) => {
        for stream in listener.incoming() {
            match stream {
                Err(e) => {
                    // error, log, ignore
                },
                Ok(s) => {
                    thread::spawn(move || handle_client(s));
                },
            }
        }
        drop(listener);
    },
    Err(e) => {
        // error, log, ignore
    }
}

其中thread::spawn(move || handle_client(s)),开启新的线程,参数是一个闭包,move关键字表示将闭包所在环境的标量的所有权强行交给闭包。之后重点是handle_client中对于TcpStream的处理,也就是解析请求,并构造响应。读取请求。

解析请求

一个HTTP的请求,格式是这样的

METHOD URI VERSION
Host: xxx
other-header: xxx

body

这个服务器目前只能处理GET和HEAD请求,并且只能处理静态文件,所有很多东西并没有做。比如querystring的解析、请求体的解析等等。各种header也只是解析,并没有真的使用。之后会慢慢完善,函数重点是

fn parse(stream: &mut TcpStream) -> Option<Request> {
    let mut s = Vec::new();
    Self::get_request(stream, &mut s);
    match String::from_utf8(s) {
        Ok(s) => {
            // parse request line and header
        },
        Err(_) => None,
    }
}

如果解析失败,返回一个None,这是Request结构的一个静态方法。解析成功则打印日志,并根据请求构造响应。

构造响应

响应的的格式为

VERSION CODE PHRASE
header: xxx
other-header: xxx

body

由于只能处理静态请求,实际上这里就是读取文件并.对于HEAD请求,只计算长度,没有响应体部分。

目前的相应的结构为

struct Response {
    head: String,
    body: String
}

通过code、mime、content等拼接字符串,得到响应头部以及响应体。最后通过TcpStream发送出去。

至此,这个web服务器就算是完成了。

最后

Rust这个语言还是非常不熟,对于lifetime的理解也太行,所以通篇没有用到lifetime标记,遇到字符串都是用的String。另外,Rust目前并没有高性能的非阻塞IO以及异步IO,有一些库在做这方面的尝试。不过对这方面不熟,没有多做尝试。

最后,项目的地址是https://github.com/iEverX/rock