作者简介: 何轶琛,去哪儿网 iOS 开发工程师,四年多 iOS 应用开发经验,在去哪儿网实践了 Realm、Cocoapods、React Native 等一些好用、有用的技术,目前主要精力在 Swift 上。
责编:唐小引,技术之路,共同进步。欢迎技术投稿、给文章纠错,请发送邮件至[email protected]。
声明: 本文为《程序员》原创文章,未经允许请勿转载,更多精彩文章请订阅 2017 年《程序员》。
【导语】Swift 自发布以来就备受众多 Apple 开发者关注,但由于 API 尚不稳定,系统没有内置 Framework 导致 App 包增大等问题,使得线上主力使用的公司还很少,不少客户端开发者都还没有机会使用 Swift 进行开发。等到 2015 年 12 月 Swift 开源并正式支持 Linux 系统,广大 Apple 开发者迎来了更广泛的开发场景,可以用它来进行服务端开发。不到一年时间各种 Server-Side Swift Web Framework 相继问世,其中以 Kitura、Perfect、Vapor、Zewo 最为成熟。
文章正式开始前,我们先对当前几款主流框架进行了解与对比。
Kitura 是 IBM 推出的框架,使用 IBM Cloud Tools for Swift 管理组件依赖,并支持部署代码到 IBM 的云服务 Bluemix。另外还有一个在线 Swift 编码网站,可以看作是线上 GUI 版本的 Swift REPL,开发者可以直接在 Web 上编写代码并查看输出。Kitura 整个产品从代码编写到部署全部包揽,提供了完整的生态环境。
Perfect 拥有 GitHub 上最多的 Star,各种功能组件和数据库连接工具也最为齐全。近期推出的 Perfect Assistant 是运行在 macOS 上的管理工具,同样支持组件依赖管理,自动化代码部署(支持 AWS、Azure),并通过调用本地 Docker 的方式实现了在 macOS 上编译 Linux 产物的功能。
Vapor 以其友好的文档和 Pure Swift 代码实现著称,其 HTTP Parser 是使用 Swift 编写实现,而不像 Kitura 和 Perfect 是用 CHTTPParser 封装,这对最终的服务性能有很大影响。Vapor 还开发了命令行工具对 SPM 进行封装,好处是开发体验更好,但提高了学习成本。另外 Vapor 比较早就做了 ORM 工具 Fluent,整体感觉十分技术范、小清新。
Zewo 是一系列开源组件的集合平台,特点是使用 libmill 实现了类似 Go 的协程功能,模块化的设计也不同于其他的框架。
这些框架在各具特色的前提下都有高性能、易扩展等优点。正好部门内部有一个信息管理平台项目,需求很简单,只要有基本的增删改查就行,于是不用麻烦后端同学排期,可以自己来开发,也算是提前实践 Swift,积累经验。
正式开发是在 2016 年 8 月,彼时 Swift 3 尚未发布,Beta 版本的 Toolchain 每周都在更新,框架也在积极跟进发布支持最新版本的 Toolchain。技术选型期间我先后尝试了 Kitura、Vapor 和 Perfect。Kitura 的整套产品耦合太紧密,用起来比较重,对于轻量级小项目并不合适。Vapor 一开始用起来很愉快,但写到数据库连接工具时一直无法连接成功,再加上当时还在 Beta 版本,问题不少也被弃用。最后,使用 Perfect 完成了项目研发。接下来,本文将着重介绍如何使用 Perfect 完成一套 RESTful API 的开发,希望能够对大家进行 Swift Server 端开发有所裨益。
在编写代码前,要先了解目前开源的 Swift 项目包括了 Swift、SPM 以及配套的编译调试工具,在核心库方面有 libdispatch、Foundation、XCTest 这三个项目。在客户端开发中,Foundation 是最常用的工具库,它提供了一系列国际化、系统无关的 API。服务端项目增加 Foundation 支持可以统一开发体验和复用客户端代码,尤其是和系统无关的 API 可以大大增加可移植性,本属于 Swift 3.0 的组成部分但至今并没有开发完成,原因在于 Foundation 中用到了一些 Objective-C Runtime 的代码,而这部分代码并不在开源范围之内。于是在开发中需要用到 Foundation 库时就会碰到不少问题。
环境配置
macOS 上依然是 Xcode 搞定一切事情,Linux 上目前只支持两个版本的 Ubuntu,所以推荐使用 Docker 搭建 Swift 编译和执行环境,这样可以支持所有 Linux 系统,也方便在 Swift 快速迭代时及时更新 Toolchain。代码都用 SPM 管理,在实际使用中还是有些问题,比如不支持 MySQL 5.7,创建工程文件配置时漏掉了编译设置,寻找公共代码库路径在不同操作系统上没有适配等。
开发中使用了两个第三方库 SwiftLog 和 MySQL-Swift。SwiftLog 支持自定义日志级别和增量写入的日志文件,并使用了喵神的 Rainbow 在 Linux 环境下输出彩色日志。MySQL-Swift 支持 MySQL 连接池复用,可以提高访问数据库的性能。
部署环境使用 CentOS 作为宿主机,开启两个 Docker 实例分别运行 Perfect 和 MySQL,两个 Docker 实例通过 link 方式实现通信。使用 link 参数运行 Docker 实例,主 Docker 的 hosts 文件会增加从 Docker 的 host 信息,从而达到通过 Docker 别名进行通信的效果。初始化 MySQL 容器时可以将数据库文件路径设置到虚拟卷中,再使用 crontab 执行定期任务运行 AutoMySQLBackup 来备份数据。
编码
先初始化工程,使用 swift package init 新建工程,此时会生成 Package.swift 配置文件和源代码、测试代码目录。在 Package.swift 中添加 Perfect、SwiftLog、MySQL-Swift 后执行 Swift Build 即可拉取所有依赖代码。此时的目录并不包含 Xcode 工程文件,需要再执行命令 swift package generate-xcodeproj 生成,工程中各个依赖库的配置都通过自身的 Package.swift 配置文件读取。
SPM 不仅是去中心化的包管理器,它还可以编译出可执行文件,我们甚至能够直接在服务端编写代码并编译运行。如果说在使用 Objective-C 开发时用 Xcodebuild 开发自定义打包工作流,那么开发 Server-Side Swift 就需要使用 SPM 在服务端实现编译、打包等流程。虽然在服务端目前还有些兼容问题,但 SPM 作为 Swift 的组成部分,一直在快速改进与提高。
然后,添加 API 路由,我们先添加路由配置,后面再将所有配置一起设置到 Server 对象上。Perfect 的路由 API 设计参考了 Express,只需要设置 HTTP 请求类型、路由地址和对应的处理函数即可,支持使用通配符与参数,用起来还算简洁。我们可以先设置基础 API 路径,再通过 Routes().add 方法添加自定义路由,这样所有的路由都被添加到指定 API 路径下。最后将路由对象输出一下,这样开启服务时会将所有注册的路由输出到日志。
func makeURLRoutes() -> Routes { var routes = Routes() var apiRoutes = Routes(baseUri: "/api") var api = Routes()
api.add(method: .get, uri: "/staff/{id}", handler: fetchStaffById)
apiRoutes.add(routes: api)
routes.add(routes: apiRoutes)
SLogWarning("\(routes.navigator.description)") return routes
}
Swift 没有 define 关键字,但可以通过 typealias 自定义类型名称,比如 Perfect 里的路由处理回调函数就被定义为 public typealias RequestHandler = (HTTPRequest, HTTPResponse) -> ()。从定义可以看到,响应函数有两个参数,HTTPRequest 包含了客户端请求的所有信息,包括 HTTP headers 和 content data。HTTPResponse 包括 HTTP headers、body 以及 HTTP Status,这些属性可以在函数中赋值并返回给客户端。
接着来实现路由函数。先将请求参数拿到,将参数进行错误处理后调用数据库工具方法获取信息,并根据获取数据的成败做对应的处理,最后返回 JSON 格式的结果。
在路由处理函数最后都需要调用 response.completed()方法通知 Server 回调处理完成,由于函数有几个结束点,在参数错误或者获取数据的时候都可能抛出异常并提前结束函数,所以需要在好几个地方执行 response.completed()方法。Swift 和 Golang 一样拥有 defer 关键字,我们可以在函数中使用它来完成资源回收或这种需要多处调用的代码。
defer {
response.completed()
}
进行数据库请求和生成 JSON 返回值的操作都有可能抛出异常,这样在 do-catch 中会抛出两种类型的异常,我们可以使用 switch-as 语法针对不同类型的异常进行处理。
do { let staffData = try StaffDataBaseUtil.sharedInstance.searchStaffByID(idString) try response.setBody(json: jsonBody(errorCode: returnCode, data: ["staff": staffData]))
} catch let error { switch error { case let error as QueryError:
default:
}
}
Perfect 并没有使用 SwiftyJSON 之类的第三方库,而是自己实现了很好用的 JSON 扩展,对常用的数据类型增加了 JSON 序列化和反序列化方法,Swift 的 Extension 在这里得到了充分的使用。
extension Dictionary: JSONConvertible {
public func jsonEncodedString() throws -> String { var s = "{"
var first = true
for (k, v) in self {
guard let strKey = k as? String else { throw JSONConversionError.invalidKey(k)
} if !first {
s.append(",")
} else {
first = false
}
s.append(try strKey.jsonEncodedString())
s.append(":")
s.append(try jsonEncodedStringWorkAround(v))
}
s.append("}") return s
}
}
数据获取需要进行数据库请求,我们使用 MySQL-Swift 作为数据库连接工具,为的是使用连接池复用连接,但也给自己挖了个坑。在 Mac 上开发时都没有问题,但在服务器端就编译失败,后来发现有一部分的代码还不支持 Linux,原来是时区的问题。在连接数据库时需要传入一个 config,包括数据库地址、端口、密码等,其中就有时区配置。在从数据库获取数据时,如果字段中有日期类型,就会将获取的绝对时间转换为对应时区的时间字符串。在日期类型数据的处理部分用到了 NSTimezone,但这一类型在 Linux 上没有实现,于是编译失败。修复的方式很简单,使用 CFTimezone 传递信息就好,但 CFTimezone 返回的类型是 CFString,于是又要针对 macOS 和 Linux 实现不同的 CFString 转换到 String 的代码。如果 Swift 有一套跨平台的 Foundation 就不会出现这个问题了。
MySQL-Swift 底层使用的是 CMySQL 库进行连接。根据 options 初始化数据库连接,将连接保存到连接池中,这样后续的数据库操作不需要再次重新连接数据库。再对每个连接对象添加是否正在使用的标记,如果当前连接池中的所有连接都在使用当中,则再次新增一个数据库连接对象进行操作。可以手动设置连接池的最大连接数,当连接池中的连接数到达最大连接数时,后续的请求将会抛出获取数据库连接失败异常。
MySQL-Swift 将查询方法封装的十分优雅,工具类在初始化时候根据提前设置好的 config 生成连接池,再调用 pool.execute 方法,并传入查询执行的闭包就行。这里用到了高阶函数和范型,语法简洁又安全。使用的时候不需要关心数据库连接对象的创建和状态,只需要直接使用闭包里传进来的 Connection 连接对象进行查询即可。
private init() {
pool = ConnectionPool(options: DBConfigOption)
}let result: [Staff] = try pool.execute { conn in
try conn.query(querySQL)
}
对于同名不同返回值的函数,Swift 会针对代码中返回值的类型推断进行分析,确定最终运行时调用哪一个函数。上面 conn.query 函数就会根据不同的返回值执行不同的函数,所以在编写上面代码的时候一定要显示的声明返回的是 Staff 数组,不然函数返回的结果就会不同。另外在 query 的实现中可以看到,最后的不同返回结果是从同一个函数返回的 tuple 中拿到的,这样的代码阅读起来很有效率。
public func query"">(_ query: String, _ args: [QueryParameter] = []) throws -> ([T], QueryStatus) { return try self.query(query: queryString)
}public func query"">(_ query: String, _ args: [QueryParameter] = []) throws -> [T] { let (rows, _) = try self.query(query, args) as ([T], QueryStatus) return rows
}public func query(_ query: String, _ args: [QueryParameter] = []) throws -> QueryStatus { let (_, status) = try self.query(query, args) as ([EmptyRowResult], QueryStatus) return status
}
拿到数据后可以将结果转换为自定义的数据类型。这里会将数据库查询结果解码为定义了默认值的结构体,并提供序列化方法给路由处理函数。
static func decodeRow(r: QueryRowResult) throws -> Staff { return try Staff(
id: r 0,
name: r "name",
department: r "department",
timestamp: r "timestamp"
)
}
在开发完成后的测试中发现返回数据的时间戳总是差几个小时,但是直接查看数据库里的数据,时间戳又是正确的,于是一步步从获取数据到返回 response 调试看看哪里出的问题,最后发现是 Swift 时区没有自动设置为和系统相同,而一直是 UTC 时间,在生成时间戳文案的时候就出错了。
所以在使用 Dateformatter 的时候需要手动设置时区,这也是 Foundation 的一个坑。
func resultDic() -> Dictionarystring, any=""> {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
dateFormatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
let dateString = dateFormatter.string(from: timestamp.date()) return Dictionarystring, any="">.init(dictionaryLiteral:
("id", id),
("name", name),
("department", department),
("timestamp", dateString)
)
}string,>string,>
在拿到数据后,可以将 HTTP Response 包装一下,在每个返回结果中都包括错误码,数据和时间戳这三个字段,并增加错误码对应的错误提示。这样不用在每个路由处理函数的最后都手动写一次。
public func jsonBody(errorCode: ErrorCode, data: Dictionarystring, any="">?) -> [String: Any] {
var body = [String: Any]() body["s"] = ["code": errorCode.rawValue, "desc": errorCode.description] if let jsonData = data { body["data"] = jsonData
} body["t"] = UInt(Date().timeIntervalSince1970) return body}string,>
至此代码基本编写完成,为了方便调试,我们可以通过 Perfect 的 Filter 方法添加自定义日志,将每次的 HTTP 请求和返回的信息输出。这样我们不需要在每个路由处理函数中调用日志方法就可以输出所有请求的参数和最终返回的结果。
func incomeMiddleware(request: HTTPRequest) {
SLogInfo("Request URL: " + request.uri)
SLogInfo("Request Method: " + request.method.description)
SLogVerbose("Request Params: " + String(describing: request.params()))
for (name, detail) in request.headers {
SLogVerbose("Request HEADER: " + name.standardName.uppercased() + " -> " + detail)
}
}
我使用的 SwiftLog 作为日志工具,没想到在部署测试的时候也出了问题,表现为开启服务的时候可以输出日志,一旦请求进来就崩溃,抛出 Segmentation fault。但是代码在开发机上运行正常,一路调试下来发现是 “(Date())” 的问题,这段代码第一次执行没问题,第二次执行就会导致崩溃。直接在服务端用 REPL 执行两次 pring(Date())也发生了相同的崩溃。推断应该是 Date 对象的 description 代码执行出错,于是自定义 Date 对象的 description 方法,避免调用自带的方法,问题解决。
所有配置和路由函数开发完成后,开始设置 server 对象。Perfect 支持静态文件路由,可以设置静态文件的路径。这里推荐针对 macOS 设置静态文件路径单独设置,因为 Xcode 编译出的可执行文件并不在代码目录,因此在本地调试时候会出现找不到静态文件的问题。
#if os(OSX)server.documentRoot = "~/webroot"#elseif os(Linux)server.documentRoot = "./webroot"#endif
代码开发完成后,使用 Docker 初始化 Swift 实例并拉取代码,使用 swift build -c release 生成可执行文件,将可执行文件和 libCHTTPParser.so、libCOpenSSL.so、libLinuxBridge.so 三个依赖库文件复制出来提交到目标 Docker 中即完成部署。目前 Swift 官方只支持 Ubuntu 系统,也有人尝试在 CentOS 上手动编译 Swift 源码,但由于缺少官方的全面测试所以不推荐在生产环境使用。
总结
Swift 作为服务端开发语言的新成员,有着不少的先天优势,比如智能的类型推导、强大的协议扩展、丰富好用的语法糖,这也是官方宣传的 Safe、Fast 和 Expressive 的具体体现。开源后的 Swift 吸引了更多的开发者参与其中,从 4.0 演进表也可以看到更多强力且有趣的功能包括反射、并发、稳定的 ABI 等。未来是美好的,现实是残酷的,以目前国内的 Swift 开发生态环境,在客户端尚且无法占据主流位置,更不用想挑战 Java、PHP、Python 等语言在服务端的地位。想要用 Swift 替代各大公司线上成熟的开发方案是不现实的,但可以从小做起,从辅助工具之类的角度着眼,先做出广泛使用的产品,逐步找到自己的定位,再扩展使用场景。私以为 Go 在这方面做的很好,Docker 的流行让更多人知道了 Go 这门语言且证明了其实力。
目前看来 Swift 最需要解决的是 ABI 稳定性和跨平台兼容两大问题,对于 ABI 来说,之所以到 3.0 版本还没有稳定下来,是开发小组认为目前稳定 ABI 将无法去掉现有实现中错误的部分,且很可能带着补丁开发后续版本将成倍提高今后的开发难度。越早提交的代码留存率越低。这对于语言的开发是件好事,不用带着很多历史包袱开发新功能。但对于开发者来说这意味着在语言的新版本发布后不能方便的快速跟进,除非所有依赖的 Swift 代码库都及时跟进并发布基于新版本编译的代码库,这会大大降低使用 Swift 开发的积极性。
另外 Server-Side Swift 目前只支持 Ubuntu 系统,Foundation 的移植也还在进行当中,并且各种兼容 Bug 频出,在开发过程中很容易遇到开发环境和部署环境运行效果不同的情景。好消息是针对后面的问题,Swift 开发团队成立了 Server APIs Work Group,工作组的目标就是提供服务端跨平台 API,消除平台相关代码差异,提供基础框架功能代替 C 库的引入,进一步降低服务端的开发门槛,提高客户端代码的可移植性。同时 Swift 3.1 的修改内容中明确说明了会改进 Swift on Linux 和 SPM 的质量,期待 2017 年春天发布的这个版本会给 Server-Side Swift 带来显著的改进。