Skip to content

Latest commit

 

History

History
864 lines (566 loc) · 49.9 KB

07.md

File metadata and controls

864 lines (566 loc) · 49.9 KB

七、同构网络形式

在前一章中,我们重点讨论了如何将服务器端应用的数据切换到客户端应用,以便在实现购物车功能的同时无缝地维护状态。在第 6 章同构切换中,我们将服务器视为真理的唯一来源。服务器向客户端指示购物车的当前状态。在本章中,我们将超越到目前为止所考虑的简单用户交互,进入接受通过同构 web 表单提交的用户生成数据的领域。

这意味着现在,客户机有了一个声音,可以口述应该存储在服务器上的用户生成的数据,当然这是有充分理由的(验证用户提交的数据)。使用同构 web 表单,验证逻辑可以跨环境共享。客户端应用可以在表单数据提交到服务器之前插入并通知用户他们犯了错误。服务器端应用拥有最终否决权,因为它将在服务器端重新运行验证逻辑(从表面上看,验证逻辑不能被篡改),并且仅在验证结果成功后处理用户生成的数据。

除了提供共享验证逻辑和表单结构的能力外,同构 web 表单还提供了一种使表单更易于访问的方法。我们必须解决可能没有 JavaScript 运行时或可能禁用 JavaScript 运行时的 web 客户端的可访问性问题。为了实现这个目标,我们将为 IGWEB 的 contact 部分构建一个同构的 web 表单,并考虑逐步增强。这意味着,只有在实现表单功能以满足最低限度的、禁用 JavaScript 的 web 客户端场景之后,我们才能继续实现直接在配备 JavaScript 的 web 浏览器中运行的客户端表单验证。

到本章结束时,我们将有一个健壮的、同构的 web 表单,它用一种语言(Go)实现,可以跨环境重用公共代码。最重要的是,同构 web 表单将可供在终端窗口中运行的最精简的 web 客户端访问,同时也可供具有最新 JavaScript 运行时的基于 GUI 的 web 客户端访问。

在本章中,我们将介绍以下主题:

  • 理解表单流
  • 联系方式的设计
  • 验证电子邮件地址语法
  • 表单接口
  • 实现联系人表单
  • 无障碍联系方式
  • 客户端注意事项
  • 联系表单 RESTAPI 端点
  • 检查客户端验证

理解表单流

图 7.1描绘了一幅仅在服务器端验证的情况下显示 web 表单的图像。表单通过 HTTP Post 请求提交到 web 服务器。服务器提供完全呈现的网页响应。如果用户没有正确填写表单,错误将被填充并显示在网页响应中。如果用户正确填写了表单,则会将 HTTP 重定向到确认网页:

图 7.1:仅具有服务器端验证的 web 表单

图 7.2描绘了一幅显示 web 表单的图像,其中客户端和服务器端验证都到位。当用户提交 web 表单时,表单中的数据将使用客户端验证进行验证。表单数据将使用对 RESTAPI 端点的 XHR 调用提交到 web 服务器,只有在成功获得客户端验证结果之后。表单数据提交到服务器后,将进行第二轮服务器端验证。这确保了表单数据的质量,即使在客户端验证可能被篡改的情况下也是如此。客户端应用将检查从服务器返回的表单验证结果,并在表单提交成功时显示确认页面,或在表单提交失败时显示联系人表单错误:

图 7.2:在客户端和服务器端验证的 web 表单

联系方式的设计

联系表将允许网站用户与 IGWEB 团队取得联系。成功完成联系人表单将导致联系人表单提交,其中包含用户生成的表单数据,这些数据将保存在 Redis 数据库中。图 7.3为描述接触形式的线框图像:

图 7.3:接触形式的线框设计

图 7.4为线框图像,描绘了当用户未正确填写表单时显示的带有表单错误的联系人表单:

图 7.4:联系人表单的线框设计,显示错误消息

图 7.5是描述确认页面的线框图像,该页面将在成功提交联系人表单后显示给用户:

图 7.5:确认页面的线框设计

联系人表单将向用户征求以下所需信息:他们的名字、姓氏、电子邮件地址以及给团队的消息。如果用户没有填写这些字段中的任何一个,在点击表单上的联系人按钮时,用户将收到特定于字段的错误消息,指示尚未填写的字段。

实现模板

从服务器端呈现联系人页面时,我们将使用contact_page模板(在shared/templates/contact_page.tmpl文件中找到):

{{ define "pagecontent" }}
{{template "contact_content" . }}
{{end}}
{{template "layouts/webpage_layout" . }}

回想一下,因为我们包含了layouts/webpage_layout模板,这将打印生成页面的doctypehtmlbody标记的标记。此模板将仅在服务器端使用。

使用define模板动作,我们划分"pagecontent"块,在该块中呈现联系人页面的内容。联系人页面的内容在contact_content模板中定义(见shared/template/contact_content.tmpl文件):

<h1>Contact</h1>

{{template "partials/contactform_partial" .}}

回想一下,除了服务器端应用之外,客户端应用还将使用contact_content模板在主要内容区域呈现联系人表单。

contact_content模板中,我们包括包含联系人表单标记的联系人表单部分模板(partials/contactform_partial

<div class="formContainer">
<form id="contactForm" name="contactForm" action="/contact" method="POST" class="pure-form pure-form-aligned">
  <fieldset>
{{if .Form }}
    <div class="pure-control-group">
      <label for="firstName">First Name</label>
      <input id="firstName" type="text" placeholder="First Name" name="firstName" value="{{.Form.Fields.firstName}}">
      <span id="firstNameError" class="formError pure-form-message-inline">{{.Form.Errors.firstName}}</span>
    </div>

    <div class="pure-control-group">
      <label for="lastName">Last Name</label>
      <input id="lastName" type="text" placeholder="Last Name" name="lastName" value="{{.Form.Fields.lastName}}">
      <span id="lastNameError" class="formError pure-form-message-inline">{{.Form.Errors.lastName}}</span>
    </div>

    <div class="pure-control-group">
      <label for="email">E-mail Address</label>
      <input id="email" type="text" placeholder="E-mail Address" name="email" value="{{.Form.Fields.email}}">
      <span id="emailError" class="formError pure-form-message-inline">{{.Form.Errors.email}}</span>
    </div>

    <fieldset class="pure-control-group">
      <textarea id="messageBody" class="pure-input-1-2" placeholder="Enter your message for us here." name="messageBody">{{.Form.Fields.messageBody}}</textarea>
      <span id="messageBodyError" class="formError pure-form-message-inline">{{.Form.Errors.messageBody}}</span>
    </fieldset>

    <div class="pure-controls">
      <input id="contactButton" name="contactButton" class="pure-button pure-button-primary" type="submit" value="Contact" />
    </div>
{{end}}
  </fieldset>
</form>
</div>

该部分模板包含实现图 7.3所示线框设计所需的 HTML 标记。访问表单字段值的模板操作及其相应错误以粗体显示。我们为给定的input字段填充value属性的原因是,如果用户填写表单时出错,这些值将用用户在上一次表单提交尝试中输入的值预填充。每个input字段后面都有一个<span>标记,该标记将包含该特定字段的相应错误消息。

最后一个<input>标签是submit按钮。通过单击此按钮,用户将能够向 web 服务器提交表单内容。

验证电子邮件地址语法

除了必须填写所有字段的基本要求外,电子邮件地址字段必须是格式正确的电子邮件地址。如果用户未能提供格式正确的电子邮件地址,则特定于字段的错误消息将通知用户电子邮件地址语法不正确。

我们将使用在shared文件夹中找到的validate包中的EmailSyntax函数:

const EmailRegex = `(?i)^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,3})+$`

func EmailSyntax(email string) bool {
  validationResult := false
  r, err := regexp.Compile(EmailRegex)
  if err != nil {
    log.Fatal(err)
  }
  validationResult = r.MatchString(email)
  return validationResult
}

回想一下,因为validate包被战略性地放在shared文件夹中,所以该包应该是同构的(跨环境使用)。EmailSyntax函数的作用是确定输入字符串是否为有效的电子邮件地址。如果电子邮件地址有效,则函数将返回true,如果输入字符串不是有效的电子邮件地址,则函数将返回false

表单接口

同构 web 表单实现isokit包中的Form接口:

type Form interface {
 Validate() bool
 Fields() map[string]string
 Errors() map[string]string
 FormParams() *FormParams
 PrefillFields()
 SetFields(fields map[string]string)
 SetErrors(errors map[string]string)
 SetFormParams(formParams *FormParams)
 SetPrefillFields(prefillFields []string)
}

Validate方法确定表单是否已正确填写,如果表单已正确填写,则返回布尔值true,如果表单未正确填写,则返回布尔值false

Fields方法返回所有表单字段的map,其中键是表单字段的名称,值是表单字段的字符串值。

Errors方法包含表单验证时填充的所有错误的map。键是表单字段的名称,值是描述性错误消息。

FormParams方法返回表单的同构表单参数对象。form parameters 对象很重要,因为它确定了可以从中获取用户输入的表单字段值的来源。在服务器端,表单字段值从*http.Request获取,在客户端,表单字段从FormElement对象获取。

以下是[T0]结构的外观:

type FormParams struct {
  FormElement *dom.HTMLFormElement
  ResponseWriter http.ResponseWriter
  Request *http.Request
  UseFormFieldsForValidation bool
  FormFields map[string]string
}

PrefillFields方法返回所有表单字段名称的字符串片段,如果用户在提交表单时出错,应保留其值。

考虑的最后四种吸气剂方法FieldsErrorsFormParamsPrefillFields分别有相应的设置方法SetFieldsSetErrorsSetFormParamsSetPrefillFields

实现联系人表单

现在我们知道表单接口是什么样子了,让我们开始实现联系人表单。在我们的导入分组中,请注意,我们包括验证包和isokit包:

import (
  "github.com/EngineerKamesh/igb/igweb/shared/validate"
  "github.com/isomorphicgo/isokit"
)

回想一下,我们需要使用包中定义的EmailSyntax函数导入电子邮件地址验证功能的验证包。

实现我们前面介绍的Form接口所需的大部分功能都是由BasicForm类型提供的,也可以在isokit包中找到。我们将在ContactForm struct的类型定义中嵌入类型BasicForm

type ContactForm struct {
  isokit.BasicForm
}

通过这样做,实现Form接口的大部分功能都是免费提供给我们的。我们必须实现Validate方法,因为在BasicForm类型中找到的默认Validate方法实现将始终返回false

联系人表单的构造函数接受一个FormParams结构,并将返回一个指向新创建的ContactForm结构的指针:

func NewContactForm(formParams *isokit.FormParams) *ContactForm {
  prefillFields := []string{"firstName", "lastName", "email", "messageBody", "byDateInput"}
  fields := make(map[string]string)
  errors := make(map[string]string)
  c := &ContactForm{}
  c.SetPrefillFields(prefillFields)
  c.SetFields(fields)
  c.SetErrors(errors)
  c.SetFormParams(formParams)
  return c
}

我们在prefillFields变量中创建一个字符串片段,其中包含应该保留其值的字段的名称。我们为fields变量和errors变量创建map[string]string类型的实例。我们创建一个对新的ContactForm实例的引用,并将其分配给变量c。我们调用ContactForm实例的SetFields方法c,并传递 fields 变量。 我们调用SetFieldsSetErrors方法,并分别传入fieldserrors变量。我们调用cSetFormParams方法来设置表单参数,这些参数被传递到构造函数中。最后,我们返回新的ContactForm实例。

如前所述,BasicForm类型中的默认Validate方法实现将始终返回false。因为我们正在实施我们自己的定制表单,即联系表单,我们有责任定义什么是成功的验证,我们通过实施Validate方法来实现:

func (c *ContactForm) Validate() bool {
  c.RegenerateErrors()
  c.PopulateFields()

  // Check if first name was filled out
  if isokit.FormValue(c.FormParams(), "firstName") == "" {
    c.SetError("firstName", "The first name field is required.")
  }

  // Check if last name was filled out
  if isokit.FormValue(c.FormParams(), "lastName") == "" {
    c.SetError("lastName", "The last name field is required.")
  }

  // Check if message body was filled out
  if isokit.FormValue(c.FormParams(), "messageBody") == "" {
    c.SetError("messageBody", "The message area must be filled.")
  }

  // Check if e-mail address was filled out
  if isokit.FormValue(c.FormParams(), "email") == "" {
    c.SetError("email", "The e-mail address field is required.")
  } else if validate.EmailSyntax(isokit.FormValue(c.FormParams(), "email")) == false {
    // Check e-mail address syntax
    c.SetError("email", "The e-mail address entered has an improper syntax.")

  }

  if len(c.Errors()) > 0 {
    return false

  } else {
    return true
  }
}

我们首先调用RegenerateErrors方法来清除显示给用户的当前错误。此方法的功能仅适用于客户端应用。在客户端实现联系人表单功能时,我们将更详细地介绍此方法。

我们调用PopulateFields方法来填充ContactForm实例的字段map。如果用户在填写表单时出错,此方法负责预先填充用户已输入的值,以避免再次输入这些值以重新提交表单的麻烦。

此时,我们可以从表单验证开始。我们首先检查名字字段是否由用户填写。我们使用isokit包中的FormValue函数获取用户输入的表单字段值,表单字段名为firstName。我们传递给FormValue函数的第一个参数是 contact form 的 form parameters 对象,第二个值是我们希望获得其值的 form 字段的名称,在本例中,即名为"firstName"的 form 字段。通过检查用户输入的值是否为空字符串,我们可以确定用户是否已在字段中输入值。如果没有,我们调用SetError方法,传递表单字段的名称以及描述性错误消息。

我们执行完全相同的检查,以查看用户是否为姓氏字段、邮件正文和电子邮件地址填写了必要的值。如果他们没有填写这些字段中的任何一个,我们将调用SetError方法,提供字段名称和描述性错误消息。

对于电子邮件地址,如果用户为电子邮件表单字段输入了值,我们将对用户提供的电子邮件地址的语法进行额外检查。我们将用户输入的电子邮件值传递给 validate 包中的EmailSyntax函数。如果电子邮件的语法无效,我们将调用SetError方法,传入表单字段名"email",并发送一条描述性错误消息。

如前所述,Validate函数根据表单是否包含错误返回布尔值。我们使用 if 条件来确定错误计数是否大于零,如果大于零,则表示表单存在错误,并返回布尔值false。如果错误计数为零,控制流将到达 else 块,在那里我们返回一个布尔值true

现在我们已经添加了联系人表单,现在是实现服务器端路由处理程序的时候了。

登记联络路线

我们首先添加联系人表单页面和联系人确认页面的路由:

  r.Handle("/contact", handlers.ContactHandler(env)).Methods("GET", "POST")
  r.Handle("/contact-confirmation", handlers.ContactConfirmationHandler(env)).Methods("GET")

请注意,我们注册的/contact路由将由ContactHandler函数处理,它将使用GETPOST方法接受 HTTP 请求。首次访问联系人表单时,将通过GET请求发送至/contact路线。当用户提交联系人表单时,他们将向/contact路由发起POST请求。这解释了为什么此路由同时接受这两种 HTTP 方法。

成功填写联系表后,用户将被重定向至/contact-confirmation路线。这样做是为了避免在用户尝试刷新网页时出现重新提交表单错误,如果我们只是使用/contact路由本身打印表单确认消息。

联系人路由处理程序

ContactHandler负责在 IGWEB 上呈现联系人页面,联系人表单将驻留在其中:

func ContactHandler(env *common.Env) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

我们声明并初始化formParams变量为新初始化的FormParams实例,为ResponseWriterRequest字段提供值:

    formParams := isokit.FormParams{ResponseWriter: w, Request: r}

然后,我们通过调用NewContactForm函数并将引用传递给formParams结构,用新创建的ContactForm实例声明并初始化contactForm变量:

    contactForm := forms.NewContactForm(&formParams)

我们switch关于 HTTP 请求方式的类型:

    switch r.Method {

    case "GET":
      DisplayContactForm(env, contactForm)
    case "POST":
      validationResult := contactForm.Validate()
      if validationResult == true {
        submissions.ProcessContactForm(env, contactForm)
        DisplayConfirmation(env, w, r)
      } else {
        DisplayContactForm(env, contactForm)
      }
    default:
      DisplayContactForm(env, contactForm)
    }

  })
}

在 HTTP 请求方法为GET的情况下,调用DisplayContactForm函数,传入env对象和contactForm对象。DisplayContactForm功能将在联系人页面上呈现联系人表单。

在 HTTP 请求方法为POST的情况下,我们验证联系人表单。请记住,如果使用POST方法访问/contact路由,则表示用户已向该路由提交了联系表单。我们声明并初始化validationResult变量,将其设置为调用ContactForm对象contactFormValidate方法的结果值。

如果validationResult的值为真,则表单验证成功。我们在提交包中调用ProcessContactForm函数,传入env对象和ContactForm对象。ProcessContactForm功能负责处理成功提交的联系表。然后调用DisplayConfirmation函数,传入env对象、http.ResponseWriterw*http.Requestr

如果validationResult的值为false,则控制流进入else块,我们调用传入env对象和ContactForm对象的DisplayContactForm函数contactForm。这将再次呈现联系人表单,这一次,用户将看到与未填写或未正确填写的字段相关的错误消息。

在 HTTP 请求方法不是GETPOST的情况下,我们达到默认条件,只需调用DisplayContactForm函数即可显示联系人表单。

以下是DisplayContactForm函数:

func DisplayContactForm(env *common.Env, contactForm *forms.ContactForm) {
  templateData := &templatedata.Contact{PageTitle: "Contact", Form: contactForm}
  env.TemplateSet.Render("contact_page", &isokit.RenderParams{Writer: contactForm.FormParams().ResponseWriter, Data: templateData})
}

函数接受一个env对象和一个ContactForm对象作为输入参数。我们首先声明并初始化变量templateData,它将作为数据对象,我们将提供给contact_page模板。我们创建一个templatedata.Contact结构的新实例,并将其PageTitle字段填充到"Contact",将其Form字段填充到传递到函数中的ContactForm对象。

下面是来自templatedata包的Contact结构的样子:

type Contact struct {
  PageTitle string
  Form *forms.ContactForm
}

PageTitle字段表示网页的页面标题,Form字段表示ContactForm对象。

然后我们在env.TemplateSet对象上调用Render方法,并传入我们希望渲染的模板名称contact_page,以及同构模板渲染参数(RenderParams对象。我们已经为RenderParams对象的Writer字段分配了与ContactForm对象关联的ResponseWriter,并为Data字段分配了templateData变量。

以下是DisplayConfirmation函数:

func DisplayConfirmation(env *common.Env, w http.ResponseWriter, r *http.Request) {
  http.Redirect(w, r, "/contact-confirmation", 302)
}

此函数负责执行重定向到确认页面。在这个函数中,我们只需调用http包中可用的Redirect函数,并执行302状态重定向到/contact-confirmation路由。

现在我们已经介绍了联系人页面的路由处理程序,现在是时候看看联系人表单确认 web 页面的路由处理程序了。

联系人确认路由处理程序

ContactConfirmationHandler功能的唯一目的是呈现联系人确认页面:

func ContactConfirmationHandler(env *common.Env) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

    env.TemplateSet.Render("contact_confirmation_page", &isokit.RenderParams{Writer: w, Data: nil})
  })
}

我们调用TemplateSet对象的Render方法,并指定要呈现contact_confirmation_page模板以及传入的RenderParams结构。我们已经用http.ResponseWriter填充了结构的Writer字段,并为Data对象分配了一个nil值,以表示没有要传递给模板的数据对象。

处理联系人表单提交

成功完成联系人表单后,我们调用submission包中的ProcessContactForm函数。如果填写联系表的工作流程类似于打棒球,则调用ProcessContactForm功能可被视为到达本垒打并得分。正如我们将在后面的章节联系人表单 Rest API 端点中看到的,此函数也将由联系人表单的 Rest API 端点调用。现在我们已经确定了此函数的重要性,让我们继续检查它:

func ProcessContactForm(env *common.Env, form *forms.ContactForm) {

  log.Println("Successfully reached process content form function, indicating that the contact form was filled out properly resulting in a positive validation.")

  contactRequest := &models.ContactRequest{FirstName: form.GetFieldValue("firstName"), LastName: form.GetFieldValue("lastName"), Email: form.GetFieldValue("email"), Message: form.GetFieldValue("messageBody")}

  env.DB.CreateContactRequest(contactRequest)
}

我们首先打印一条日志消息,表明我们已成功完成该功能,表明用户已正确填写了联系表,并且用户输入的数据值得处理。然后,我们用新创建的ContactRequest实例声明并初始化contactRequest变量。

ContactRequest结构的目的是对从联系人表单收集的数据进行建模。以下是[T1]结构的外观:

type ContactRequest struct {
  FirstName string
  LastName string
  Email string
  Message string
}

如您所见,ContactRequest结构中的每个字段都对应于联系人表单中存在的表单字段。我们通过调用 contact form 对象上的GetFieldValue方法并提供表单字段的名称,使用联系人表单中相应的用户输入值填充ContactRequest结构中的每个字段。

如前所述,成功的联系人表单提交包括将联系人请求信息存储在 Redis 数据库中:

env.DB.CreateContactRequest(contactRequest)

我们调用自定义 Redis 数据存储对象的CreateContactRequest方法env.DB,并将ContactRequest对象contactRequest传递给该方法。此方法将联系人请求信息保存到 Redis 数据库中:

func (r *RedisDatastore) CreateContactRequest(contactRequest *models.ContactRequest) error {

  now := time.Now()
  nowFormatted := now.Format(time.RFC822Z)

  jsonData, err := json.Marshal(contactRequest)
  if err != nil {
    return err
  }

  if r.Cmd("SET", "contact-request|"+contactRequest.Email+"|"+nowFormatted, string(jsonData)).Err != nil {
    return errors.New("Failed to execute Redis SET command")
  }

  return nil

}

CreateContactRequest方法接受ContactRequest对象作为唯一的输入参数。我们使用 JSON 封送ContactRequest值并将其存储到 Redis 数据库中。如果 JSON 封送处理失败或保存到数据库失败,则返回错误对象。如果没有遇到错误,我们返回nil

无障碍联系方式

在这一点上,我们已经准备好了所有东西,可以采用试驾的联系方式。然而,与在基于 GUI 的 web 浏览器中打开联系人表单不同,我们首先要看看,对于使用 Lynx web 浏览器的视力受损用户来说,联系人表单的可访问性如何。

给人的第一印象是,我们正在使用一个 25 岁的纯文本网络浏览器测试联系人表单,这似乎有些奇怪。然而,Lynx 能够提供可刷新的盲文显示,以及文本到语音的功能,这使它成为视觉受损者值得称赞的网络浏览技术。因为 Lynx 不支持显示图像和运行 JavaScript,所以我们可以很好地了解联系人表单对于需要更大可访问性的用户的作用。

如果您在 Mac 电脑上使用自制软件,您可以像这样轻松安装 Lynx:

$ brew install lynx

如果您使用的是 Ubuntu,您可以通过发出以下命令来安装 Lynx:

$ sudo apt-get install lynx

如果您使用的是 Windows,您可以从以下网页下载 Lynx:[T0]http://lynx.invisible-island.net/lynx2.8.8/index.html

你可以在维基百科的上阅读更多关于 Lynx 网络浏览器的信息 https://en.wikipedia.org/wiki/Lynx_(网络浏览器)

随着igwebweb 服务器实例的运行,我们使用--nocolor选项启动 lynx,如下所示:

$ lynx --nocolor localhost:8080/contact

图 7.6显示了 Lynx web 浏览器中的联系人表单:

图 7.6:Lynx web 浏览器中的联系人表单

现在,我们将部分填写联系人表单,目的是测试表单验证逻辑是否有效。对于 email 字段,我们将提供一个格式不正确的 email 地址,如图 7.7所示:

图 7.7:未正确填写联系表

点击联系人按钮时,请注意,我们会收到与未正确填写的字段相关的错误消息,如图 7.8所示:

图 7.8:电子邮件地址字段和消息文本区域显示错误消息

还请注意,我们收到了错误消息,告诉我们电子邮件地址格式不正确。

图 7.9显示了我们更正所有错误后的联系方式:

图 7.9:正确填写联系表

提交更正后的联系方式后,我们会看到确认信息,告知我们已经成功填写了联系方式,如图 7.10所示:

图 7.10:确认页面

检查 Redis 数据库,使用 Redis cli 命令,我们可以验证我们是否收到表单提交,如图 7.11所示:

图 7.11:验证 Redis 数据库中新存储的联系人请求条目

在这一点上,我们可以满意地知道,我们已经使我们的联系方式可供视障用户使用,而这并不需要我们付出太多的努力。让我们看一下如何在一个基于 JavaScript 禁用的基于 GUI 的 Web 浏览器中查看联系表单。

联系人表单可以在没有 JavaScript 的情况下运行

在 Safari web 浏览器中,我们可以通过选择 Safari 的“开发”菜单中的“禁用 JavaScript”选项来禁用 JavaScript:

图 7.12:使用 Safari 的开发菜单禁用 JavaScript

图 7.13显示了基于图形用户界面GUI的 web 浏览器中的联系人表单:

图 7.13:基于 GUI 的 web 浏览器中的联系人表单

我们遵循在 Lynx web 浏览器上执行的相同测试策略。我们部分填写表格并提供无效的电子邮件地址,如图 7.14所示:

图 7.14:未正确填写联系表

点击联系人按钮后,出现问题的字段旁边会显示错误消息,如图 7.15所示:

图 7.15:错误消息显示在有问题的字段旁边

在提交联系人表单时,请注意,我们收到了与填写不正确的字段有关的错误。在更正错误后,我们现在可以点击联系人按钮再次提交表单,如图 7.16所示:

图 7.16:正确填写的联系表,准备重新提交

提交联系单后,我们被转发到/contact-confirmation路线,我们收到确认信息,确认联系单已正确填写,如图 7.17所示:

图 7.17:确认页面

即使启用了 JavaScript,我们实现的基于服务器端的联系人表单也将继续运行。您可能想知道为什么我们需要在客户端实现联系人表单?难道我们就不能只使用基于服务器端的联系人表单,就到此为止吗?

答案归结为为为用户提供增强的用户体验。通过单独使用服务器端联系人表单,我们打破了用户体验的单页应用体系结构。精明的读者会认识到,提交表单需要重新加载整个页面,如果出现错误,则需要重新提交表单。HTTP 重定向到/contact-confirmation路由也会破坏用户体验,因为它还会导致整个页面重新加载。

为了在客户端实施联系人表单,需要实现以下两个目标:

  • 提供一致、无缝的单页应用体验
  • 提供在客户端验证联系人表单的功能

第一个目标,即提供一致、无缝的单页应用体验,可以很容易地使用同构模板集将内容呈现到主内容区域div容器中,正如我们在前几章中所示。

第二个目标是能够在客户端验证联系人表单,因为 web 浏览器启用了 JavaScript。使用此功能,我们可以在客户端本身验证联系人表单。考虑一下场景,我们有一个用户,在填写联系人表单时总是出错。我们可以减少对 web 服务器进行的不必要的网络调用。只有在用户通过第一轮验证(在客户端)后,表单才会通过网络提交到 web 服务器,并在那里进行最后一轮验证(在服务器端)。

客户端注意事项

令人惊讶的是,我们不需要做太多的工作来让联系人表单在客户端运行。让我们逐节检查在client/handlers文件夹中找到的contact.go源文件:

func ContactHandler(env *common.Env) isokit.Handler {
  return isokit.HandlerFunc(func(ctx context.Context) {
    contactForm := forms.NewContactForm(nil)
    DisplayContactForm(env, contactForm)
  })
}

这是我们的ContactHandler功能,它将服务于客户端/contact路由的需要。我们首先声明并初始化contactForm变量,将其分配给ContactForm实例,该实例通过调用NewContactForm构造函数返回。

请注意,当我们通常应该传递一个FormParams结构时,我们将nil传递给构造函数。在客户端,我们将填充FormParams结构的FormElement字段,以将网页上的表单元素与contactForm对象相关联。然而,在呈现网页之前,我们遇到了一个鸡先于蛋的场景。我们无法填充FormParams结构的FormElement字段,因为网页上还不存在表单元素。因此,我们的第一个任务是呈现联系人表单,目前,我们将联系人表单的FormParams结构设置为nil以便执行此操作。稍后,我们将使用contactForm对象的SetFormParams方法设置contactForm对象的FormParams结构。

为了在网页上显示联系人表单,我们调用传入env对象和contactForm对象的DisplayContactForm函数contactForm。此功能有助于实现我们的第一个目标,即保持无缝的单页应用用户体验。以下是DisplayContactForm函数的外观:

func DisplayContactForm(env *common.Env, contactForm *forms.ContactForm) {
  templateData := &templatedata.Contact{PageTitle: "Contact", Form: contactForm}
  env.TemplateSet.Render("contact_content", &isokit.RenderParams{Data: templateData, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent, PageTitle: templateData.PageTitle})
  InitializeContactPage(env, contactForm)
}

我们声明并初始化templateData变量,它将是我们传递给模板的数据对象。templateData变量被分配给templatedata包中新创建的Contact实例,将PageTitle属性设置为"Contact",将Form属性设置为contactForm对象。

我们调用env.TemplateSet对象的Render方法,并指定要呈现"contact_content"模板。我们还将同构呈现参数(RenderParams提供给Render方法,将Data字段设置为等于templateData变量,并将Disposition字段设置为isokit.PlacementReplaceInnerContents,,这表明我们将如何呈现与关联元素相关的模板内容。通过将Element字段设置为env.PrimaryContent,我们指定主要内容div容器将是模板将呈现到的关联元素。最后,我们设置了PageTitle属性,当用户从客户端到达/contact路径时,动态更改网页的标题。

我们调用InitializeContactPage函数,提供env对象和contactForm对象。回想一下,InitializeContactPage函数负责为联系人页面设置与用户交互相关的代码(事件处理程序)。让我们检查一下InitializeContactPage函数:

func InitializeContactPage(env *common.Env, contactForm *forms.ContactForm) {

  formElement := env.Document.GetElementByID("contactForm").(*dom.HTMLFormElement)
  contactForm.SetFormParams(&isokit.FormParams{FormElement: formElement})
  contactButton := env.Document.GetElementByID("contactButton").(*dom.HTMLInputElement)
  contactButton.AddEventListener("click", false, func(event dom.Event) {
    handleContactButtonClickEvent(env, event, contactForm)
  })
}

我们调用env.Document对象上的GetElementByID方法来获取 contact form 元素并将其分配给变量formElement。我们调用SetFormParams方法,提供一个FormParams结构并用formElement变量填充其FormElement字段。此时,我们已经为contactForm对象设置了表单参数。我们通过调用env.Document对象上的GetElementByID方法并提供"contactButton"id来获取联系人表单的button元素。

我们在联系人button的点击事件上添加了一个事件监听器,该监听器将调用handleContactButtonClickEvent函数并传递env对象、event对象和contactForm对象。handleContactButtonClickEvent函数非常重要,因为它将在客户端运行表单验证,如果验证成功,它将启动对服务器端 Rest API 端点的 XHR 调用。以下是handleContactButtonClickEvent功能的代码:

func handleContactButtonClickEvent(env *common.Env, event dom.Event, contactForm *forms.ContactForm) {

  event.PreventDefault()
  clientSideValidationResult := contactForm.Validate()

  if clientSideValidationResult == true {

    contactFormErrorsChannel := make(chan map[string]string)
    go ContactFormSubmissionRequest(contactFormErrorsChannel, contactForm)

我们要做的第一件事是抑制单击联系人按钮的默认行为,这将提交整个 web 表单。此默认行为源于 contactbutton元素是类型为submitinput元素,其单击时的默认行为是提交 web 表单。

然后我们声明并初始化clientSideValidationResult,一个布尔变量,分配给调用contactForm对象上的Validate方法的结果。如果clientSideValidationResult的值为false,则到达else块,在该块中调用contactForm对象上的DisplayErrors方法。DisplayErrors方法是从isokit包装中的BasicForm类型提供给我们的。

如果clientSideValidationResult的值为 true,则表示表单在客户端正确验证。此时,联系人表单提交已经完成了客户端的第一轮验证。

为了开始第二轮(也是最后一轮)验证,我们需要在服务器端调用 RESTAPI 端点,该端点负责验证表单的内容并重新运行同一组验证。我们创建一个名为contactFormErrorsChannel的通道,这是我们将发送map[string]string值的通道。我们将ContactFormSubmissionRequest函数称为 goroutine,传入通道contactFormErrorsChannelcontactForm对象。ContactFormSubmissionRequest函数将启动对服务器端 Rest API 端点的 XHR 调用,以验证服务器端的联系人表单。错误的map将通过contactFormErrorsChannel发送。

让我们在返回到 Tyl T1 函数之前快速查看 AutoT0-函数:

func ContactFormSubmissionRequest(contactFormErrorsChannel chan map[string]string, contactForm *forms.ContactForm) {

  jsonData, err := json.Marshal(contactForm.Fields())
  if err != nil {
    println("Encountered error: ", err)
    return
  }

  data, err := xhr.Send("POST", "/restapi/contact-form", jsonData)
  if err != nil {
    println("Encountered error: ", err)
    return
  }

  var contactFormErrors map[string]string
  json.NewDecoder(strings.NewReader(string(data))).Decode(&contactFormErrors)

  contactFormErrorsChannel <- contactFormErrors
}

ContactFormSubmissionRequest函数中,我们使用 JSON 封送contactForm对象的字段,并通过调用xhr包中的Send函数向 web 服务器发出 XHR 调用。我们指定 XHR 调用将使用POSTHTTP 方法,并将发布到/restapi/contact-form端点。我们将联系人表单字段的 JSON 编码数据作为最终参数传递给Send函数。

如果 JSON 封送处理过程中没有错误,或者在进行 XHR 调用时没有错误,我们将从服务器获取数据,并尝试将其从 JSON 格式解码到contactFormErrors变量中。然后通过通道contactFormErrorsChannel发送contactFormErrors变量。

现在,让我们回到handleContactButtonClickEvent函数:

    go func() {

      serverContactFormErrors := <-contactFormErrorsChannel
      serverSideValidationResult := len(serverContactFormErrors) == 0

      if serverSideValidationResult == true {
        env.TemplateSet.Render("contact_confirmation_content", &isokit.RenderParams{Data: nil, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent})
      } else {
        contactForm.SetErrors(serverContactFormErrors)
        contactForm.DisplayErrors()
      }

    }()

  } else {
    contactForm.DisplayErrors()
  }
}

为了防止事件处理程序中出现阻塞,我们创建并运行一个匿名 goroutine 函数。我们从contactFormErrorsChannel接收serverContactFormErrors变量中的map错误。serverSideValidationResult布尔变量负责通过检查错误的长度map来确定联系人表单中是否存在错误。如果错误长度为零,则表示联系人表单提交中没有错误。如果长度大于零,则表示联系人表单提交中存在错误。

如果severSideValidationResult布尔变量的值为true,我们调用同构模板集上的Render方法来渲染contact_confirmation_content模板,并传入同构模板渲染参数。在RenderParams对象中,我们将Data字段设置为nil,因为我们不会向模板传递任何数据对象。我们为Disposition字段指定值isokit.PlacementReplaceInnerContents,以指示我们将对相关元素执行替换内部 HTML 操作。我们将Element字段设置为相关元素,即主要内容div容器,因为模板将在此处呈现。

如果serverSideValidationResult布尔变量的值为false,则表示该表单仍包含需要更正的错误。我们对传入serverContactFormErrors变量的contactForm对象调用SetErrors方法。然后我们调用contactForm对象上的DisplayErrors方法向用户显示错误。

我们就要完成了,在客户端实现联系人表单所剩下的唯一一项就是实现服务器端 RESTAPI 端点,该端点对联系人表单提交执行第二轮验证。

联系表单 RESTAPI 端点

igweb.go源文件中,我们已经注册了/restapi/contact-form端点及其关联的处理函数ContactFormEndpoint

r.Handle("/restapi/contact-form", endpoints.ContactFormEndpoint(env)).Methods("POST")

ContactFormEndpoint功能负责维护/restapi/contact-form端点:

func ContactFormEndpoint(env *common.Env) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

    var fields map[string]string

    reqBody, err := ioutil.ReadAll(r.Body)
    if err != nil {
      log.Print("Encountered error when attempting to read the request body: ", err)
    }

    err = json.Unmarshal(reqBody, &fields)
    if err != nil {
      log.Print("Encountered error when attempting to unmarshal json data: ", err)
    }

    formParams := isokit.FormParams{ResponseWriter: w, Request: r, UseFormFieldsForValidation: true, FormFields: fields}
    contactForm := forms.NewContactForm(&formParams)
    validationResult := contactForm.Validate()

    if validationResult == true {
      submissions.ProcessContactForm(env, contactForm)
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(contactForm.Errors())
  })
}

此函数的目的是提供联系人表单的服务器端验证,并返回 JSON 编码的错误map。我们创建了一个类型为map[string]string的变量fields,它表示联系人表单中的字段。我们阅读请求正文,其中将包含 JSON 编码的字段map。然后,我们将 JSON 编码的字段map解组到fields变量中。

我们创建一个新的FormParams实例并将其分配给变量formParams。在FormParams结构中,我们为ResponseWriter字段指定http.ResponseWriterw的值,为Request字段指定*http.Requestr的值。我们将[T9]字段设置为[T10]。这样做将更改从请求中获取特定字段的表单值的默认行为,而表单字段的值将从联系人表单的formFields``map中获取。最后,我们将FormFields字段设置为fields变量,map字段是我们从请求主体 JSON 解组的字段。

我们通过调用NewContactForm函数并传入对formParams对象的引用来创建一个新的contactForm对象。为了执行服务器端验证,我们只需在contactForm对象上调用Validate方法,并将方法调用的结果分配给validationResult变量。请记住,客户端上存在的相同验证代码也存在于服务器端,我们在这里实际上没有做什么特别的事情,只是从服务器端调用验证逻辑,在服务器端它可能无法被篡改。

如果validationResult的值为true,则表示联系人表单已经通过服务器端的第二轮表单验证,我们可以调用submissions包中的ProcessContactForm函数,传入env对象和contactForm对象。记住,在成功验证联系人表单时,调用ProcessContactForm功能意味着我们已经到达本垒打并得分。

如果validationResult的值是false,那么我们没有什么特别的事情要做。在调用对象的Validate方法后,contactForm对象的Errors字段将被填充。如果没有错误,Errors字段将只是一个空的map

我们向客户端发送一个头,以指示服务器将发送 JSON 对象响应。然后,我们将contactForm对象的错误map编码为其 JSON 表示,并使用http.ResponseWriterw将其写入客户端。

检查客户端验证

我们现在已经为联系人表单的客户端验证准备好了一切。让我们打开启用 JavaScript 的 web 浏览器。让我们打开 web inspector 来检查网络呼叫,如图 7.18 所示:

图 7.18:打开 web 检查器的联系表单

首先,我们将部分填写联系表,如图 7.19所示:

图 7.19:未正确填写联系表

点击联系人按钮,我们会在客户端触发表单验证错误,如图 7.20所示。请注意,在执行此操作时,无论我们单击联系人按钮多少次,都不会向服务器发出网络呼叫:

图 7.20:执行客户端验证后显示错误消息。请注意,没有对服务器进行网络调用

现在,让我们更正联系人表单中出现的错误(如图 7.21所示),并准备重新提交:

图 7.21:正确填写联系表并准备重新提交

重新提交表格后,我们收到确认信息,如图 7.22所示:

图 7.22:发出一个包含表单数据的 XHR 调用,并在服务器端表单验证成功后显示确认消息

请注意,已启动对 web 服务器的 XHR 调用,如图 7.23所示。查看调用的响应,我们可以看到端点响应返回的空对象({},表示errors``map为空,表示表单提交成功:

图 7.23:XHR 调用的响应为空错误映射,表明表单成功地清除了服务器端表单验证

既然我们已经验证了客户端验证逻辑在联系人表单上起作用,那么我们必须强调一个重要的点,这一点在从客户端接收数据时非常重要。在验证用户输入的数据时,服务器必须始终拥有否决权。在服务器端执行的第二轮验证应该是强制性步骤。让我们来看看为什么我们总是需要服务器端验证。

篡改客户端验证结果

让我们考虑一下这个场景,我们有一个邪恶的(聪明的)用户知道如何缩短客户端验证逻辑。它毕竟是 JavaScript,并且在 web 浏览器中运行。没有什么能阻止恶意用户将我们的客户端验证逻辑抛诸脑后。为了模拟这样的篡改事件,我们只需将布尔值true分配给contact.go源文件中的clientSideValidationResult变量,如下所示:

func handleContactButtonClickEvent(env *common.Env, event dom.Event, contactForm *forms.ContactForm) {

  event.PreventDefault()
  clientSideValidationResult := contactForm.Validate()

  clientSideValidationResult = true

  if clientSideValidationResult == true {

    contactFormErrorsChannel := make(chan map[string]string)
    go ContactFormSubmissionRequest(contactFormErrorsChannel, contactForm)

    go func() {

      serverContactFormErrors := <-contactFormErrorsChannel
      serverSideValidationResult := len(serverContactFormErrors) == 0

      if serverSideValidationResult == true {
        env.TemplateSet.Render("contact_confirmation_content", &isokit.RenderParams{Data: nil, Disposition: isokit.PlacementReplaceInnerContents, Element: env.PrimaryContent})
      } else {
        contactForm.SetErrors(serverContactFormErrors)
        contactForm.DisplayErrors()
      }

    }()

  } else {
    contactForm.DisplayErrors()
  }
}

此时,我们绕过了客户端验证的实际结果,并强制客户端 web 应用始终为客户端上执行的联系人表单验证亮起绿灯。如果我们只在客户端执行表单验证,这将使我们处于非常糟糕的情况。这正是我们需要在服务器端进行第二轮验证的原因。

我们打开 web 浏览器,重新填写部分表单,如图 7.24所示:

图 7.24:即使在禁用客户端表单验证之后,服务器端表单验证也可以防止提交填写不正确的联系人表单

注意,这一次点击 Contact 按钮时,XHR 调用启动到服务器端的 Rest API 端点,返回联系人表单中的错误map,如图 7.25所示:

图 7.25:服务器响应中的错误映射填充了一个错误,表明在电子邮件地址字段中输入的值具有不正确的语法

在服务器端执行的第二轮验证已经开始,它阻止了恶意用户到达本垒打并得分。如果客户端验证无法正常运行,服务器端验证将捕获不完整或格式不正确的表单字段。这是您应该始终为 web 表单实现服务器端表单验证的一个主要原因。

总结

在本章中,我们演示了构建可访问的同构 web 表单的过程。首先,我们在禁用 JavaScript 的场景和启用 JavaScript 的场景中演示了同构 web 表单的流程。

我们向您展示了如何创建同构 web 表单,它能够跨环境共享表单代码和验证逻辑。在表单包含错误的场景中,我们向您展示了如何以有意义的方式向用户显示错误。创建的同构 web 表单非常健壮,能够在 web 浏览器中禁用 JavaScript 或不存在 JavaScript 运行时(如 Lynx web 浏览器)的情况下以及在 web 浏览器中启用 JavaScript 的情况下运行。

我们演示了使用 LynxWeb 浏览器测试可访问的同构 web 表单,以验证该表单是否可供需要更大可访问性的用户使用。我们还验证了表单在配备 JavaScript 运行时的 web 浏览器中正常运行,即使禁用了 JavaScript。

在 web 浏览器中启用 JavaScript 的场景中,我们向您展示了如何在客户端验证表单,并在执行客户端验证后将数据提交给 Rest API 端点。即使在客户端验证表单的方便性和增强的能力下,我们也强调了始终在服务器端验证表单的重要性,通过演示服务器端表单验证开始的场景,即使在客户端验证结果被篡改的潜在场景中也是如此。

用户和联系人表单之间的交互相当简单。用户必须正确填写表单,才能将数据提交到服务器,最终在服务器上处理表单数据。在下一章中,我们将超越这种简单的交互,并考虑用户和 Web 应用以几乎类似会话的方式进行通信的场景。在第 8 章实时 Web 应用功能中,我们将实现 IGWEB 的实时聊天功能,允许网站用户与聊天机器人进行简单的问答对话。