在服务端使用 Swift 时,大多数路由框架都会把路由同一个指定的闭包关联起来。比如我们在编写 Beacon 时使用的 Vapor 框架。你可以在该框架主页的测试示例中看到如下的代码:
import Vapor let droplet = try Droplet() droplet.get("hello") { req in return "Hello, world." } try droplet.run()
当你运行这段代码时,访问 localhost:8080/hello
会展示文本 Hello, world.
。
有时候,你想要向 API
的调用者返回一个特殊的 HTTP
代码,提示执行了一个特殊的操作。示例如下:
droplet.post("devices", handler: { request in let apnsToken: String = try request.niceJSON.fetch("apnsToken") let user = try request.session.ensureUser() var device = try Device(apnsToken: apnsToken, userID: user.id.unwrap()) try device.save() return try device.makeJSON() })
(我打算在之后的博客中详细介绍 niceJSON
的用法,不过现在请忽略它。)
这是个非常好的请求,和 Beacon
中的代码很像。不过有一个问题:当你返回类似字符串的对象(本文的第一个示例)或者 JSON
(本文的第二个示例)时,Vapor 会返回 200
的状态码。但是这是一个 POST
请求并且创建了一个新的 Device
资源,所以应该返回 201 Created
的 HTTP
状态码。所以你需要创建一个完整的 Response
对象:
let response = Response(status: .created) response.json = try device.makeJSON() return response
对每个创建型的请求重复执行这样的操作很烦人。
最后一点,端点(endpoints)通常会有副作用。特别是使用 Rails 编写的应用,管理和测试这些端点是非常困难的,在 Rails 社区中已经出现了许多有关的讨论了。如果注册需要发送注册邮件,那么如何布置 “桩代码” (stub)以便测试剩余的代码?这是很难做到的,如果所有逻辑都在一个复杂的函数中执行,更是难上加难。在 Beacon
中没有任何发送邮件的功能,但是我们的确有很多推送通知。监管这些推送的副作用是很重要的。
一般来说,在每个路由中使用一个闭包的风格,已经应用在 Flask
、 Sinatra
和 Express
之类的框架中了。它们都是非常好的示例,不过真实的项目往往具有复杂的端点,依旧是把所有逻辑放在一个复杂的方法中。
进一步说,Rails 风格的控制器模块很庞大,而与每个端点匹配的相关方法都使用控制器作为命名空间,这使得控制器彼此之间具有边界攻击性。我觉得我们可以做的比以上两种模式(使用闭包的风格和 Rails 风格)更好。(如果你想了解 Ruby 服务器架构,我已经从 Trailblazer 项目中总结了一些经验。)
最基本的一点是我想要一个更好的抽象来响应传入的请求。为此,我使用了一个称之为 Command
的对象来封装端点需要做的工作。
Command
模式的起始部分是一个协议:
public protocol Command { init(request: Request, droplet: Droplet) throws var status: Status { get } func execute() throws -> JSON } extension Command: ResponseRepresentable { public func makeResponse() throws -> Response { let response = Response(status: self.status) response.json = try execute() return response } }
上面的代码是 Command
协议的基础外壳,后面会向其中添加更多的代码。从协议的基础部分可以了解如何使用这种模式。下面来使用新模式重写“注册设备”这个端点。
droplet.post("devices", handler: { request in return RegisterDeviceCommand(request: request, droplet: droplet) })
因为该命令遵守了 ResponseRepresentable
,所以 Vapor 接受它作为路由的 handler
闭包的有效返回值。 它将自动调用 Command
的 makeResponse()
方法并且为 API 的调用者返回一个 Response
。
public final class RegisterDeviceCommand: Command { let apnsToken: String let user: User public init(request: Request, droplet: Droplet) throws { self.apnsToken = try request.niceJSON.fetch("apnsToken") self.user = try request.session.ensureUser() } public let status = Status.created public func execute() throws -> JSON { var device = try Device(apnsToken: apnsToken, userID: user.id.unwrap()) try device.save() return try device.makeJSON() } }
以下是该模式的一些优点:
apnsToken
和 user
的类型是非可选型的,所以如果 init
方法在结束时没有初始化所有的属性,则文件无法通过编译。 save()
方法的测试是分离的。 Command
放置到自己的文件中。
可以向 Command
中添加两个更重要的组件。首先是验证。添加 func validate() throws
到 Command
协议中,编写一个默认的空实现。他也会被添加到 makeResponse()
方法中,在 execute()
方法之前执行:
public func makeResponse() throws -> Response { let response = Response(status: self.status) try validate() response.json = try execute() return response }
validate()
方法的典型样式可能如下所示(来自 Beacon 的 AttendEventCommand
):
public func validate() throws { if attendees.contains(where: { $0.userID == user.id }) { throw ValidationError(message: "You can't join an event you've already joined.") } if attendees.count >= event.attendanceLimit { throw ValidationError(message: "This event is at capacity.") } if user.id == event.organizer.id { throw ValidationError(message: "You can't join an event you're organizing.") } }
这样的代码易于阅读,保持所有验证本地化并且非常易于测试。虽然你可以构造你自己的 Request
和 Droplet
对象,然后把它们传入 Command
的指定构造器,但是你完全没必要这么做。因为每个 Command
都是你自己的对象,所以你可以编写一个接受完备的诸如 User
和 Event
这类对象的构造器,你不需要手动构造 Request
对象进行测试,除非你有测试 Command
构造过程的特殊需求。
Command 需要的最后一个组件是执行副作用的功能。副作用很简单:
public protocol SideEffect { func perform() throws }
我在 Command
协议中增加了一个属性,该属性列出了 Command 执行之后需要执行的 SideEffect
对象。
var sideEffects: [SideEffect] { get }
最后,副作用被添加到了 makeResponse()
方法中:
public func makeResponse() throws -> Response { let response = Response(status: self.status) try validate() response.json = try execute() try sideEffects.forEach({ try $0.perform() }) return response }
(在代码的未来版本中,副作用可能会以异步的方式执行,即不会阻止向用户发送响应的过程,不过目前只能同步执行。)将副作用与 Command
的其余部分分离的主要原因是便于测试。你可以创建 Command
并且执行,而不必隔离副作用,因为副作用永远不会被执行。
Command
模式是一个简单的抽象,但它是可测试且正确的,坦白地说,它使用起来令人很愉悦。你可以在这个 gist
中找到协议的完整定义。我不会因为 Vapor 没有包含这类的抽象而打击它:和其他服务端的框架一样,Vapor 设计简单,你可以根据个人喜好对它进行抽象。
本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问http://swift.gg。