翻译自 µExpress/NIO - Adding Templates,部分内容由于与文章主题关系不大故没有翻译,如果英文尚可,建议阅读原文。

术语:

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

上篇文章(可以参考我的翻译)我们基于 SwiftNIO 构建了一个微型 Web 框架,这篇我们给它添加对 Mustache 模板的支持(并且我们的目标依然是用尽可能少的代码来实现这个功能):

使用起来也和 Express.js 类似,通过 render 函数:

app.get("/todos") { _ res, _ in
  res.render("todolist", [ 
    "title" : "Todos!", 
    "todos" : todos 
  ])
}

客户端看到的结果就像这样:

当然这里并不会实现 Express 提供的所有功能,我们只实现一个基本的 render 函数。如果你想要一个更完整的实现,可以看看 Noze.io 或者 ExExpress

初始设置参考上篇文章

计划

  1. 首先,我们借助 NonBlockingFileIO 这个辅助对象来异步读取模板数据(记住,使用 SwiftNIO 时永远不要阻塞 EventLoop!);
  2. 然后,导入 Swift 版本的 Mustache 库;
  3. 接着,给 ServerResponse 添加一个 render 方法
  4. 最后,写一个 Todolist HTML 页面并使用 Mustache 模板渲染。

1. NonBlockingFileIO

你也许还记得我们在上篇文章里讲过的:SwiftNIO 是以非阻塞的方式来执行 I/O 操作的。例如,从 socket 读取数据的时候,我们实际上只是告诉系统在收到数据的时候通知我们,然后就接着执行其他任务了。只有当数据真正到达的时候,我们才重新唤醒刚才的任务。与之相对的是阻塞系统,在这种方式下,我们提交一个读数据的请求并等待(阻塞)数据到达。

在 Node/Express(同样是非阻塞 I/O) 里,你可以使用 fs.readFile 函数加载文件数据到内存里,我们用 SwiftNIO 也来实现一个这样的方法:

public enum fs {
  static func readFile(_ path : String, 
                       _ cb   : ( Error?, ByteBuffer? ))
}

使用方法:

fs.readFile("/etc/passwd") { err, data in
  guard let data = data else {
    return print("Could not read file:", err) 
  }
  print("Read passwd:", data)
}

SwiftNIO 提供了 NonBlockingFileIO 来帮助我们读取文件。之所以提供这个辅助类型是因为在 Posix 系统里,磁盘读写操作一般都不是用非阻塞 I/O 的方式实现的。为了绕过这个问题,NonBlockingFileIO 使用一个线程池(在子线程)读写文件并在读写完成之后通知我们。

fs.swift

// File: fs.swift - create this in Sources/MicroExpress
import NIO

public enum fs {
  
  static let threadPool : BlockingIOThreadPool = {
    let tp = BlockingIOThreadPool(numberOfThreads: 4)
    tp.start()
    return tp
  }()

  static let fileIO = NonBlockingFileIO(threadPool: threadPool)

  public static
  func readFile(_ path    : String,
                eventLoop : EventLoop? = nil,
                maxSize   : Int = 1024 * 1024,
                 _ cb: @escaping ( Error?, ByteBuffer? ) -> ())
  {
    let eventLoop = eventLoop
                 ?? MultiThreadedEventLoopGroup.currentEventLoop
                 ?? loopGroup.next()
    
    func emit(error: Error? = nil, result: ByteBuffer? = nil) {
      if eventLoop.inEventLoop { cb(error, result) }
      else { eventLoop.execute { cb(error, result) } }
    }
    
    threadPool.submit {
      assert($0 == .active, "unexpected cancellation")
      
      let fh : NIO.FileHandle
      do { // Blocking:
        fh = try NIO.FileHandle(path: path)
      }
      catch { return emit(error: error) }
      
      fileIO.read(fileHandle : fh, byteCount: maxSize,
                  allocator  : ByteBufferAllocator(),
                  eventLoop  : eventLoop)
        .map         { try? fh.close(); emit(result: $0) }
        .whenFailure { try? fh.close(); emit(error:  $0) }
    }
  }
}

注意到上面的代码里有一个 loopGroup,它其实就是 Express 里的 loopGroup 变量,但为了让这里可以调用它,我们需要把它从 Express 类里暴露出来:

Express.swift

let loopGroup =
  MultiThreadedEventLoopGroup(numThreads: System.coreCount)
  
open class Express : Router {

讨论

上面的代码里:

  1. 首先创建并启动了一个线程池 threadPool 对象,
  2. 然后基于这个 threadPool 创建了一个用于文件读写的 NonBlockingFileIO 对象
  3. 最后添加一个 readFile 函数

此外,在 readFile 里我们还创建了一个 emit 函数,它可以确保我们在选定的 EventLoop 上反馈错误或者结果。接着,我们向 threadPool 里提交了一个任务:

threadPool.submit {
   ...
   fh = try NIO.FileHandle(path: path)
}

之所以这么做是因为 FileHandle(path: path) 也是一个阻塞操作。是否还记得打开 Finder 时转风火轮?它可能也是因为类似这样的调用而阻塞了。

最后,为了简单起见,我们一次性读取整个文件:

fileIO.read(fileHandle : fh, byteCount: maxSize,
            allocator  : alloc,
            eventLoop  : eventLoop)
  .map         { try? fh.close(); emit(result: $0) }
  .whenFailure { try? fh.close(); emit(error:  $0) }

read 函数立即返回一个 Future 实例,但真正的文件内容将会异步读取,如果读取完成则执行 map 里面的操作:关闭文件并返回数据缓冲区;如果失败则执行 whenFailure 里的操作。

使用一下看看:

main.swift

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

fs.readFile("/etc/passwd") { err, data in
  guard let data = data else { return print("Failed:", err) }
  print("Read passwd:", data)
}

所以,现在我们有了一个可以异步非阻塞读文件的函数,接下来将用它来加载 Mustache 模板文件。

Grand Central Dispatch

上面的代码如果用 GCD 来做将非常简单:

DispatchQueue.global().async {
  do    { cb(nil,   try Data(contentsOf: fileurl)) }
  catch { cb(error, nil) }
}

2. 添加 Mustache 库

我们使用 ARI Mustache作为我们的 Mustache 模板引擎。修改 Package.swift:

Package.swift

// File: Package.swift - add to dependencies (below NIO):
.package(url: "https://github.com/AlwaysRightInstitute/mustache.git",
         from: "0.5.1")

// File: Package.swift - add to target dependencies:
dependencies: [
  "NIO",
  "NIOHTTP1",
  "mustache" // <= new one
])

重新编译。

3. ServerResponse.render

接下来我们给 ServerResponse 对象添加一个 render 函数,使得我们可以这样调用:

app.get("/") {
  response.render("index", [ "title": "Hello World!" ])
}

我们来实现这个函数:

ServerResponse.swift

// File: ServerResponse.swift - Add at the bottom

import mustache

public extension ServerResponse {
  
  public func render(pathContext : String = #file,
                     _ template  : String,
                     _ options   : Any? = nil)
  {
    let res = self
    
    // Locate the template file
    let path = self.path(to: template, ofType: "mustache",
                         in: pathContext)
            ?? "/dummyDoesNotExist"
    
    // Read the template file
    fs.readFile(path) { err, data in
      guard var data = data else {
        res.status = .internalServerError
        return res.send("Error: \(err as Optional)")
      }
      
      data.write(bytes: [0]) // cstr terminator
      
      // Parse the template
      let parser = MustacheParser()
      let tree   : MustacheNode = data.withUnsafeReadableBytes {
        let ba  = $0.baseAddress!
        let bat = ba.assumingMemoryBound(to: CChar.self)
        return parser.parse(cstr: bat)
      }
      
      // Render the response
      let result = tree.render(object: options)
      
      // Deliver
      res["Content-Type"] = "text/html"
      res.send(result)
    }
  }
  
  private func path(to resource: String, ofType: String, 
                    in pathContext: String) -> String?
  {
    #if os(iOS) && !arch(x86_64) // iOS support, FIXME: blocking ...
      return Bundle.main.path(forResource: template, ofType: "mustache")
    #else
      var url = URL(fileURLWithPath: pathContext)
      url.deleteLastPathComponent()
      url.appendPathComponent("templates", isDirectory: true)
      url.appendPathComponent(resource)
      url.appendPathExtension("mustache")
      return url.path
    #endif
  }
}

讨论

模板查询

给这个函数传入一个模板文件名,例如说 “index”,然后我们需要通过某种方式将它映射到文件系统里的一个路径上:

/Users/helge/Documents/
  MicroExpress/Sources/MicroExpress/
  templates/index.mustache

对于 iOS/macOS 来说,你可以将这个文件放到资源包里然后使用 Bundle.main.path() 方法来获取这个文件,但不幸的是,SPM 目前还不支持管理资源文件,所以我们得用一些技巧绕过:

public func render(pathContext : String = #file, ..)

我们使用 #file 作为 pathContext 的默认值,它会被扩展为调用 render 函数的源文件的文件路径,比如说,如果你从定义在 main.swift 里的一个路由里调用这个方法,那么它会被扩展为:

/Users/helge/Documents/
  MicroExpress/Sources/TodoBackend/
  main.swift

也就是说,它给我们提供了一个用于查询资源的基本路径。

读取,解析,发送

接下来的代码非常直白:

  • 使用 fs.readFile 读取模板文件
  • 使用 MustacheParser 解析刚才读取的模板
  • 根据模板生成字符串
  • 使用 send 方法将刚才生成的字符串发送出去

4. Todolist

在上篇文章里我们已经实现了将一个 Todo 结构体的数组转换成 JSON 数据并发送给客户端的功能,今天,我们想要将这个数组渲染成一个 HTML 页面:

main.swift

// File: main.swift - add before catch-all route!

app.get("/todos") { _, res, _ in
  res.render("Todolist", [ "title": "DoIt!", "todos": todos ])
}

先创建一个名为 Todolist.mustache 的文件,并放在 template 目录下:

Todolist.mustache

重新编译运行,从浏览器打开链接:http://localhost:1337/todos

由于 Xcode 默认生成的项目结构里有一个 Source 文件夹,如果你直接在 Xcode 里添加一个 templates 目录和这个模板文件可能会遇到找不到文件的问题,此时你需要将 main.swift 从 Source 移动到跟 templates 同级的目录下

可以运行了,但是看起来很丑,继续修改:

Todolist.mustache

编译运行: