数据校验 封面
Vapor 大型教程

数据校验

表单提交的数据不一定靠谱。本文教你用 Vapor 3 的 Validatable 协议校验注册表单:既支持字段级规则,也能自定义「两次密码一致」这样的跨字段校验,并把错误提示回显到页面。

一、先做注册页面

前面我们已经有了一个能创建缩略语、类别的 Web 应用。现在为它加上用户注册界面。先写注册表单模板 register.leaf

#set("content") {
    <h1>#(title)</h1>
    <form method="post">
        <div class="form-group">
            <label for="name">Name</label>
            <input type="text" name="name" class="form-control"
            id="name"/>
        </div>
        <div class="form-group">
            <label for="username">Username</label>
            <input type="text" name="username" class="form-control"
            id="username"/>
        </div>
        <div class="form-group">
            <label for="password">Password</label>
            <input type="password" name="password" class="form-control"
            id="password"/>
        </div>
        <div class="form-group">
            <label for="confirmPassword">Confirm Password</label>
            <input type="password" name="confirmPassword"
            class="form-control" id="confirmPassword"/>
        </div>
        <button type="submit" class="btn btn-primary">
            Register
        </button>
    </form>
}
#embed("base")

注册表单对应的上下文与数据模型如下。注意 RegisterData 遵循 Content,这样可以直接用 req.content.decode 解码请求体:

struct RegisterContext: Encodable {
    let title = "Register"
}

struct RegisterData: Content {
    let name: String
    let username: String
    let password: String
    let confirmPassword: String
}

boot(router:) 中注册两个路由:一个显示注册页(GET),一个接收提交(POST):

func boot(router: Router) throws {
        ...
        authSessionRoutes.get("register", use: registerHandler)
        authSessionRoutes.post(RegisterData.self, at: "register", use: registerPostHandler)
        ...
    }

    ...
    func registerHandler(_ req: Request) throws -> Future<View> {
        let context = RegisterContext()
        return try req.view().render("register", context)
    }
    func registerPostHandler(_ req: Request, data: RegisterData) throws -> Future<Response> {
        let password = try BCrypt.hash(data.password)
        let user = User(name: data.name, username: data.username, password: password)

        return user.save(on: req).map(to: Response.self) { user in
            try req.authenticateSession(user)
            return req.redirect(to: "/")
        }
    }

导航栏模板 base.leaf 里,未登录时才显示 Register 入口:

...
        #if(!userLoggedIn) {
            <li class="nav-item #if(title == "Register"){active}">
                <a href="/register" class="nav-link">Register</a>
            </li>
        }
    </ul>
...

二、注册用户时的数据有效性验证

基本数据有效性验证

填写表单时,我们要保证数据符合规则,不符合就提示录入错误。Vapor 3 通过 ValidatableReflectable 协议来声明校验规则:

extension RegisterData: Validatable, Reflectable {
    static func validations() throws -> Validations<RegisterData> {
        var validations = Validations(RegisterData.self)
        try validations.add(\.name, .ascii)
        try validations.add(\.username, .alphanumeric && .count(3...))
        try validations.add(\.password, .count(8...))
        return validations
    }
}

然后在处理提交时调用 data.validate(),校验失败就重定向回注册页:

...
    func registerPostHandler(_ req: Request, data: RegisterData) throws -> Future<Response> {
        do {
            try data.validate()
        } catch {
            return req.future(req.redirect(to: "register"))
        }
        ...
    }
说明:.ascii.alphanumeric.count(3...) 都是 Vapor 内置的校验器。多个规则之间可以用 && 组合,比如要求用户名既是字母数字、又至少 3 位。

自定义数据有效性验证

前面都是针对单个字段的校验,但「两次输入的密码一致」这种跨字段规则 Vapor 没有内建,需要自定义:

extension RegisterData: Validatable, Reflectable {
    static func validations() throws -> Validations<RegisterData> {
        ...
        validations.add("passwords match", { (model) in
            guard model.password == model.confirmPassword else {
                throw BasicValidationError("passwords don't match")
            }
        })
        return validations
    }
}

注册页面显示错误信息

校验完成了,但出错时没有途径通知用户修正,所以要在出错时把错误信息显示在页面上。模板里用 #if(message) 渲染提示框:

#set("content") {
    <h1>#(title)</h1>
    #if(message) {
        <div class="alert alert-danger" role="alert">
            Please fix the following errors:<br />
            #(message)
        </div>
    }
    ...

上下文增加可选的 message 字段,并在显示页面时从查询参数里读取:

struct RegisterContext: Encodable {
    let title = "Register"
    let message: String?

    init(message: String? = nil) {
        self.message = message
    }
}
...
    func registerHandler(_ req: Request) throws -> Future<View> {
        let context: RegisterContext
        if let message = req.query[String.self, at: "message"] {
            context = RegisterContext(message: message)
        } else {
            context = RegisterContext()
        }
        ...
    }

提交处理函数捕获校验错误,把错误信息 URL 编码后回带到注册页:

 func registerPostHandler(_ req: Request, data: RegisterData) throws -> Future<Response> {
        do {
            try data.validate()
        } catch(let error) {
            let redirect: String
            if let error = error as? ValidationError,
                let message = error.reason.addingPercentEncoding(
                    withAllowedCharacters: .urlQueryAllowed) {
                redirect = "/register?message=\(message)"
            } else {
                redirect = "/register?message=Unknown+error"
            }
            return req.future(req.redirect(to: redirect))
        }
        ...
        }
    }
小提示:错误信息要经过 addingPercentEncoding 做 URL 编码再拼进查询串,否则特殊字符(如空格、&)会破坏 URL。

本系列其他文章