採用 SwiftNIO 實現一個類似 Express 的 Web 框架

Binboy_王興彬發表於2018-08-09

SwiftNIO is a cross-platform asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients. It’s like Netty, but written for Swift.

SwiftNIO 是由蘋果推動並開源的一款基於事件驅動的跨平臺網路應用開發框架,用於快速開發可維護的高效能伺服器與客戶端應用協議。NIO 是(Non-blocking)I/O 的縮寫,即為了提升效能,其採用的是非阻塞 I/O。

SwiftNIO 實際上是一個底層工具,致力於為上層框架專注提供基礎 I/O 功能與協定。接下來我們就將採用其構建一個類似 Express 的小型 Web 框架。

目標:看看我們最終實現的框架能做些什麼

import MicroExpress

let app = Express()

app.get("/hello") { req, res, next in
	res.send("Hello, ExpressSwift")
}

app.get("/todolist") { _, res, _ in
	res.json(todolist)
}

app.listen(1337)
複製程式碼

實現這樣一個網路應用,我們要做以下這些元件:

  1. 一個 Express 例項類,用於執行服務
  2. 請求(IncomingMessage)與響應(ServerResponse) 物件
  3. 中介軟體(Middleware)和路由(Router
  4. 採用 Codable 對 JSON 物件進行處理

Step 0: 準備 Xcode 工程專案

安裝相應的swift-xcode-nio

brew install swiftxcode/swiftxcode/swift-xcode-nio
swift xcode link-templates
複製程式碼

建立一個新專案,選中Swift-NIO模板

建立新專案-SwiftNIO

Step 1: Express 例項類

  • main.swift
let app = Express()
app.listen(1337)
複製程式碼
  • Express.swift
import Foundation
import NIO
import NIOHTTP1

open class Express {
	let loopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 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.configureHTTPServerPipeline()
			    // this is where the action is going to be!
		    }
		    .childChannelOption(ChannelOptions.socket(
                            IPPROTO_TCP, TCP_NODELAY), value: 1)
            .childChannelOption(reuseAddrOpt, value: 1)
            .childChannelOption(ChnanelOptions.maxMessagePerRead, 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)")
		}
	}
}
複製程式碼

討論:

let loopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
複製程式碼

EventLoopPromise和EventLoopFuture

EventLoop 是 SwfitNIO 最基本的 IO 元素,它等待事件的發生,在發生事件時觸發某種回撥操作。在大部分 SwfitNIO 應用程式中,EventLoop 物件的數量並不多,通常每個CPU核數對應一到兩個 EventLoop 物件。一般來說,EventLoop 會在應用程式的整個生命週期中存在,進行無限的事件分發。

EventLoop 可以組合成 EventLoopGroup,EventLoopGroup 提供了一種機制用於在各個EventLoop 間分發工作負載。例如,伺服器在監聽外部連線時,用於監聽連線的 socket 會被註冊到一個 EventLoop 上。但我們不希望這個 EventLoop 承擔所有的連線負載,那麼就可以通過 EventLoopGroup 在多個EventLoop間分攤連線負載。

目前,SwiftNIO 提供了一個 EventLoopGroup 實現(MultiThreadedEventLoopGroup)和兩個 EventLoop 實現(SelectableEventLoop 和 EmbeddedEventLoop)。

MultiThreadedEventLoopGroup 會建立多個執行緒(使用 POSIX 的 pthreads 庫),併為每個執行緒分配一個 SelectableEventLoop 物件。

SelectableEventLoop使用選擇器(基於 kqueue 或 epoll)來管理來自檔案和網路IO事件。EmbeddedEventLoop 是一個空的 EventLoop,什麼事也不做,主要用於測試。

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

Channels、ChannelHandler、ChannelPipeline 和 ChannelHandlerContext

儘管 EventLoop 非常重要,但大部分開發者並不會與它有太多的互動,最多就是用它建立 EventLoopPromise 和排程作業。開發者經常用到的是 Channel 和 ChannelHandler。

每個檔案描述符對應一個 Channel,Channel 負責管理檔案描述符的生命週期,並處理髮生在檔案描述符上的事件:每當 EventLoop 檢測到一個與相應的檔案描述符相關的事件,就會通知 Channel。

ChannelPipeline 由一系列 ChannelHandler 組成,ChannelHandler 負責按順序處理 Channel 中的事件。ChannelPipeline 就像資料處理管道一樣,所以才有了這個名字。

ChannelHandler 要麼是 Inbound,要麼是 Outbound,要麼兩者兼有。Inbound 的ChannelHandler 負責處理 “inbound” 事件,例如從 socket 讀取資料、關閉 socket 或者其他由遠端發起的事件。Outbound 的 ChannelHandler 負責處理 “outbound” 事件,例如寫資料、發起連線以及關閉本地 socket。

ChannelHandler 按照一定順序處理事件,例如,讀取事件從管道的前面傳到後面,而寫入事件則從管道的後面傳到前面。每個 ChannelHandler 都會在處理完一個事件後生成一個新的事件給下一個 ChannelHandler。

ChannelHandler 是高度可重用的元件,所以儘可能設計得輕量級,每個 ChannelHandler 只處理一種資料轉換,這樣就可以靈活組合各種 ChannelHandler,提升程式碼的可重用性和封裝性。

我們可以通過 ChannelHandlerContext 來跟蹤 ChannelHandler 在 ChannelPipeline 中的位置。ChannelHandlerContext 包含了當前 ChannelHandler 到上一個和下一個 ChannelHandler的引用,因此,在任何時候,只要 ChannelHandler 還在管道當中,就能觸發新事件。

SwiftNIO 內建了多種 ChannelHandler,包括 HTTP 解析器。另外,SwiftNIO 還提供了一些Channel 實現,比如 ServerSocketChannel(用於接收連線)、SocketChannel(用於TCP連線)、DatagramChannel(用於UDP socket)和 EmbeddedChannel(用於測試)。

Step 1b: 新增 NIO Handler

  • Express.swift
open class Express {
  ...
      .childChannelInitializer { channel in
        channel.pipeline.configureHTTPServerPipeline().then {
          channel.pipeline.add(handler: HTTPHandler())
        }
      }
  ...
}
複製程式碼

新增真正的處理器方法

  • Express.swift
open class Express {

  //...
  
  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: "/", ...)
複製程式碼

.head中新增以下程式碼

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()
  }
複製程式碼

現在,我們第一步就完成了,實現了一個 Express 物件,執行我們的 Web 服務。

Step 2: 請求(IncomingMessage)與響應(ServerResponse) 物件

  • IncomingMessage.swift
import NIOHTTP1

open class IncomingMessage {

  public let header   : HTTPRequestHead // <= from NIOHTTP1
  public var userInfo = [ String : Any ]()
  
  init(header: HTTPRequestHead) {
    self.header = header
  }
}
複製程式碼
  • ServerResponse.swift
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() }
  }
}
複製程式碼

在 HTTPHandler 中使用

  • Express.swift
case .head(let header):
  let request  = IncomingMessage(header: header)
  let response = ServerResponse(channel: ctx.channel)
  
  print("req:", header.method, header.uri, request)
  response.send("Way easier to send data!!!")
複製程式碼

Step 3: 中介軟體(Middleware)和路由(Router)

中介軟體其實就是閉包,採用 typealias 進行別名定義:

  • Middleware.swift
public typealias Next = ( Any... ) -> Void

public typealias Middleware = (IncomingMessage, ServerResponse, @escaping Next ) -> Void
複製程式碼
  • Router.swift
open class Router {
  
  /// The sequence of Middleware functions.
  private var middleware = [ Middleware ]()

  /// Add another middleware (or many) to the list
  open func use(_ middleware: Middleware...) {
    self.middleware.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.middleware
    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!()
  }
}
複製程式碼

將路由類接入Express

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

// -------

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 request  = IncomingMessage(header: header)
          let response = ServerResponse(channel: ctx.channel)
          
          // trigger Router
          router.handle(request: request, response: response) {
            (items : Any...) in // the final handler
            response.status = .notFound
            response.send("No middleware handled the request!")
          }

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

// ------

...
.childChannelInitializer { channel in
    channel.pipeline.configureHTTPServerPipeline().then {
      channel.pipeline.add(
        handler: HTTPHandler(router: self))
    }
  }
...
複製程式碼

在 main.swift 中使用中介軟體和路由

  • main.swift
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)
複製程式碼

有了 use(),接下來實現 get(path)

  • 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)
    }
  }
}
複製程式碼

Step 4: 可複用的中介軟體

  • QueryString.swift
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 {
    req.userInfo[paramDictKey] =
      Dictionary(grouping: queryItems, by: { $0.name })
        .mapValues { $0.flatMap({ $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]
  }
}
複製程式碼
  • main.swift
app.use(queryString) // parse query params

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

Step 5: 採用 Codable 對 JSON 物件進行處理

  • ServerResponse.swift
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: ", ")
    }
  }
}
複製程式碼
  • TodoModel.swift
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)
]
複製程式碼
  • 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() }
  }
}
複製程式碼
  • main.swift
app.get("/todomvc") { _, res, _ in
  // send JSON to the browser
  res.json(todos)
}
複製程式碼

總結

以上就實現了一個小型的 Express Web 框架了,整體寫下來,對平時使用的諸多現成框架也有了更深刻的理解。

更多

相關文章