Vapor 框架学习记录(5)抽象表单与表单字段

本篇都是关于创建一个抽象的表单构建器,我们可以使用它来生成 HTML 表单。 这能让我们复用通用字段来组成所有类型的输入表单。 在本篇的后半部分,将讨论使用面向协议的解决方案处理用户输入、加载和持久化数据。 最后,我们将使用组件重建我们已经存在的用户登录表单模块

可复用表单

作为第一步,我们应该在Framework文件夹中创建一个新的 Form 目录,我们可以在其中放置所有共享的表单组件。 我们先从一个 LabelContext 对象开始,该对象将代表给定表单字段的标签。 将它放在 Templates/Contexts 子目录中


/// FILE: Sources/App/Framework/Form/Templates/Contexts/LabelContext.swift

public struct LabelContext {
    public var key: String
    public var title: String?
    public var required: Bool
    public var more: String?
    
    public init(key: String,
                title: String? = nil,
                required: Bool = false,
                more: String? = nil) {
        self.key = key
        self.title = title
        self.required = required
        self.more = more
    }
}

正如我们从前面文章中学到的,我们将需要一个模板文件来渲染上下文对象。

/// FILE: Sources/App/Framework/Form/Templates/Html/LabelTemplate.swift

import Vapor
import SwiftHtml

public struct LabelTemplate: TemplateRepresentable {
    
    var context: LabelContext
    
    public init(_ context: LabelContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        Label {
            Text(context.title ?? context.key.capitalized)
            
            if let more = context.more {
                Span(more)
                    .class("more")
            }
            if context.required {
                Span("*")
                    .class("required")
            }
        }.for(context.key   )
    }
}

现在我们有一个可重用的标签,我们可以使用它来呈现表单字段对象。 如果我们想重构用户登录表单,我们将需要一个可以呈现电子邮件和密码样式的 HTML 输入的通用输入框。 我们将使用 InputFieldContext 结构来设置输入模板。 我们将把所有与字段相关的文件放入 Form/Fields 目录中。

/// FILE: Sources/App/Framework/Form/Fields/InputFieldContext.swift

import SwiftHtml

public struct InputFieldContext {
    public let key: String
    public var label: LabelContext
    public var type: SwiftHtml.Input.`Type`
    public var placeholder: String?
    public var value: String?
    public var error: String?
    
    public init(key: String,
                label: LabelContext? = nil,
                type: Input.`Type` = .text,
                placeholder: String? = nil,
                value: String? = nil,
                error: String? = nil) {
        self.key = key
        self.label = label ?? .init(key: key)
        self.type = type
        self.placeholder = placeholder
        self.value = value
        self.error = error
    }
}

类型枚举将用于设置输入字段的类型,key将是每个字段的唯一值,稍后我们将能够使用此key检索服务器端的字段值 . value 将是一个简单的字符串值表示,如果没有设置value,则输入元素将显示占位符值。 label 属性是用于呈现标签模板的上下文。 error 属性是一个可选值,如果有错误我们将显示它。

import Vapor
import SwiftHtml

public struct InputFieldTemplate: TemplateRepresentable {
    public var context: InputFieldContext
    
    public init(_ context: InputFieldContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        LabelTemplate(context.label).render(req)
        
        Input()
            .type(context.type)
            .key(context.key)
            .placeholder(context.placeholder)
            .value(context.value)
            .class("field")
        if let error = context.error {
            Span(error)
                .class("error")
        }
    }
}

现在我们需要一些东西来连接输入和输出模板。 理想情况下,我们希望能够通过解码通用输入值来处理表单,然后当我们必须呈现表单时,最好使用通用模板来显示实际的表单字段。 我们将创建一个抽象表单字段类来实现这一点。


/// FILE: Sources/App/Framework/Form/AbstractFormField.swift

import Vapor

open class AbstractFormField {
    public var key: String
    public var input: Input
    public var output: Output
    public var error: String?
    
    public init(key: String,
                input: Input,
                output: Output,
                error: String? = nil) {
        self.key = key
        self.input = input
        self.output = output
        self.error = error
    }
    
    open func config(_ block: (AbstractFormField) -> Void) -> Self {
        block(self)
        return self
    }
}

key将用于解码输入并设置输出模板的密钥。 输入和输出值是通用值,它们将在子类中指定。 我们还将向 AbstractFormField 类添加一个错误属性,以便我们可以验证输入并在以后呈现问题(如果有)
这就是我们如何根据现有组件定义实际的 InputField

/// FILE: Sources/App/Framework/Form/Fields/InputField.swift

public final class InputField: AbstractFormField {
    public convenience init(_ key: String) {
        self.init(key: key, input: "", output: .init(.init(key: key)))
    }
}

我们仍然需要以某种方式将Field存储在表单类中,但在此之前,我们可以用 FormAction定义这个操作

/// FILE: Sources/App/Framework/Form/FormAction.swift


import SwiftHtml

public struct FormAction {
    public var method: SwiftHtml.Method
    public var url: String?
    public var enctype: SwiftHtml.Enctype?
    
    public init(method: SwiftHtml.Method = .post,
                url: String? = nil,
                enctype: SwiftHtml.Enctype? = nil){
        self.method = method
        self.url = url
        self.enctype = enctype
    }
}

就是这样,我们现在可以定义一个 AbstractForm 类,它可以存储所有可用的表单字段以及表单操作。 一个表单也可以有一个通用的错误信息,所以我们为此目的存储一个错误属性。 例如,如果登录尝试失败,我们只想打印“无效的电子邮件或密码消息”而不是特定的表单字段错误,但如果是电子邮件输入错误,我们可能希望在其旁边显示“无效的电子邮件地址”

/// FILE: Sources/App/Framework/Form/AbstractForm.swift

import Vapor

open class AbstractForm {
    open var action: FormAction
    open var fields: [Any]
    open var error: String?
    open var submit: String?
    
    public init(action: FormAction = .init(),
                fields: [Any] = [],
                error: String? = nil,
                submit: String? = nil) {
        self.action = action
        self.fields = fields
        self.error = error
        self.submit = submit
    }
}

因为Swift 不允许我们存储不确定类型的 ** [AbstractFormField] **数组,因为它使用到泛型。所以我们先临时在 fields 数组中放置一个 Any 表示

最终我们还是需要一种渲染表单的方法,所以在 FormContextfields将被表示为 TemplateRepresentable


/// FILE: Sources/App/Framework/Form/Templates/Contexts/FormContext.swift

public struct FormContext {
    public var action: FormAction
    public var fields: [TemplateRepresentable]
    public var error: String?
    public var submit: String?
}

Inside the FormTemplate we can use the context to render the form with the fields.

/// FILE: Sources/App/Framework/Form/Templates/Html/FormTemplate.swift

import Vapor
import SwiftHtml

public struct FormTemplate: TemplateRepresentable {
    var context: FormContext
    
    public init(_ context: FormContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        Form {
            if let error = context.error {
                Section {
                    P(error)
                        .class("error")
                }
            }
            
            context.fields.map { field in
                Section {
                    field.render(req)
                }
            }
            
            Section {
                Input()
                    .type(.submit)
                    .value(context.submit ?? "Save")
            }
        }
        .method(context.action.method)
        .action(context.action.url)
        .enctype(context.action.enctype)
    }
}

逻辑很简单,我们只需要遍历表单字段并调用 TemplateRepresentable 协议上的 render 方法。 如果context携带异常值,我们将显示错误,并且我们还设置了正确的 actionmethodenctype values

这样通用表单的基础框架就搭建完毕了,后面我们可以做更多的扩展和处理 AbstractForm 类中的 Any 类型问题

表单组件

我们将要通过表单组件去响应处理表单发生的多个事件

load事件在当初始加载表单时会调用,这是从数据库加载渲染表单所需的相关模型的好位置。 在渲染方法之前,应该调用 read 方法,我们可以使用该方法读回字段的实际值。 当后端尝试使用模板引擎显示表单时,将调用 render 方法

process 方法负责处理用户通过表单提交的输入数据。 将数据存储在表单字段的输入值中后,应调用 validate 方法,进行验证并设置错误。 在write 方法能使用模型写入有效的输入值。 在我们写回经过验证的数据值之后,可以使用 save 方法执行保存操作

根据上述,下面是 FormComponent 协议的一些方法。


/// FILE: Sources/App/Framework/Form/FormComponent.swift

import Vapor

public protocol FormComponent {
    
    func load(req: Request) async throws
    func process(req: Request) async throws
    func validate(req: Request) async throws -> Bool
    func write(req: Request) async throws
    func save(req: Request) async throws
    func read(req: Request) async throws
    func render(req: Request) -> TemplateRepresentable
}

一个表单组件将在我们的 AbstractForm 类中表示表单字段数组。 首先,我们必须在 AbstractFormField 类中实现 FormComponent 协议


/// FILE: Sources/App/Framework/Form/AbstractFormField.swift

import Vapor

open class AbstractFormField: FormComponent {
    
    public var key: String
    public var input: Input
    public var output: Output
    public var error: String?
    
    public init(key: String,
                input: Input,
                output: Output,
                error: String? = nil) {
        self.key = key
        self.input = input
        self.output = output
        self.error = error
    }
    
    open func config(_ block: (AbstractFormField) -> Void) -> Self {
        block(self)
        return self
    }
    
    // MARK: - FormComponent
    public func load(req: Request) async throws {
        
    }
    
    public func process(req: Request) async throws {
        if let value = try? req.content.get(Input.self, at: key) {
            input = value
        }
    }
    
    public func validate(req: Request) async throws -> Bool {
        return true
    }
    
    public func write(req: Request) async throws {
        
    }
    
    public func save(req: Request) async throws {
        
    }
    
    public func read(req: Request) async throws {
        
    }
    
    public func render(req: Request) -> TemplateRepresentable {
        return output
    }
}

我们现在没有在扩展中定义这些方法,因为想要允许子类在需要时覆盖它们。 到现在,当前版本可以正常工作,下面我们将填补缺失的空白。 让我们继续处理AbstractForm类,并使用 FormComponent 协议对其进行扩展

/// FILE: Sources/App/Framework/Form/AbstractForm.swift

import Vapor

open class AbstractForm: FormComponent {
    open var action: FormAction
    open var fields: [FormComponent]
    open var error: String?
    open var submit: String?
    
    public init(action: FormAction = .init(),
                fields: [FormComponent] = [],
                error: String? = nil,
                submit: String? = nil) {
        self.action = action
        self.fields = fields
        self.error = error
        self.submit = submit
    }
    
    // MARK: - FromComponent
    
    public func load(req: Request) async throws {
        for field in fields {
            try await field.load(req: req)
        }
    }
    
    public func process(req: Request) async throws {
        for field in fields {
            try await field.process(req: req)
        }
    }
    
    public func validate(req: Request) async throws -> Bool {
        var result: [Bool] = []
        for field in fields {
            result.append(try await field.validate(req: req))
        }
        return result.filter { $0 == false }.isEmpty
    }
    
    public func write(req: Request) async throws {
        for field in fields {
            try await field.write(req: req)
        }
    }
    
    public func save(req: Request) async throws {
        for field in fields {
            try await field.save(req: req)
        }
    }
    
    public func read(req: Request) async throws {
        for field in fields {
            try await field.read(req: req)
        }
    }
    
    public func render(req: Request) -> TemplateRepresentable {
        FormTemplate(.init(action: action,
                           fields: fields.map { $0.render(req: req)},
                           error: error,
                           submit: submit))
    }
}

现在我们已经准备好 AbstractForm 的实现了,我们可以开始重构一下我们的用户模块。 让我们在用户模块内创建一个新的 Forms 目录,接着创建一个** UserLoginForm**

重构用户登录

第一步是调整UserLoginContext 对象。 我们将使用表单作为 TemplateRepresentable 变量,因此我们可以将其作为模板内的标签呈现

/// FILE: Sources/App/Modules/User/Templates/Contexts/UserLoginContext.swift
struct UserLoginContext {
    let icon: String
    let title: String
    let message: String
    let form: TemplateRepresentable
    
    init(icon: String,
         title: String,
         message: String,
         form: TemplateRepresentable) {
        self.icon = icon
        self.title = title
        self.message = message
        self.form = form
    }
}

因为我们改变了UserLoginContextUserLoginTemplate 也要跟着改变。

/// FILE: Sources/App/Modules/User/Templates/Html/UserLoginTemplate.swift

import Vapor
import SwiftHtml
import SwiftSgml

struct UserLoginTemplate: TemplateRepresentable {
    
    var context: UserLoginContext
    
    @TagBuilder
    func render(_ req: Request) -> Tag {
        WebIndexTemplate.init(.init(title: context.title)) {
            Div {
                Section {
                    P (context.icon)
                    H1(context.title)
                    P(context.message)
                }
                .class("lead")
                
                context.form.render(req)
            }
            .id("user-login")
            .class("container")
        }
        .render(req)
    }
}

我们还需要创建一个带有两个输入框的 UserLoginForm

/// FILE: Sources/App/Modules/User/Forms/UserLoginForm.swift

import Vapor

final class UserLoginForm: AbstractForm {
 
    public convenience init() {
        self.init(action: .init(method: .post, url: "/sign-in/"),
                  submit: "Sign in")
        self.fields = createFields()
    }
    
    func createFields() -> [FormComponent] {
        return [
            InputField("email")
                .config {
                    $0.output.context.label.required = true
                    $0.output.context.type = .email
                },
            InputField("password")
                .config {
                    $0.output.context.label.required = true
                    $0.output.context.type = .password
                }
        ]
    }
}

接下来,我们回到 UserLoginController类中,我们可以使用 UserLoginForm去渲染页面。

/// FILE: Sources/App/Modules/User/Controllers/UserFrontendController.swift

import Vapor

struct UserFrontendController {
        
    func renderSignInView(_ req: Request, _ form: UserLoginForm) -> Response {
        let template = UserLoginTemplate(context: .init(icon: "⬇️",
                                                        title: "Sign in",
                                                        message: "Please log in with your existing account",
                                                        form: form.render(req: req)))
        
        return req.templates.renderHtml(template)
    }
    
    func signInView(_ req: Request) async throws -> Response {
        return renderSignInView(req, .init())
    }
    
    func signInAction(_ req: Request) async throws -> Response {
        /// the user is authenticated, we can store the user data inside the session too
        if let user = req.auth.get(AuthenticatedUser.self) {
            req.session.authenticate(user)
            return req.redirect(to: "/")
        }
        
        /// if the user credentials were wrong we render the form again with an error message
        let form = UserLoginForm()
        try await form.process(req: req)
        form.error = "Invalid email or password."
        return renderSignInView(req, form)
    }
    
    func signOut(req: Request) throws -> Response {
        req.auth.logout(AuthenticatedUser.self)
        req.session.unauthenticate(AuthenticatedUser.self)
        return req.redirect(to: "/")
    }
    
}

我们做完了上面的修改去适配我们新表单框架。 如你所见,我们能够从 UserFrontendController 中抽离了相关代码,并将与表单相关的逻辑移到单独的文件中。

表单组件构建器

在上面 UserLoginForm 中的 createFields方法中,我们返回了一个表单字段数组,但我不喜欢我们总是必须在元素之间写方括号和冒号, 所以接下来我想优化这一块的代码

Swift 有一个很好的特性,叫做 result builders,我们可以使用它来让我们的代码更漂亮一点。 我们将创建一个可用于构建表单字段数组的 FormComponentBuilder

/// FILE: Sources/App/Framework/Form/FormComponentBuilder.swift

@resultBuilder
public enum FormComponentBuilder {
    public static func buildBlock(_ components: FormComponent...) -> [FormComponent] {
        components
    }
}

现在可以使用 @FormComponentBuilder 结果构建器标记 createFields 方法,我们可以删除多余的符号

/// FILE: Sources/App/Modules/User/Forms/UserLoginForm.swift

import Vapor

final class UserLoginForm: AbstractForm {
 
    public convenience init() {
        self.init(action: .init(method: .post, url: "/sign-in/"),
                  submit: "Sign in")
        self.fields = createFields()
    }
    
    @FormComponentBuilder
    func createFields() -> [FormComponent] {
        InputField("email")
            .config {
                $0.output.context.label.required = true
                $0.output.context.type = .email
            }
        InputField("password")
            .config {
                $0.output.context.label.required = true
                $0.output.context.type = .password
            }
    }
}

Result builders 是 Swift 中一个强大的功能。 可以在 Swift 中创建全新的领域特定语言 (DSL)。 SwiftHtml 模板引擎也利用了结果构建器,这样能让我们写出更加直观的 HTML页面。

总结

在本篇中,我们学习了如何构建可重用的表单组件系统并且利用新机制重构了用户模块。在下一篇,我们回继续完善表单组件事件和抽象表单的异步验证机制。

你可能感兴趣的:(Vapor 框架学习记录(5)抽象表单与表单字段)