有趣的Code Poster

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

2017-03-04 21:44186FPElixir