前段时间发现了一个十分强大的工具:Sourcery,它很好的解决了我在Swift开发中遇到的一些问题,在中文社区中sourcery似乎并不是很有名,所以这里特地写一篇文章来作介绍。本文大致分为三个部分:
- 元编程的概念和作用
- Sourcery的原理和基本使用
- Sourcery和Codable的实践
什么是元编程
很多人可能对元编程(meta-programming)这个概念比较陌生,当然有一部分是因为翻译的问题,这个“元”字看起来实在是云里雾里。如果用一句话来解释,所谓元编程就是用代码来生成代码。
这句话可以从两个层面上来理解:
OC有着十分强大的Runtime特性,在运行时可以查看和修改一个对象的所有成员,所以有了Mantle
之类JSON转Model的库;甚至可以在运行时添加、删除、替换一个类型中的方法,当然也可以动态的添加类型,所以有了Aspects
和AOP
。这些应用都可以归纳为元编程的范畴,因为它们的功能都是通过在运行时修改程序本身来实现的,这一特性为我们节省了很多重复的样板代码。
而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二进制文件也加到了工程目录中):
需要注意的是这个脚本一定要添加在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,不妨来尝试一下,和那些琐碎重复的样板代码挥手作别😄。