Pete Corey的博客《Build your own code poster with Elixir》展示了如何通过Elixir实现一个类似Commits.io一样的功能,即可以将自己的代码融合到一张LOGO图片中,例如这样的效果:
左侧是原图,右侧则将代码重叠到图中有颜色的部分,形成一种颇有极客范儿的图片。这很有趣。
实现代码并不复杂,不到200行代码。虽然简单,代码却很好地体现了Elixir编程的风格。这种风格提倡运用|>
管道符以流的形式传递数据,体现函数组合子的强大威力。针对问题域,我们的解决方案是思考数据流动的方向,以及处理数据的各个阶段,并将其组合成一个顺序的执行流程。
以Code Poster为例,其执行流程如下所示:
- 读取代码文件
- 读取图片文件
- 生成Text元素集(这个过程会完成代码字符与颜色的融合)
- 生成SVG
- 保存SVG文件
Elixir的管道运算符,会自然地让我们想起Unix中实现的Pipe and Filter模式,每个Filter均被定义为一个统一的接口:输入为stdin,输出为stdout或者stderr。
在Unix中,这种统一接口可以被隐喻为文件(file),上图中的stdin、stdout与stderr都属于file descriptor,可以像操作文件那样读或写字节流(stream of bytes)。这里所谓的文件是一个宽泛的概念,可以是一个文件系统,也可以通过管道(pipe)将字节流传递到另一个进程,可以是Unix Socket,设备驱动,内核API以及TCP连接。
那么,在Code Poster场景下,这个统一接口是什么呢?或者说我们需要抽象的数据究竟是什么呢?分析前面的执行流程,读取一个code文件与读取一个image文件,返回的结果其实完全不同。那么,如这般不同的数据结构如何才能够像Stream一样通过管道连接起来呢?
在Elixir中,我们通常通过定义一个struct来完成对数据的抽象。整个管道处理中需要的数据会作为一个“并集”被定义到struct的属性中。例如,load code file操作需要一个code path,返回文件中的代码code;load image file操作则需要一个image path,返回一个image结构体。image结构体包含了图片的width和所有像素(pixels)。这些数据都是construct text element步骤需要的,在对这些数据进行处理后,该步骤又会返回生成的text elements集合;然后再交给construct svg,最后生成一个svg对象并保存。这个过程如下图所示:
整个过程传递的数据为PosterData,它好像是一个数据容器一般,各个执行步骤都会对该数据容器传来的相关数据进行处理,然后再将获得的结果塞到该容器中,再接力传递给下一个。PosterData的定义如下:
defstruct code_path: nil, image_path: nil, ratio: nil, final_width: nil, final_height: nil, out_path: nil, code: nil, image: nil, pixels: nil, text_elements: nil, svg: nil
Elixir使用管道操作符|>
非常好地改善了代码的阅读体验。阅读如下代码,与阅读自然语言没有太大区别:
def go(ratio, final_width, final_height, code_path, image_path, out_path) do %PosterData{ ratio: ratio, final_width: final_width, final_height: final_height, code_path: code_path, image_path: image_path, out_path: out_path } |> load_code |> load_image |> construct_text_elements |> construct_svg |> save_svg end
在编写这样的方法时,我们一定要注意方法的抽象层次,即遵循所谓的单一抽象层次原则。该原则要求一个函数中的所有操作都处于相同的抽象层。只有如此,才不会让函数表达的意思失衡,有的隐藏了细节,有的又暴露了不必要的内容。
我们可以通过对需求的任务逐层拆分来保证这一点。前面提到的流程是最高抽象层的任务分解,而针对load code file,又可以拆分为如下的子任务:
- 读取代码文件
- 去除文件内容中的空格、tab、换行符(表现的业务含义就是拼接这些代码字符)
- 转换为字符集合
load_code
函数体现了这样的流程:
def load_code(data = %PosterData{code_path: code_path}) do Logger.debug("Loading code from '#{code_path}'...") code = code_path |> File.read! |> join_code |> String.codepoints %{data | code: code} end
其中join_code
接收的是File.read!
函数读取的文件内容,它又可以继续拆分子任务:
def join_code(code) do Logger.debug("Joining code...") code |> String.trim |> String.replace(~r/\s*\n+\s*/, " ") |> String.replace(~r/\s/," ") end
多么美妙的代码表现力!又是多么清晰的任务分解层次!管道操作符将整个业务盘活了,就好像赋予了代码灵魂一般。
✟ 本文的代码全部来自Pete Corey在Github的Repository。