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

前段时间发现了一个十分强大的工具: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,不妨来尝试一下,和那些琐碎重复的样板代码挥手作别😄。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容