Sourcery - Swift元编程实践,告别样板代码

6,183 阅读10分钟

前段时间发现了一个十分强大的工具:Sourcery,它很好的解决了我在Swift开发中遇到的一些问题,在中文社区中sourcery似乎并不是很有名,所以这里特地写一篇文章来作介绍。本文大致分为三个部分:

  • 元编程的概念和作用
  • Sourcery的原理和基本使用
  • Sourcery和Codable的实践

什么是元编程

很多人可能对元编程(meta-programming)这个概念比较陌生,当然有一部分是因为翻译的问题,这个“元”字看起来实在是云里雾里。如果用一句话来解释,所谓元编程就是用代码来生成代码

这句话可以从两个层面上来理解:

  • 在运行时通过反射之类的技术来动态修改程序自身的结构。比如说我们都非常熟悉的Objective-C Runtime。
  • 通过DSL来生成特定的代码,这通常发生在编译期预处理阶段。

OC有着十分强大的Runtime特性,在运行时可以查看和修改一个对象的所有成员,所以有了Mantle之类JSON转Model的库;甚至可以在运行时添加、删除、替换一个类型中的方法,当然也可以动态的添加类型,所以有了AspectsAOP。这些应用都可以归纳为元编程的范畴,因为它们的功能都是通过在运行时修改程序本身来实现的,这一特性为我们节省了很多重复的样板代码。

而Swift是一门静态强类型语言,没有OC这样强大的运行时特性,虽然Swift也可以接入OC Runtime,但是那很容易让你的代码变成“用Swift写的OC”,而且对运行时的修改容易让程序变得难以理解。既然这样,再来看看Swift自身的反射机制,Swift提供了一个名为Mirror的类型用来在运行时检查对象的属性,但是一方面Mirror只能查看不能修改,另一方面它的性能很差,文档中也建议仅在Debug的时候使用。

所以说第一条路子在Swift中是走不通了,只能从另一个方面来寻找答案,所幸的是已经有了一套成熟的解决方案,那就是下面要介绍的Sourcery。

Sourcery

简单来说Sourcery是一个Swift代码的生成器,它能够根据我们预先定义好的模板来自动生成Swift代码。

基本使用

定义模板

以官方的Demo为例,比如说你有一个自定义的类型:

struct Person {
	var name: String
    var age: Int
}

想要为这个类型实现Equatable协议,必须在==方法中依次比较每一个属性的相等性:

extension Person {
	static func ==(lhs: Person, rhs: Person) -> Bool {
        guard lhs.name == rhs.name else { return false }
        guard lhs.age == rhs.age else { return false }
        return true
    }
}

通常我们的项目中都会有大量的Model类型,如果要为它们都实现Equatable,会带来大量重复的工作。而且如果你在一个类型中添加了新的属性的话,必须同步修改它的Equatable实现,否则可能会出现难以预料的Bug。

Sourcery可以将我们从这些繁琐的样板代码中解放出来,首先我们需要为所有的Equatable实现定义一个统一的模板,这部分是通过一门名为Stencil的语言来编写的。Stencil是一门专门为Swift设计的模板语言,语法十分简单,对于上面代码可以定义这样的模板(模板的编写推荐使用vscode加上stencil插件):

{% for type in types.implementing.AutoEquatable %}
extension {{type.name}}: Equatable {
    static func ==(lhs: {{type.name}}, rhs: {{type.name}}) -> Bool {
        {% for variable in type.storedVariables %}
        guard lhs.{{variable.name}} == rhs.{{variable.name}} else { return false }
        {% endfor %}
        return true
    }
}
{% endfor %}

代码中出现的AutoEquatable是预先定义在我们自己代码中的一个协议,只是一个作为标记用的空协议:

protocol AutoEquatable { }

它的作用是让我们能够在模板中找到需要的类型,只需将自定义的Person类型声明为实现AutoEquatable,之后在模板中就可以通过types.implementing.AutoEquatable找到目标类型,然后通过type.storedVariables来遍历类型中的所有储存属性生成对应的比较代码。

代码生成

定义了模板之后就可以通过这个模板来生成代码了,首先在系统中安装Sourcery:brew install sourcery。之后运行下面的指令:

sourcery \
   --sources ./YourProject \
   --templates ./YourTemplates \
   --output ./YourProject/AutoGenerated.swift

其中--source指定了工程的根目录,--templates指定存放模板文件的目录,--output将生成的代码输出到指定路径,除了命令行也可以通过一个.sourcery.yml文件来定制参数,这里就不再展开介绍了。

之后就能在工程的路径下看到一个名为AutoGenerated.swift的代码文件,它包含了这样的内容:

// Generated using Sourcery 0.12.0 — https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
extension Person {
	static func ==(lhs: Person, rhs: Person) -> Bool {
        guard lhs.name == rhs.name else { return false }
        guard lhs.age == rhs.age else { return false }
        return true
    }
}

生成的代码文件是需要参与编译的,记得将它添加到工程中。

接着,我们可以将代码生成这一步整合到Xcode的编译流程中,在Build Phases添加这样一个脚本(这里我把sourcery二进制文件也加到了工程目录中):

Run Script

需要注意的是这个脚本一定要添加在Compile Sources之前,否则新生成的代码无法参与编译。在这之后只要我们的类型实现了AutoEquatable,无论是添加还是删除属性,每次Build代码就会自动更新,免去了手动修改的困扰。

以上的Equatable只是作为示例,完整的版本请看官方提供的这个模板AutoEquatable.stencil

原理

从上面的例子中可以看出来,Sourcery之所以如此强大,关键在于模板解析时能够获取我们代码中的所有类型信息,这使我们在编写模板的时候获得了极大的自由度。Sourcery使用了两个关键的技术来实现这一切:Stencil和SourceKitten。

Stencil

在之前的介绍中也提到了,Stencil是一门用Swift实现的专门为Swift设计的模板语言,它的语法十分简单,只解析下面这三种语法模式:

  • {{ ... }}:变量语法,将中间的部分作为变量(或变量的表达式)来解析,解析后的值会作为结果插入到模板中的相应位置上。
  • {% ... %}:标签语法(Tag),标签用来表示一些具有特殊功能的语法,比如用来实现判断的if和循环的for
  • {# ... #}:注释语法,不会出现在解析后的结果中。

除此之外还有一个名为Filter的概念,它的语法是这样的:{{ "stencil"|uppercase }}。符号|左边是输入的变量,右边就是一个Filter,这里输出了字符串的大写形式。Filter本质上是一个输入和输出都是Any的方法,比如说上面的uppercase在源码中对应是这样的:

registerFilter("uppercase", filter: uppercase) // 注入一个Filter

func uppercase(_ value: Any?) -> Any? {
    return stringify(value).uppercased()
}

同样模板解析时可以访问的变量也是在运行时注入到Stencil环境中的。Stencil有着十分强大的扩展性,github上有一个这样的库StencilSwiftKit,为Stencil扩展了许多更加便捷的语法。

SourceKit

Xcode对Swift和OC的处理有一点不同的地方,OC的编译器是在Xcode进程中执行的,而Swift的编译器是在一个独立的进程中进行的,所涉及到的一系列编译工具的集合称为SourceKit,编译的结果通过XPC与Xcode进行通信。

这样一来就有机会对编译中间的结果做一些分析,SourceKitten就是这样一个开源库,它与SourceKit进行交互并将代码的语法结构转换成JSON的形式返回。利用SourceKitten,Sourcery可以获取代码中所有类型的相关信息,并将它们作为变量注入到了Stencil的上下文环境中,所以我们才能在模板中用{{ types }}这样的方式遍历代码中的所有类型。

在Codable中的实践

下面所介绍的Demo已上传至我的Github:AutoCodableDemo

Codable是Swift4引入的对JSON解析的原生支持,与ObjectMapper之类的第三方库相比,它可以自动地解析Model中的属性,如果你的数据模型和JSON结构完全一致的话,使用起来将会非常简单。

然而现实往往并不是这么美好,很多时候需要对解析做一些自定义,这样一来操作将会变得十分繁琐,要自定义KeyPath首先得为类型定义一个实现了CodingKey的枚举,这个枚举中要包含所有的属性字段,即使这个属性不需要自定义;而如果要做更加复杂的自定义的话还得自己实现init(from decoder: Decoder)encode(to encoder: Encoder)方法,并为所有的属性实现decode和encode操作。

显然这些代码具有很高的重复性,非常适合使用Sourcery来自动生成:

AutoCodable

首先在项目中定义一个AutoCodable类型:

protocol AutoCodable: Codable { }

在模板中找到所有实现了AutoCodable的类型,并在扩展中为它们自动加上一个包含了所有属性名的枚举:

enum CodingKeys: String, CodingKey {
    {% for var in type.storedVariables %}
        case {{var.name}} {% if var|annotated:"key" %}= "{{var.annotations.key}}"{% endif %}
    {% endfor %}
}

Sourcery提供了一个名为annotation的机制,可以在代码中以注释的形式向模板提供一些必要的数据,只需要在某个变量或是类型的定义前加上一行这样的注释:

// sourcery: key = "value"
var something: Int

Sourcery会将这种格式的注释解析出来,以key-value的方式添加到模板中该变量所对应的annotations属性上,通过这种方式可以在代码中为模板解析提供一些自定义的数据。

使用

让你的自定义类型实现AutoCodable

struct Person: AutoCodable {
    var myName: String
}

AutoCodable实现了以下功能:

  • 自定义字段名称: 在需要自定义字段名称的属性前加上这样一个annotation

    // sourcery: key = "my_name"
    var myName: String
    
  • 设置属性默认值: AutoCodable允许你为属性提供默认值,当JSON中的该字段解析失败时该属性会被设置为默认值,而不是抛出错误,有了默认值之后该属性不再需要定义成可选类型:

    // sourcery: default = true
    var something: Bool
    
  • 忽略某个字段: 被忽略的属性不会参与JSON的Encode和Decode,另外被忽略的属性必须带有一个默认值:

    // sourcery: skip
    var something: Int = 0
    
  • 支持将Int解析成Bool类型:

    Codable在解析JSON的时候对于类型是有严格要求的,如果一个属性的类型是Bool,在JSON中对应的字段值是Int类型的话会抛出一个类型错误(不像OC中的Mantle会自动转换)。 虽然Codable的这个做法无可厚非,然而在我们的实际项目中已经有大量的后台接口数据使用1和0来表示true和false了。所以在这里AutoCodable针对Bool类型做了处理,支持将Int类型的值解析成Bool类型。

之后像上面所介绍的那样将生成的代码文件添加到工程里即可,可以看到Sourcery为我们免去了自定义解析时大量重复的代码,唯一的缺点就是向模板传值只能通过注释的形式,在Xcode添加一个Code Snippet:// sourcery: <#key#> = <#value#>能提供一些帮助,至于Key的名称就只能在编码的时候注意别写错了。除此之外Sourcery已经完美的解决了我在使用Codable时碰到的问题。

总结

Sourcery本质上相当于一个预处理器,它为Swift带来了灵活的元编程特性,你甚至可以将生成的代码内嵌到自己的代码中,它的应用场景远远不只是上面所介绍的这些。程序员的时间是宝贵的,我们应该将精力集中在真正关键的部分,如果你也在使用Swift,不妨来尝试一下,和那些琐碎重复的样板代码挥手作别😄。