翻译自 A µTutorial on Swift NIO,部分内容由于与文章主题关系不大故没有翻译,如果英文尚可,建议阅读原文。

术语:

  1. Router:路由
  2. Middleware:一般翻译为中间件,但用在这个微型框架里听起来怪怪的,因此这里我翻译为插件

本文的目标是最后实现一个类似 Express.js 的 HTTP 框架,它支持插件和路由系统,而不是一个类似 Netty 的底层框架:

import MicroExpress

let app = Express()

app.get("/moo") { req, res, next in
  res.send("Muhhh")
}
app.get("/json") { _, res, _ in
  res.json([ "a": 42, "b": 1337 ])
}
app.get("/") { _, res, _ in
  res.send("Homepage")
}

app.listen(1337)

这个微型框架也支持 JSON,并且只需很少的代码(最终一共 350 行左右)就实现以上所有的功能。

首先:SwiftNIO 是什么?

SwiftNIO 是一个跨平台、异步、事件驱动的网络应用框架,可用于快速开发和维护高性能的网络协议服务器和客户端。

它很像 Netty,但是用 Swift 语言开发的。

啥?好吧,其实它是一个用来开发各种互联网服务器(以及客户端)的工具包。你可以用它写 web 服务器(HTTP),也可以用它写邮件服务器(IMAP4/SMTP),或者 IRC 服务器。它的构建目标专注于高性能和可扩展性。

作为一个常年使用 Rails 或者 Node 开发 HTTP 服务的工程师,你也许并不关心如何与 SwiftNIO 直接打交道,但如果你想自定义一个全新的协议,添加一些网络层的功能(例如频率控制,内容压缩,XSLT 渲染等),并且对性能非常在意,或者想要创建一个自己的 web 框架,那就得与它打交道了。对,最后一个就是我们今天要做的。

也就是说,SwiftNIO 是一个偏底层的框架,某种程度上类似于 Apache 2 的模块 API。

为了实现我们自己的这个 web 框架,需要创建以下几个组件:

  1. 一个代表运行着的服务器的 app 对象
  2. request 和 response 对象
  3. 插件和路由
  4. 其他有趣的东西

准备工作

我们使用 swift xcode 这个项目来帮助我们在 Xcode 里直接使用 Swift Package Manager:

brew tap swiftxcode/swiftxcode
brew install swift-xcode
brew install swift-xcode-nio
swift xcode link-templates

打开 Xcode,创建一个新项目,并选择“Swift NIO”作为模板:

命名为 MicroExpress,并确保勾选上“Include SwiftNIO HTTP1 module”:

编译。

第 1 步

Express 类的主要用途是启动和运行 HTTP 服务器:

main.swift

// File: main.swift - Add to existing file
let app = Express()

app.listen(1337)

Express.swift

// File: Express.swift - create this in Sources/MicroExpress

import Foundation
import NIO
import NIOHTTP1

open class Express {
  
  override public init() {}
  
  let loopGroup = 
        MultiThreadedEventLoopGroup(numThreads: System.coreCount)
  
  open func listen(_ port: Int) {
    let reuseAddrOpt = ChannelOptions.socket(
                         SocketOptionLevel(SOL_SOCKET),
                         SO_REUSEADDR)
    let bootstrap = ServerBootstrap(group: loopGroup)
      .serverChannelOption(ChannelOptions.backlog, value: 256)
      .serverChannelOption(reuseAddrOpt, value: 1)
      
      .childChannelInitializer { channel in
        channel.pipeline.addHTTPServerHandlers()
        
        // this is where the action is going to be!
      }
      
      .childChannelOption(ChannelOptions.socket(
                            IPPROTO_TCP, TCP_NODELAY), value: 1)
      .childChannelOption(reuseAddrOpt, value: 1)
      .childChannelOption(ChannelOptions.maxMessagesPerRead, 
                          value: 1)
    
    do {
      let serverChannel = 
            try bootstrap.bind(host: "localhost", port: port)
                         .wait()
      print("Server running on:", serverChannel.localAddress!)
      
      try serverChannel.closeFuture.wait() // runs forever
    }
    catch {
      fatalError("failed to start server: \(error)")
    }
  }
}

编译运行,可以看到控制台应该输出:

Server running on: [IPv6]::1:1337
讨论

上面的代码首先创建了一个 MultiThreadedEventLoopGroup:

let loopGroup = MultiThreadedEventLoopGroup(numThreads: System.coreCount)

在 SwiftNIO 里,EventLoop 对象非常像我们熟悉的 DispatchQueue,它用于处理 I/O 事件,你可以将一段 block 代码追加到它的队列里并让它稍后执行(就像 DispatchQueue.async),你也可以通过一个定时器来调度它(就像 DispatchQueue.asyncAfter)。

而 MultiThreadedEventLoopGroup 就像一个并发队列,它使用多个线程来分发执行添加到里面的任务。

接下来是 listen 方法:

open func listen(_ port: Int) {
  ...
  let bootstrap = ServerBootstrap(group: loopGroup)
    ...
    .childChannelInitializer { channel in
      channel.pipeline.addHTTPServerHandlers()
      // this is where the action is going to be!
    }
  ...
  let serverChannel = 
        try bootstrap.bind(host: "localhost", port: port)
                     .wait()

这里,使用了一个 ServerBootstrap 对象来配置“服务端管道”。ServerBootstrap 就是一个帮助执行配置工作的辅助工具,当它执行完它的使命也就完成了。

SwiftNIO 里的 Channel 类似于 Foundation 框架中的 FileHandle,通常,它内部包装了一个 Unix 文件描述符(socket,pipe,文件,等等)并在包装之上提供一些操作方法。

在这个例子里,我们创建了一个“服务端管道”(通过 ServerBootstrap),也就是一个被动等待连接的 socket,接着依然是一个 Channel 对象,并且通过 childChannelInitializer 方法进行配置,这个方法里的 channel 参数(实际上是一个 closure 参数方法的参数)就是刚刚配置好的用于连接客户端的管道。

每个 Channel 都维护着一个 ChannelPipeline,实际上就是一些 “handler” 对象的集合(某种意义上说它们与插件 Middleware 区别不大)。这些 handler 对象被顺序执行,将接收到的输入信息转变为返回给客户端的输出信息。

注意到上面的代码里调用了 channel.pipeline.addHTTPServerHandlers(),它向 client 管道里添加了一个把接收到的数据(原始字节)转换为 HTTP 对象(比如说请求 request),把返回给客户端的 HTTP 对象(比如说响应 response)转换为字节数据的 handler。

接下来,我们向这个管道里添加我们自己的 handler。

第 1b 步:添加一个 SwiftNIO Handler

我们添加的这个 handler 接收 HTTP 请求(已经管道里已经通过 addHTTPServerHandlers 添加了一个将字节数据转变为 HTTP 对象的 handler 了),并向客户端写回 HTTP 信息:

Express.swift

// File: Express.swift - change the .childChannelInitializer call as shown

open class Express {
  ...
      .childChannelInitializer { channel in
        channel.pipeline.addHTTPServerHandlers().then {
          channel.pipeline.add(handler: HTTPHandler())
        }
      }
  ...
}

如果你对这里 .then 的用途有疑惑的话我们可以暂时忽略它,你只需要知道 SwiftNIO 里大多数的方法都返回一个 Future,这里它表示一旦 addHTTPServerHandlers 执行完毕就添加我们自己的这个 handler。 
我们把我们要创建的 handler 放在 Express 对象里:

Express.swift

// File: Express.swift - insert at the bottom

open class Express {
  // other code
  
  final class HTTPHandler : ChannelInboundHandler {
    typealias InboundIn = HTTPServerRequestPart
    
    func channelRead(ctx: ChannelHandlerContext, data: NIOAny) {
      let reqPart = unwrapInboundIn(data)

      switch reqPart {
        case .head(let header):
          print("req:", header)

        // ignore incoming content to keep it micro :-)
        case .body, .end: break
      }
    }
  }
} // end of Express class

编译运行并访问 http://localhost:1337 目前它依然不会生成响应但你可以在控制台里看到如下输出:

Server running on: [IPv6]::1:1337
req: HTTPRequestHead(method: NIOHTTP1.HTTPMethod.GET, uri: "/", ...)
讨论

我们的 handler 是一个 ChannelInboundHandler,这表明它会接收来自客户端(浏览器,curl,等等)的数据。通过设置 InboundIn 的类型别名来指定它能够接受的输入数据类型。

typealias InboundIn = HTTPServerRequestPart

这一句表示我们的 handler 可以接收 HTTPServerRequestPart 类型的输入数据,这是一个枚举类型:

  • .head:HTTP 请求头
  • .body:HTTP 请求体
  • .end:请求数据读取完毕

当接收到新数据时:

func channelRead(ctx: ChannelHandlerContext, data: NIOAny) {
  let reqPart : HTTPServerRequestPart = unwrapInboundIn(data)
  ...

考虑到效率因素,接收到的数据被作为 NIOAny 类型传入到方法中,因此你需要自己解包成所需要的类型。因为在将我们自己的这个 handler 添加到管道之前我们已经添加了 NIOHTTP1 handler,因此我们接收到的数据其实已经被它处理成为了 HTTPServerRequestPart 类型。

我们先临时返回一些“Hello World”数据给浏览器来看看效果:

case .head(let header):
  print("req:", header)
  
  let head = HTTPResponseHead(version: header.version, 
                              status: .ok)
  let part = HTTPServerResponsePart.head(head)
  _ = ctx.channel.write(part)

  var buffer = ctx.channel.allocator.buffer(capacity: 42)
  buffer.write(string: "Hello Schwifty World!")
  let bodypart = HTTPServerResponsePart.body(.byteBuffer(buffer))
  _ = ctx.channel.write(bodypart)

  let endpart = HTTPServerResponsePart.end(nil)
  _ = ctx.channel.writeAndFlush(endpart).then {
    ctx.channel.close()
  }

编译运行,从浏览器里打开 http://localhost:1337 看看效果。

这里有几点需要注意:

  • 我们不能直接将字节数据写回到管道里。就像我们接收到的是 HTTP 对象一样,我们发送出去的也必须是 HTTP 对象(.head,.body 和 .end)。NIOHTTP1 handler 会负责将这些对象转换成字节数据。
  • 调用 channel 的 write 方法。这并不意味着真的立刻将数据写到 socket,你必须调用 channel 的 flush 方法才能真正的将数据写入到 socket。这就是最后调用 writeAndFlush 的原因。
  • 通常,SwiftNIO 希望我们使用 ByteBuffer 来发送响应字节数据。ByteBuffer 类似于 Foundation 框架里的 Data 类型。
  • writeAndFlush 执行完成后关闭管道(也就是一个连接)。但通常来说,连接不会立刻被关闭,因为此时可能数据尚未写入到 socket,因此我们使用 .then 方法将关闭管道的操作放在 writeAndFlush 返回的 Future 对象里。

第 2 步:请求/响应对象

2.1 IncomingMessage

HTTP .head 枚举值有一个类型为 HTTPRequestHead 的关联值,我们的 IncomingMessage 类将包含这个值。

此外,这个类主要的增强功能是提供了一个 userInfo 存储结构,它可以用来存储插件传进来的数据并可以被接下来的插件处理。

IncomingMessage.swift

// File: IncomingMessage.swift - create this in Sources/MicroExpress

import NIOHTTP1

open class IncomingMessage {

  public let header   : HTTPRequestHead // <= from NIOHTTP1
  public var userInfo = [ String : Any ]()
  
  init(header: HTTPRequestHead) {
    self.header = header
  }
}

为什么我们要创建这么一个类而不是直接使用 HTTPRequestHead 呢?一个原因是因为 HTTPRequestHead 是一个结构体类型,目前我们还无法向结构体添加存储属性来扩展它。此外,请求 request 将被多次传递,传引用显然比传结构体的开销要小。最后,HTTPRequestHead 仅仅代表 HTTP 头,并不是一个完整的 HTTP 信息(比如说没有 body 信息)。

2.2 ServerResponse

ServerResponse 包含了上面向客户端发送“Hello World”信息的临时方法。一旦一个 ServerResponse 对象创建完成它就会被传入相应的管道(Channel)里。

首先,它的主要功能是 send 方法,这个方法会向管道里依次写入响应头,响应体,最后关闭连接。

ServerResponse.swift

// File: ServerResponse.swift - create this in Sources/MicroExpress

import NIO
import NIOHTTP1

open class ServerResponse {

  public  var status         = HTTPResponseStatus.ok
  public  var headers        = HTTPHeaders()
  public  let channel        : Channel
  private var didWriteHeader = false
  private var didEnd         = false
  
  public init(channel: Channel) {
    self.channel = channel
  }
  
  /// An Express like `send()` function.
  open func send(_ s: String) {
    flushHeader()

    let utf8   = s.utf8
    var buffer = channel.allocator.buffer(capacity: utf8.count)
    buffer.write(bytes: utf8)

    let part = HTTPServerResponsePart.body(.byteBuffer(buffer))
    
    _ = channel.writeAndFlush(part)
               .mapIfError(handleError)
               .map { self.end() }
  }
  
  /// Check whether we already wrote the response header.
  /// If not, do so.
  func flushHeader() {
    guard !didWriteHeader else { return } // done already
    didWriteHeader = true
    
    let head = HTTPResponseHead(version: .init(major:1, minor:1),
                                status: status, headers: headers)
    let part = HTTPServerResponsePart.head(head)
    _ = channel.writeAndFlush(part).mapIfError(handleError)
  }
  
  func handleError(_ error: Error) {
    print("ERROR:", error)
    end()
  }
  
  func end() {
    guard !didEnd else { return }
    didEnd = true
    _ = channel.writeAndFlush(HTTPServerResponsePart.end(nil))
               .map { self.channel.close() }
  }
}

如何使用呢?很简单:

response.send("Hello World!")

2.3 关联到 HTTPHandler

现在,我们将 IncomingMessage 和 ServerResponse 使用到 Express.HTTPHandler 里:

Express.swift

// File: Express.swift - replace the hello stuff w/ this code

case .head(let header):
  let request  = IncomingMessage(header: header)
  let response = ServerResponse(channel: ctx.channel)
  
  print("req:", header.method, header.uri)
  response.send("Way easier to send data!!!")

目前,代码里还没有用到 request,但显然发送数据的时候更简单了。

讨论

writeAndFlush 是一个异步方法,它仅仅将要写的数据加入队列并返回一个 Future 对象。对于这个 Future 对象,你可以给它添加一个用于处理错误的 handler (mapIfError(handleError)),也可以给它添加一个 handle 用于这个 Future 执行完的后续操作(map { self.end() })。

第 3 步:插件和路由

3.1 插件

“插件”有很多含义,但在 Express.js 里它仅仅表示一个可以用来处理 HTTP 请求的闭包或者函数。

一个插件函数包含一个请求 request,响应 response 和一个用于接着处理请求(如果当前函数没有完全处理完成请求)的函数,例如:

func moo(req  : IncomingRequest,
         res  : ServerResponse,
         next : @escaping Next)
{
  res.send("Moooo!")
}

通常你不需要将这个插件函数当成一个普通的函数,而是作为一个尾随闭包添加到路由系统里:

app.use { req, res, next in
  print("We got a request:", req)
  next() // do not stop here
}

在 Swift 里你可以使用 typealias 轻松定义插件类型:

Middleware.swift

// File: Middleware.swift - create this in Sources/MicroExpress

public typealias Next = ( Any... ) -> Void

public typealias Middleware =
                  ( IncomingMessage,
                    ServerResponse, 
                    @escaping Next ) -> Void

3.2: 路由

因为我们的目标是写一个微型框架,因此我们可以简单地把路由当作一个插件函数列表,使用 use() 方法将插件添加到路由表里。当接收到请求时,路由迭代取出插件列表里的函数并传入这个请求,以此类推直到最后一个函数不再调用 next 方法为止,此时,这个请求才算处理完成了。

Router.swift

// File: Router.swift - create this in Sources/MicroExpress

open class Router {
  
  /// The sequence of Middleware functions.
  private var middlewares = [ Middleware ]()

  /// Add another middleware (or many) to the list
  open func use(_ middleware: Middleware...) {
    self.middlewares.append(contentsOf: middleware)
  }
  
  /// Request handler. Calls its middleware list
  /// in sequence until one doesn't call `next()`.
  func handle(request        : IncomingMessage,
              response       : ServerResponse,
              next upperNext : @escaping Next)
  {
    let stack = self.middlewares
    guard !stack.isEmpty else { return upperNext() }
    
    var next : Next? = { ( args : Any... ) in }
    var i = stack.startIndex
    next = { (args : Any...) in
      // grab next item from matching middleware array
      let middleware = stack[i]
      i = stack.index(after: i)
      
      let isLast = i == stack.endIndex
      middleware(request, response, isLast ? upperNext : next!)
    }
    
    next!()
  }
}

注意:这里暂时忽略了错误处理,如果你想要处理错误的话可以看看 ExExpress

事实上这里并没有做实际的路由操作😀,但不要急,等会就说,先看看怎么使用:

router.use { req, res, next in
  print("We got a request:", req)
  next() // do not stop here
}
router.use { _, res, _ in
  res.send("hello!") // response is done.
}
router.use { _, _, _ in
  // we never get here, because the 
  // middleware above did not call `next`
}
讨论

很显然,目前 Router 里最主要的就是 handle 方法,但是为啥不直接遍历执行 middlewares 数组里的函数,就向 µExpress 里做的那样?这里的 next 闭包是什么?

µExpress 项目里 Router 可以完全异步执行,当里面的一个插件执行后,它不需要立刻调用 next 闭包,这也是它被标记为 @escaping 的原因。我们来看个例子:

app.use { _, res, next in
  // run the closure/task in 2 seconds
  _ = res.channel.eventLoop.scheduleTask(in: .seconds(2)) {
    next()
  }
}

可以看到,next 闭包可能在 2 秒后的任意时间执行,与此同时,我们可能还在遍历执行插件函数,因此,为了解决这个问题,在上面的代码里(Router.handle),我们使用了一个 next 变量来捕获当前正在执行的插件函数:

var i = stack.startIndex    // <= CAPTURED
next = { ...
  let middleware = stack[i]
  i = stack.index(after: i) // <= SHARED

眼尖的读者可能会注意到:Router 本身就像一个插件(它的 handle 函数签名和 Middleware 一样),因此你可以很容易的将 Router 进行链式调用(将一个 Router 添加到另一个 Router 上)。

3.3:关联 Router 到 App

让 App 也成为一个 Router

这一步很简单,在 Express 里,App 对象也是一个 Router,也就是说,你可以直接调用 app.use { … },我们唯一需要做的事情就是让 Router 成为 Express 的父类:

Express.swift

// File: Express.swift - adjust

open class Express : Router { // <= make Router the superclass
  ...
}

将 Rooter 关联到 HTTPHandler

修改 HTTPHandler:

  1. 初始化的时候传入 router 对象
  2. 存储这个 router 对象
  3. 将 request 和 response 作为参数调用这个 router 的 handle 方法

Express.swift

// File: Express.swift - adjust HTTPHandler

  final class HTTPHandler : ChannelInboundHandler {
    typealias InboundIn = HTTPServerRequestPart
    
    let router : Router
    
    init(router: Router) {
      self.router = router
    }
    
    func channelRead(ctx: ChannelHandlerContext, data: NIOAny) {
      let reqPart = unwrapInboundIn(data)
      
      switch reqPart {
        case .head(let header):
          let req = IncomingMessage(header: header)
          let res = ServerResponse(channel: ctx.channel)
          
          // trigger Router
          router.handle(request: req, response: res) {
            (items : Any...) in // the final handler
            res.status = .notFound
            res.send("No middleware handled the request!")
          }

        // ignore incoming content to keep it micro :-)
        case .body, .end: break
      }
    }
  }

重点看一下 handle 函数传进去的这个 next 闭包,当没有插件函数处理这个请求时,这个 next 闭包将会被调用,这个方法就是通常说的 final handler。这里,我们直接返回 404.

由于我们是在 ServerBootstrap 里初始化的 HTTPHandler,因此这里的代码也需要修改:既然 Express 类是 Router 的子类,因此可以直接将它作为参数来初始化 HTTPHandler:

Express.swift

// File: Express.swift - adjust

  .childChannelInitializer { channel in
    channel.pipeline.addHTTPServerHandlers().then {
      channel.pipeline.add(
        handler: HTTPHandler(router: self))
    }
  }

现在,打开 main.swift 文件,来添加一个 middleware 函数:

main.swift

// File: main.swift - update existing file

let app = Express()

// Logging
app.use { req, res, next in
  print("\(req.header.method):", req.header.uri)
  next() // continue processing
}

// Request Handling
app.use { _, res, _ in
  res.send("Hello, Schwifty world!")
}

app.listen(1337)

编译运行,可以看到日志插件通过 print 函数输出了请求信息,然后接着执行后面的操作。

3.4 use()? get(path)!

上面的代码里我们使用 use() 函数来注册插件函数,然而我们更想要的是像 get(),post(),delete() 这样的方法,通过注册路径来注册插件函数,例如:

app.get("/moo") { req, res, next in
  res.send("Muhhh")
}

只有当请求的 HTTP 方法是 GET,路径以 /moo 开始时这个方法才会被调用。修改一下 Router.swift:

Router.swift

// File: Router.swift - add this to Router.swift
public extension Router {
  
  /// Register a middleware which triggers on a `GET`
  /// with a specific path prefix.
  public func get(_ path: String = "", 
                  middleware: @escaping Middleware)
  {
    use { req, res, next in
      guard req.header.method == .GET,
            req.header.uri.hasPrefix(path)
       else { return next() }
      
      middleware(req, res, next)
    }
  }
}

这里的技巧是将一个插件函数嵌入到另一个插件函数里。外部插件的作用仅仅是当请求的 HTTP 方法和路径与内部插件相符时调用内部插件,否则它就直接将参数用 next 闭包传递下去。

用刚才增加的这个函数,我们看起来才真正拥有了路由的功能:

app.get("/hello") { _, res, _ in 
  res.send("Hello")
}
app.get("/moo")   { _, res, _ in 
  res.send("Moo!") 
}

第 4 步:可重用插件

你可以使用插件函数做任何事情,但通常我们用它做一些重复性的任务,例如说提取请求数据并转换为真正处理请求任务的 handler 所需要的数据类型。它也可以用来做权限认证、JSON 数据解析,或者解析请求参数。

QueryString.swift

// File: QueryString.swift - create this in Sources/MicroExpress

import Foundation

fileprivate let paramDictKey = 
                  "de.zeezide.µe.param"

/// A middleware which parses the URL query
/// parameters. You can then access them
/// using:
///
///     req.param("id")
///
public 
func querystring(req  : IncomingMessage,
                 res  : ServerResponse,
                 next : @escaping Next)
{
  // use Foundation to parse the `?a=x` 
  // parameters
  if let queryItems = URLComponents(string: req.header.uri)?.queryItems {
    // use compactMap since Swift 4.1 deprecate flatMap and use compactMap instead
    req.userInfo[paramDictKey] =
      Dictionary(grouping: queryItems, by: { $0.name })
        .mapValues { $0.compactMap({ $0.value })
	               .joined(separator: ",") }
  }
  
  // pass on control to next middleware
  next()
}

public extension IncomingMessage {
  
  /// Access query parameters, like:
  ///     
  ///     let userID = req.param("id")
  ///     let token  = req.param("token")
  ///
  func param(_ id: String) -> String? {
    return (userInfo[paramDictKey] 
       as? [ String : String ])?[id]
  }
}

我们使用 IncomingMessage.userInfo 来持有解析得到的参数并传递给后续的插件。修改 main.swift 来看看效果:

app.use(querystring) // parse query params

app.get { req, res, _ in
  let text = req.param("text")
          ?? "Schwifty"
  res.send("Hello, \(text) world!")
}

然后在浏览器里输入链接:http://localhost:1337/?text=Awesome

第 5 步:用 Codable 处理 JSON

目前为止,我们的服务器仅能给浏览器返回简单的文本数据,下面我们来为他增加 JSON 支持功能,以 Todo-Backend 网站为例,实现它的读取功能:

开始之前,先给 ServerResponse 添加一个设置 HTTP 响应头的便利方法:

ServerResponse

// File: ServerResponse.swift - add this to the end

public extension ServerResponse {
    
  /// A more convenient header accessor. Not correct for
  /// any header.
  public subscript(name: String) -> String? {
    set {
      assert(!didWriteHeader, "header is out!")
      if let v = newValue {
        headers.replaceOrAdd(name: name, value: v)
      }
      else {
        headers.remove(name: name)
      }
    }
    get {
      return headers[name].joined(separator: ", ")
    }
  }
}

注意:这个下标函数并不适用于所有的 HTTP 响应头(参数),但大部分的简单情况没有问题。

首先我们要创建一个存储返回给客户端数据的 model 类型,在这个例子里,它包含一个 todo 的列表:

TodoModel.swift

// File: TodoModel.swift - create this in Sources/MicroExpress

struct Todo : Codable {
  var id        : Int
  var title     : String
  var completed : Bool
}

// Our fancy todo "database". Since it is
// immutable it is webscale and lock free, 
// if not useless.
let todos = [
  Todo(id: 42,   title: "Buy beer",
       completed: false),
  Todo(id: 1337, title: "Buy more beer",
       completed: false),
  Todo(id: 88,   title: "Drink beer",
       completed: true)
]

为了将 JSON 数据发送给客户端,我们现在给 ServerResponse 增加一个 json() 方法:

ServerResponse.swift

// File: ServerResponse.swift - add this to ServerResponse.swift

import Foundation

public extension ServerResponse {
  
  /// Send a Codable object as JSON to the client.
  func json<T: Encodable>(_ model: T) {
    // create a Data struct from the Codable object
    let data : Data
    do {
      data = try JSONEncoder().encode(model)
    }
    catch {
      return handleError(error)
    }
    
    // setup JSON headers
    self["Content-Type"]   = "application/json"
    self["Content-Length"] = "\(data.count)"
    
    // send the headers and the data
    flushHeader()
    
    var buffer = channel.allocator.buffer(capacity: data.count)
    buffer.write(bytes: data)
    let part = HTTPServerResponsePart.body(.byteBuffer(buffer))

    _ = channel.writeAndFlush(part)
               .mapIfError(handleError)
               .map { self.end() }
  }
}

最后,创建一个将 todo 发送给客户端的插件:

main.swift

// File: main.swift - add this to main.swift

app.get("/todomvc") { _, res, _ in
  // send JSON to the browser
  res.json(todos)
}

编译运行,并从浏览器打开:http://localhost:1337/todomvc/,顺利的话可以看到:

[ { "id": 42,   "title": "Buy beer", 
    "completed": false },
  { "id": 1337, "title": "Buy more beer",
    "completed": false },
  { "id": 88,   "title": "Drink beer",
    "completed": true  } ]

用 TodoBackend 来模拟请求一下我们的服务器:

http://todobackend.com/client/index.html?http://localhost:1337/todomvc/

可以看到浏览器里啥都没有,如果你打开浏览器调试器的 JavaScript 控制台,那么你会看到这样的错误:

Origin http://todobackend.com \
  is not allowed by \
  Access-Control-Allow-Origin. \
  http://localhost:1337/todomvc/

也就是说,我们啥都没做就对自己进行了一次跨站脚本攻击,因为我们的服务器和 todobackend.com 有着不同的主机名,所以浏览器拒绝了这次访问。

CORS

为了实现我们的服务器能够被外界(用上述方式)访问,我们需要实现跨域资源共享功能,也就是 CORS。很简单,创建一个用于设置支持 CORS 头的插件就行了:

CORS.swift

// File: CORS.swift - create this in Sources/MicroExpress

public func cors(allowOrigin origin: String) 
            -> Middleware
{
  return { req, res, next in
    res["Access-Control-Allow-Origin"]  = origin
    res["Access-Control-Allow-Headers"] = "Accept, Content-Type"
    res["Access-Control-Allow-Methods"] = "GET, OPTIONS"
    
    // we handle the options
    if req.header.method == .OPTIONS {
      res["Allow"] = "GET, OPTIONS"
      res.send("")
    }
    else { // we set the proper headers
      next()
    }
  }
}

然后在 TodoMVC 前添加这个插件:

main.swift

// File: main.swift - change this in main.swift

app.use(querystring, 
        cors(allowOrigin: "*"))

编译运行并在浏览器打开上面那条链接:

总结

基于 SwiftNIO,我们仅用 350 行左右的代码构建了一个拥有插件、路由、支持 JSON 和 CORS 的微型异步框架。当然,你想要的功能可能远远不止这些,但它清晰地为我们展示了如何用 Swift 写一个拥有 HTTP 和 JSON 解析功能的站点。

如果你想要增强这个框架的功能,有以下几点需要考虑:

  • 支持 POST 请求
  • 模板引擎的支持
  • 错误处理
  • 路径参数(/users/:id)
  • 支持访问数据库

链接

联系

如果你有疑问或者反馈可以直接联系原作者:@helje5@ar_institute.

Email:wrong@alwaysrightinstitute.com

读后感

文章很长,读完学到了很多,也深感不会的更多,最后完成的代码看起来很像 Vapor,而 Vapor 目前的版本也是基于 SwiftNIO 开发了,并且它是一个可以用在生产环境、功能更完备的框架,因此有必要学习一下 Vapor 的使用,读读它的代码。