Skip to content

Latest commit

 

History

History
1064 lines (789 loc) · 56.1 KB

File metadata and controls

1064 lines (789 loc) · 56.1 KB

三、实现个人资料图片的三种方式

到目前为止,我们的聊天应用程序已经使用了 OAuth2 协议,允许用户登录到我们的应用程序,以便我们知道谁在说什么。在本章中,我们将添加个人资料图片,使聊天体验更具吸引力。

我们将通过以下方式在应用程序中的消息旁边添加图片或化身:

  • 使用由认证服务器提供的化身图片
  • 使用Gravatar.comweb 服务通过用户的电子邮件地址查找图片
  • 允许用户上传自己的图片并自己托管

前两个选项允许我们通过身份验证服务或Gravatar.com将图片托管委托给第三方,这非常好,因为它降低了托管应用程序的成本(就存储成本和带宽而言,因为用户的浏览器实际上会从身份验证服务的服务器下载图片,而不是从我们的服务器下载)。第三种选择要求我们自己将图片托管在可通过 web 访问的位置。

这些选项并不是相互排斥的;您很可能会在实际生产应用程序中使用它们的一些组合。在本章末尾,我们将看到出现的灵活设计如何允许我们依次尝试每个实现,直到找到合适的化身。

在本章中,我们将灵活地进行设计,尽可能少地完成每个里程碑所需的工作。这意味着在每一部分的末尾,我们都会有可以在浏览器中演示的工作实现。这也意味着,我们将在需要的时候重构代码,并在进行决策时讨论决策背后的基本原理。

具体而言,在本章中,您将学习以下内容:

  • 即使在没有标准的情况下,从身份验证服务获取附加信息的良好做法是什么
  • 在适当的时候将抽象构建到我们的代码中
  • Go 的零初始化模式如何节省时间和内存
  • 重用接口使我们能够以与现有接口相同的方式处理集合和单个对象
  • 如何使用Gravatar.comweb 服务
  • 如何在 Go 中进行 MD5 哈希
  • 如何通过 HTTP 上传文件并将其存储在服务器上
  • 如何通过 Go web 服务器提供静态文件
  • 如何使用单元测试指导代码重构
  • 如何以及何时将功能从struct类型抽象到接口中

来自认证服务器的化身

事实证明,大多数身份验证服务器已经为其用户提供了图像,并且它们通过我们已经知道如何访问的受保护用户资源提供图像,以便获取我们用户的姓名。要使用这个化身图片,我们需要从提供者那里获取 URL,将其存储在用户的 cookie 中,并通过 web 套接字发送,这样每个客户端都可以将图片与相应的消息一起呈现。

获取头像 URL

用户或配置文件资源的模式不是 OAuth2 规范的一部分,这意味着每个提供者都负责决定如何表示该数据。事实上,提供商的做法有所不同,例如,GitHub 用户资源中的化身 URL 存储在名为avatar_url的字段中,而在谷歌中,相同的字段名为picture。Facebook 甚至更进一步,将头像 URL 值嵌套在名为picture的对象内的url字段中。幸运的是,Gomniauth 为我们抽象了这一点;它对提供者的GetUser调用使接口标准化,以获得公共字段。

为了使用化身 URL 字段,我们需要返回并将其信息存储在 cookie 中。在auth.go中,查看callback动作开关盒内部,更新创建authCookieValue对象的代码,如下所示:

authCookieValue := objx.New(map[string]interface{}{
  "name":       user.Name(),
 "avatar_url": user.AvatarURL(),
}).MustBase64()

前面代码中调用的AvatarURL方法将返回相应的 URL 值,然后将其存储在avatar_url字段中,该字段将存储在 cookie 中。

提示

Gomniauth 定义了一种User类型的接口,每个提供程序实现自己的版本。从身份验证服务器返回的通用map[string]interface{}数据存储在每个对象中,方法调用使用该提供程序的正确字段名访问适当的值。这种描述信息访问方式而不严格关注实现细节的方法是 Go 中接口的一个重要用途。

发送化身 URL

我们需要更新我们的message类型,这样它也可以携带头像 URL。在message.go中,添加AvatarURL字符串字段:

type message struct {
  Name      string
  Message   string
  When      time.Time
  AvatarURL string
}

到目前为止,我们还没有像为Name字段那样为AvatarURL赋值,因此我们必须更新client.go中的read方法:

func (c *client) read() {
  for {
    var msg *message
    if err := c.socket.ReadJSON(&msg); err == nil {
      msg.When = time.Now()
      msg.Name = c.userData["name"].(string)
      if avatarUrl, ok := c.userData["avatar_url"]; ok {
        msg.AvatarURL = avatarUrl.(string)
      }
      c.room.forward <- msg
    } else {
      break
    }
  }
  c.socket.Close()
}

我们所做的就是从userData字段中获取值,该字段表示我们在 cookie 中输入的内容,并将其分配给message中的相应字段(如果该值存在于地图中)。我们现在将执行额外的步骤来检查值是否存在,因为我们无法保证身份验证服务将为此字段提供值。因为它可能是nil,如果它真的丢失了,将它分配给string类型可能会引起恐慌。

将化身添加到用户界面

现在,我们的 JavaScript 客户端通过套接字获得了一个化身 URL 值,我们可以使用它来显示消息旁边的图像。我们通过更新chat.html中的socket.onmessage代码来实现:

socket.onmessage = function(e) {
  var msg = eval("("+e.data+")");
  messages.append(
    $("<li>").append(
      $("<img>").css({
        width:50,
        verticalAlign:"middle"
      }).attr("src", msg.AvatarURL),
      $("<strong>").text(msg.Name + ": "),
      $("<span>").text(msg.Message)
    )
  );
}

当我们收到一条消息时,我们将插入一个img标记,将源设置为消息中的AvatarURL字段。我们将使用 jQuery 的css方法强制设置50像素的宽度。这样可以防止大量图片破坏界面,并允许我们将图像与周围文本的中间对齐。

如果我们在使用以前的版本登录后构建并运行应用程序,您会发现不包含化身 URL 的authcookie 仍然存在。我们没有被要求再次登录(因为我们已经登录),添加avatar_url字段的代码永远不会运行。我们可以删除 cookie 并刷新页面,但无论何时在开发过程中进行更改,我们都必须继续这样做。让我们通过添加注销功能来正确解决这个问题。

注销

让用户注销的最简单方法是去掉authcookie 并将用户重定向到聊天页面,这将导致重定向到登录页面,因为我们刚刚删除了 cookie。为此,我们向main.go添加了一个新的HandleFunc调用:

http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
  http.SetCookie(w, &http.Cookie{
    Name:   "auth",
    Value:  "",
    Path:   "/",
    MaxAge: -1,
  })
  w.Header()["Location"] = []string{"/chat"}
  w.WriteHeader(http.StatusTemporaryRedirect)
})

前面的处理函数使用http.SetCookie将 cookie 设置MaxAge更新为-1,表示浏览器应立即删除。并非所有浏览器都被迫删除 cookie,这就是为什么我们还提供了一个空字符串的新Value设置,从而删除以前存储的用户数据。

提示

作为一项附加任务,您可以通过更新auth.goauthHandlerServeHTTP第一行来稍微防弹您的应用程序,使其能够处理空值情况以及缺少的 cookie 情况:

if cookie, err := r.Cookie("auth"); err == http.ErrNoCookie || cookie.Value == ""

我们没有忽略返回的r.Cookie,而是保留了一个对返回的 cookie 的引用(如果确实有),并且还添加了一个额外的检查,以查看 cookie 的Value字符串是否为空。

在继续之前,让我们添加一个Sign Out链接,以便更轻松地摆脱 cookie,并允许用户注销。在chat.html中,更新chatbox表单以插入指向新/logout处理程序的简单 HTML 链接:

<form id="chatbox">
  {{.UserData.name}}:<br/>
  <textarea></textarea>
  <input type="submit" value="Send" />
  or <a href="/logout">sign out</a>
</form>

现在构建并运行应用程序,打开localhost:8080/chat浏览器:

go buildo chat
./chathost=:8080

如果需要,请注销并重新登录。当你点击发送时,你会看到你的头像图片出现在你的信息旁边。

Logging out

让东西更漂亮

我们的应用程序看起来有点难看,是时候做点什么了。在上一章中,我们在登录页面中实现了 Bootstrap 库,现在我们将把它的使用扩展到聊天页面。我们将在chat.html中进行三项更改:包括引导和调整页面的 CSS 样式,更改表单的标记,以及调整页面上呈现消息的方式。

首先,让我们更新页面顶部的style标记,并在其上方插入link标记以包括引导:

<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
<style>
  ul#messages        { list-style: none; }
  ul#messages li     { margin-bottom: 2px; }
  ul#messages li img { margin-right: 10px; }
</style>

接下来,让我们用以下代码替换body标记顶部的标记(在script标记之前):

<div class="container">
  <div class="panel panel-default">
    <div class="panel-body">
      <ul id="messages"></ul>
    </div>
  </div>
  <form id="chatbox" role="form">
    <div class="form-group">
      <label for="message">Send a message as {{.UserData.name}}</label> or <a href="/logout">Sign out</a>
      <textarea id="message" class="form-control"></textarea>
    </div>
    <input type="submit" value="Send" class="btn btn-default" />
  </form>
</div>

该标记遵循引导标准,将适当的类应用于各种项目,例如,form-control类整齐地格式化了form中的元素(您可以查看引导文档以了解这些类的更多功能)。

最后,让我们更新我们的socket.onmessageJavaScript 代码,将发送者的名字作为图像的title属性。这使我们的应用程序在您将鼠标悬停在图像上时显示图像,而不是在每条消息旁边显示图像:

socket.onmessage = function(e) {
  var msg = eval("("+e.data+")");
  messages.append(
    $("<li>").append(
      $("<img>").attr("title", msg.Name).css({
        width:50,
        verticalAlign:"middle"
      }).attr("src", msg.AvatarURL),
      $("<span>").text(msg.Message)
    )
  );
}

构建并运行应用程序,刷新浏览器以查看是否出现新设计:

go buildo chat
./chathost=:8080

前面的命令显示以下输出:

Making things prettier

通过对代码进行相对较少的更改,我们极大地改善了应用程序的外观和感觉。

实施 Gravatar

Gravatar 是一项 web 服务,允许用户上传一张个人资料图片,并将其与电子邮件地址关联,以便从任何网站获得。和我们一样,开发人员只需在特定的 API 端点上执行GET操作,就可以为我们的应用程序访问这些映像。在本节中,我们将了解如何实现 Gravatar,而不是使用身份验证服务提供的图片。

抽象化身 URL 流程

由于在我们的应用程序中有三种不同的方式来获取化身 URL,因此我们已经达到了一个明智的地步,即学习如何抽象功能以干净地实现选项。抽象是指一个过程,在这个过程中,我们将某物的概念与其具体实现分离开来。http.Handler是一个很好的例子,说明如何使用处理程序及其输入输出,而不具体说明每个处理程序采取的操作。

在 Go 中,我们开始描述通过定义一个接口来获取化身 URL 的想法。让我们创建一个名为avatar.go的新文件,并插入以下代码:

package main
import (
  "errors"
)
// ErrNoAvatar is the error that is returned when the
// Avatar instance is unable to provide an avatar URL.
var ErrNoAvatarURL = errors.New("chat: Unable to get an avatar URL.")
// Avatar represents types capable of representing
// user profile pictures.
type Avatar interface {
  // GetAvatarURL gets the avatar URL for the specified client,
  // or returns an error if something goes wrong.
  // ErrNoAvatarURL is returned if the object is unable to get
  // a URL for the specified client.
  GetAvatarURL(c *client) (string, error)
}

Avatar接口描述了类型必须满足的GetAvatarURL方法,才能获取化身 URL。我们将客户端作为参数,以便知道返回 URL 的用户。该方法返回两个参数:一个字符串(如果一切顺利,它将是 URL)和一个错误,以防出错。

可能出现问题的一个原因是Avatar的一个具体实现无法获取 URL。在这种情况下,GetAvatarURL将返回ErrNoAvatarURL错误作为第二个参数。因此,ErrNoAvatarURL错误成为接口的一部分;这是该方法可能返回的结果之一,代码用户可能应该显式处理。我们在方法代码的注释部分提到了这一点,这是在 Go 中传达此类设计决策的唯一方法。

提示

由于错误是使用errors.New立即初始化并存储在ErrNoAvatarURL变量中的,因此只会创建其中一个对象;将错误指针作为返回传递是非常便宜的。这与 Java 的检查异常不同,检查异常具有类似的用途,即创建昂贵的异常对象并将其用作控制流的一部分。

认证服务与化身的实现

我们写入的Avatar的第一个实现将取代我们硬编码从身份验证服务获得的化身 URL 的现有功能。让我们使用一种测试驱动开发TDD)方法,这样我们就可以确保我们的代码在不需要手动测试的情况下工作。让我们在chat文件夹中创建一个名为avatar_test.go的新文件:

package main
import "testing"
func TestAuthAvatar(t *testing.T) {
  var authAvatar AuthAvatar
  client := new(client)
  url, err := authAvatar.GetAvatarURL(client)
  if err != ErrNoAvatarURL {
    t.Error("AuthAvatar.GetAvatarURL should return ErrNoAvatarURL when no value present")
  }
  // set a value
  testUrl := "http://url-to-gravatar/"
  client.userData = map[string]interface{}{"avatar_url": testUrl}
  url, err = authAvatar.GetAvatarURL(client)
  if err != nil {
    t.Error("AuthAvatar.GetAvatarURL should return no error when value present")
  } else {
    if url != testUrl {
      t.Error("AuthAvatar.GetAvatarURL should return correct URL")
    }
  }
}

此测试文件包含对我们截至目前不存在的AuthAvatar类型的GetAvatarURL方法的测试。首先,它使用没有用户数据的客户端,并确保返回ErrNoAvatarURL错误。在设置了一个合适的值之后,我们的测试这次再次调用该方法以断言它返回了正确的值。但是,构建此代码失败,因为AuthAvatar类型不存在,所以我们将在下一步声明authAvatar

在我们编写实现之前,值得注意的是,我们只将authAvatar变量声明为AuthAvatar类型,但从未实际为其赋值,因此其值保持为nil。这不是一个错误;我们实际上是在利用 Go 的零初始化(或默认初始化)功能。由于我们的对象不需要状态(我们将把client作为参数传递),因此不需要在初始化它的实例时浪费时间和内存。在 Go 中,可以对nil对象调用方法,前提是该方法不尝试访问字段。当我们真正开始编写我们的实现时,我们将考虑一种方法来确保这一点。

让我们回到avatar.go并通过测试。将以下代码添加到文件底部:

type AuthAvatar struct{}
var UseAuthAvatar AuthAvatar
func (_ AuthAvatar) GetAvatarURL(c *client) (string, error) {
  if url, ok := c.userData["avatar_url"]; ok {
    if urlStr, ok := url.(string); ok {
      return urlStr, nil
    }
  }
  return "", ErrNoAvatarURL
}

这里,我们将AuthAvatar类型定义为空结构,并定义GetAvatarURL方法的实现。我们还创建了一个名为UseAuthAvatar的方便变量,该变量具有AuthAvatar类型,但仍保留nil值。我们以后可以将UseAuthAvatar变量分配给任何寻找Avatar接口类型的字段。

通常,方法的接收者(名称前括号中定义的类型)将被分配给变量,以便可以在方法体中访问该变量。因为在我们的例子中,我们假设对象可以有nil值,所以我们可以使用下划线来告诉 Go 扔掉引用。这对我们自己来说是一个额外的提醒,我们应该避免使用它。

除此之外,我们的实现主体相对简单:我们正在安全地查找avatar_url的值,并在返回它之前确保它是一个字符串。如果在过程中出现任何故障,我们将返回接口中定义的ErrNoAvatarURL错误。

让我们打开一个终端,然后导航到chat文件夹并键入以下内容来运行测试:

go test

如果一切顺利,我们的测试将通过,我们将成功创建第一个Avatar实现。

使用一个实现

当我们使用一个实现时,我们可以直接引用 helper 变量,或者在需要功能时创建我们自己的接口实例。然而,这将破坏抽象的对象。相反,我们使用Avatar接口类型来指示我们需要该功能的位置。

对于我们的聊天应用程序,我们将有一个单一的方法来获得每个聊天室的化身 URL。因此,让我们更新room类型,以便它可以容纳Avatar对象。在room.go中,将以下字段定义添加到room struct类型中:

// avatar is how avatar information will be obtained.
avatar Avatar

更新newRoom函数,以便传入Avatar实现供使用;我们将在创建room实例时将此实现分配给新字段:

// newRoom makes a new room that is ready to go.
func newRoom(avatar Avatar) *room {
  return &room{
    forward: make(chan *message),
    join:    make(chan *client),
    leave:   make(chan *client),
    clients: make(map[*client]bool),
    tracer:  trace.Off(),
    avatar:  avatar,
  }
}

现在构建该项目将突出一个事实,即main.go中对newRoom的调用被中断,因为我们没有提供Avatar参数;让我们通过传递我们方便的UseAuthAvatar变量来更新它,如下所示:

r := newRoom(UseAuthAvatar)

我们不必创建AuthAvatar的实例,因此没有分配内存。在我们的例子中,这并不会带来巨大的节约(因为我们的整个应用程序只有一个房间),但是想象一下,如果我们的应用程序有数千个房间,那么潜在的节约会有多大。我们命名UseAuthAvatar变量的方式意味着前面的代码非常容易阅读,这也让我们的意图显而易见。

提示

在设计接口时,考虑代码可读性很重要。考虑一种方法,只要布尔值的输入是真的或假的,就隐藏了真正的意义,如果你不知道参数名。考虑在下面的简短示例中定义一对辅助常数:

func move(animated bool) { /* ... */ }
const Animate = true
const DontAnimate = false

思考以下哪一个对move的调用更容易理解:

move(true)
move(false)
move(Animate)
move(DontAnimate)

现在剩下的就是更改client以使用我们新的Avatar接口。在client.go中,更新read方法如下:

func (c *client) read() {
  for {
    var msg *message
    if err := c.socket.ReadJSON(&msg); err == nil {
      msg.When = time.Now()
      msg.Name = c.userData["name"].(string)
      msg.AvatarURL, _ = c.room.avatar.GetAvatarURL(c)
      c.room.forward <- msg
    } else {
      break
    }
  }
  c.socket.Close()
}

在这里,我们要求room上的avatar实例为我们获取化身 URL,而不是从userData中提取它。

当您构建并运行应用程序时,您会注意到(尽管我们对其进行了一些重构),行为和用户体验根本没有改变。这是因为我们告诉我们的房间使用AuthAvatar实现。

现在,让我们为房间添加另一个实现。

Gravatar 实施

Avitar中的Gravatar 实现将完成与AuthAvatar实现相同的工作,只是它将为Gravatar.com上托管的配置文件图片生成 URL。让我们先在avatar_test.go文件中添加一个测试:

func TestGravatarAvatar(t *testing.T) {
  var gravatarAvitar GravatarAvatar
  client := new(client)
  client.userData = map[string]interface{}{"email": "MyEmailAddress@example.com"}
  url, err := gravatarAvitar.GetAvatarURL(client)
  if err != nil {
    t.Error("GravatarAvitar.GetAvatarURL should not return an error")
  }
  if url != "//www.gravatar.com/avatar/0bc83cb571cd1c50ba6f3e8a78ef1346" {
    t.Errorf("GravatarAvitar.GetAvatarURL wrongly returned %s", url)
  }
}

Gravatar 使用电子邮件地址的散列为每个配置文件图片生成一个唯一的 ID,因此我们设置了一个客户端并确保userData包含一个电子邮件地址。接下来,我们调用相同的GetAvatarURL方法,但这次调用的对象是GravatarAvatar类型。然后我们断言返回了正确的 URL。我们已经知道这是指定电子邮件地址的适当 URL,因为它在 Gravatar 文档中作为一个示例列出—这是一个确保我们的代码实现其应有功能的伟大策略。

提示

回想一下,这本书的所有源代码都可以在 GitHub 上获得。您可以通过复制和粘贴中的比特和片段来节省构建前一个核心的时间 https://github.com/matryer/goblueprints 。硬编码的东西,如基本网址通常不是一个好主意;我们在整本书中都进行了硬编码,以使代码片段更容易阅读和更明显,但如果您愿意,欢迎您在阅读过程中提取它们。

运行这些测试(使用go test)显然会导致错误,因为我们还没有定义类型。让我们回到avatar.go并添加以下代码,同时确保导入io包:

type GravatarAvatar struct{}
var UseGravatar GravatarAvatar
func (_ GravatarAvatar) GetAvatarURL(c *client) (string, error) {
  if email, ok := c.userData["email"]; ok {
    if emailStr, ok := email.(string); ok {
      m := md5.New()
      io.WriteString(m, strings.ToLower(emailStr))
      return fmt.Sprintf("//www.gravatar.com/avatar/%x", m.Sum(nil)), nil
    }
  }
  return "", ErrNoAvatarURL
}

我们使用了与AuthAvatar相同的模式:我们有一个空结构、一个有用的UseGravatar变量和GetAvatarURL方法实现本身。在这种方法中,我们按照 Gravatar 的指导原则从电子邮件地址生成 MD5 哈希(在我们确保它是小写的之后),并将其附加到硬编码的基本 URL。

由于 Go 标准库的编写人员付出了艰苦的努力,在 Go 中实现哈希非常容易。crypto包有一系列令人印象深刻的加密和散列功能,这些功能都非常易于使用。在我们的例子中,我们创建了一个新的md5哈希器;因为散列程序实现了io.Writer接口,所以我们可以使用io.WriteString向其写入一个字节字符串。调用Sum返回写入字节的当前哈希值。

提示

您可能已经注意到,每次需要头像 URL 时,我们都会对电子邮件地址进行哈希运算。这是相当低效的,尤其是在规模上,但我们应该优先完成工作而不是优化。如果我们需要的话,我们可以随时回来改变工作方式。

现在运行测试表明我们的代码正在工作,但是我们还没有在authcookie 中包含电子邮件地址。我们通过定位分配给auth.goauthCookieValue对象的代码,并更新它以从 Gomniauth 获取Email值来实现这一点:

authCookieValue := objx.New(map[string]interface{}{
  "name":       user.Name(),
  "avatar_url": user.AvatarURL(),
  "email":      user.Email(),
}).MustBase64()

我们必须做的最后一件事是告诉房间使用 Gravatar 实现,而不是AuthAvatar实现。我们通过在main.go中调用newRoom并进行以下更改来实现此目的:

r := newRoom(UseGravatar)

再次构建并运行聊天程序,进入浏览器。请记住,因为我们已经更改了存储在 cookie 中的信息,所以我们必须注销并再次登录才能看到更改生效。

假设您的 Gravatar 帐户具有不同的映像,您将注意到系统现在正在从 Gravatar 而不是身份验证提供程序中提取映像。使用浏览器的检查器或调试工具将向您显示img标记的src属性确实已更改。

Gravatar implementation

如果您没有Gravatar 帐户,您可能会看到默认占位符图像代替您的个人资料图片。

上传化身图片

在上传图片的第三种也是最后一种方法中,我们将研究如何允许用户从本地硬盘上传一张图片,作为聊天时的个人资料图片。我们需要一种将文件与特定用户关联的方法,以确保将正确的图片与相应的消息关联起来。

用户识别

为了唯一地识别我们的用户,我们将复制 Gravatar 的方法,对他们的电子邮件地址进行散列,并使用得到的字符串作为标识符。我们将用户 ID 与其他用户特定数据一起存储在 cookie 中。这实际上还有一个额外的好处,就是消除了与连续散列相关的低效性。

auth.go中,将创建authCookieValue对象的代码替换为以下代码:

m := md5.New()
io.WriteString(m, strings.ToLower(user.Name()))
userId := fmt.Sprintf("%x", m.Sum(nil))
// save some data
authCookieValue := objx.New(map[string]interface{}{
  "userid":     userId,
  "name":       user.Name(),
  "avatar_url": user.AvatarURL(),
  "email":      user.Email(),
}).MustBase64()

这里我们对电子邮件地址进行了哈希运算,并将结果值存储在用户登录点的userid字段中。从今以后,我们可以在 Gravatar 代码中使用此值,而不是对每条消息的电子邮件地址进行哈希运算。为此,首先我们通过从avatar_test.go中删除以下行来更新测试:

client.userData = map[string]interface{}{"email": "MyEmailAddress@example.com"}

然后,我们将前一行替换为这一行:

client.userData = map[string]interface{}{"userid": "0bc83cb571cd1c50ba6f3e8a78ef1346"}

email字段不使用,不需要再设置;相反,我们只需要为新的userid字段设置一个适当的值。但是,如果您在终端中运行go test,您将看到此测试失败。

为了使测试通过,在avatar.go中更新GravatarAuth类型的GetAvatarURL方法:

func (_ GravatarAvatar) GetAvatarURL(c *client) (string, error) {
  if userid, ok := c.userData["userid"]; ok {
    if useridStr, ok := userid.(string); ok {
      return "//www.gravatar.com/avatar/" + useridStr, nil
    }
  }
  return "", ErrNoAvatarURL
}

这不会改变行为,但它允许我们进行意外的优化,这是一个很好的例子,说明了为什么不应该过早地优化代码早期发现的低效可能不会持续足够长的时间来保证修复它们所需的努力。

上传表单

如果我们的用户要上传一个文件作为他们的化身,他们需要一种方式来浏览他们的本地硬盘并将文件提交给服务器。我们通过添加一个新的模板驱动页面来实现这一点。在chat/templates文件夹中,创建一个名为upload.html的文件:

<html>
  <head>
    <title>Upload</title>
    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
  </head>
  <body>
    <div class="container">
      <div class="page-header">
        <h1>Upload picture</h1>
      </div>
      <form role="form" action="/uploader" enctype="multipart/form-data" method="post">
        <input type="hidden" name="userid" value="{{.UserData.userid}}" />
        <div class="form-group">
          <label for="message">Select file</label>
          <input type="file" name="avatarFile" />
        </div>
        <input type="submit" value="Upload" class="btn " />
      </form>
    </div>
  </body>
</html>

我们再次使用 Bootstrap 使我们的页面看起来很好,并且使它与其他页面相适应。然而,这里需要注意的关键点是 HTML 表单,它将提供上传文件所需的用户界面。操作指向/uploader,我们尚未实现该处理程序,enctype属性必须是multipart/form-data,这样浏览器就可以通过 HTTP 传输二进制数据。然后,有一个类型为fileinput元素,它将包含对我们想要上传的文件的引用。还请注意,我们已将UserData映射中的userid值作为隐藏输入,这将告诉我们哪个用户正在上载文件。name属性的正确性很重要,因为这是我们在服务器上实现处理程序时引用数据的方式。

现在让我们将新模板映射到main.go中的/upload路径:

http.Handle("/upload", &templateHandler{filename: "upload.html"})

处理上传

当用户选择文件后点击上传时,浏览器会将该文件的数据以及用户 ID 发送到/uploader,但目前,该数据实际上没有发送到任何地方。我们将实现一个新的HandlerFunc,它能够接收文件,读取通过连接传输的字节,并将其保存为服务器上的新文件。在chat文件夹中,让我们创建一个名为avatars的新文件夹-我们将在这里保存化身图像文件。

接下来,创建一个名为upload.go的新文件并插入以下代码,确保添加适当的包名和导入(即ioutilsnet/httpiopath

func uploaderHandler(w http.ResponseWriter, req *http.Request) {
  userId := req.FormValue("userid")
  file, header, err := req.FormFile("avatarFile")
  if err != nil {
    io.WriteString(w, err.Error())
    return
  }
  data, err := ioutil.ReadAll(file)
  if err != nil {
    io.WriteString(w, err.Error())
    return
  }
  filename := path.Join("avatars", userId+path.Ext(header.Filename))
  err = ioutil.WriteFile(filename, data, 0777)
  if err != nil {
    io.WriteString(w, err.Error())
    return
  }
  io.WriteString(w, "Successful")
}

这里,首先uploaderHandler使用http.Request上的FormValue方法来获取我们在 HTML 表单的隐藏输入中放置的用户 ID。然后它通过调用返回三个参数的req.FormFile,得到一个能够读取上传字节的io.Reader类型。第一个参数用multipart.File接口类型表示文件本身,它也是一个io.Reader。第二个是一个multipart.FileHeader对象,它包含关于文件的元数据,例如文件名。最后,第三个参数是一个错误,我们希望它有一个nil值。

我们说multipart.File接口类型也是io.Reader是什么意思?那么,快速浏览一下上的文档 http://golang.org/pkg/mime/multipart/#File 明确指出,该类型实际上只是一些其他更通用接口的包装器接口。这意味着multipart.File类型可以传递给需要io.Reader的方法,因为实现multipart.File的任何对象都必须实现io.Reader

提示

嵌入标准库接口来描述新概念是确保代码在尽可能多的上下文中工作的好方法。类似地,您应该尝试编写使用您能找到的最简单接口类型的代码,最好是从标准库中。例如,如果您编写了一个需要读取文件内容的方法,您可以要求用户提供一个类型为multipart.File的参数。但是,如果您要求使用io.Reader,代码将变得更加灵活,因为任何具有适当Read方法的类型都可以传入,其中也包括用户定义的类型。

ioutil.ReadAll方法将一直从指定的io.Reader读取数据,直到接收到所有字节,因此这就是我们实际从客户端接收字节流的地方。然后我们使用path.Joinpath.Ext使用userid构建一个新的文件名,并从multipart.FileHeader获取的原始文件名复制扩展名。

然后我们使用ioutil.WriteFile方法在avatars文件夹中创建一个新文件。我们在文件名中使用userid将图像与正确的用户相关联,这与 Gravatar 的方式非常相似。0777值指定我们创建的新文件具有完整的文件权限,如果您不确定应该设置哪些其他权限,这是一个很好的默认设置。

如果在任何阶段发生错误,我们的代码都会将其写入响应,这将帮助我们调试它,或者如果一切顺利,它会将成功写入

为了将这个新的处理函数映射到/uploader,我们需要返回main.go并将以下行添加到func main

http.HandleFunc("/uploader", uploaderHandler)

现在构建并运行应用程序,并记住注销并再次登录,以便让我们的代码有机会上传authcookie。

go build -o chat
./chat -host=:8080

打开http://localhost:8080/upload点击选择文件,然后从硬盘中选择一个文件,点击上传。导航到您的chat/avatars文件夹,您会注意到该文件确实已上载并重命名为您的userid字段的值。

为图像服务

现在我们有了一个地方可以在服务器上保存用户的头像图像,我们需要一种方法让浏览器可以访问它们。我们通过使用net/http包的内置文件服务器来实现这一点。在main.go中,添加以下代码:

http.Handle("/avatars/",
  http.StripPrefix("/avatars/",
    http.FileServer(http.Dir("./avatars"))))

这实际上是一行代码,为了提高可读性而被分解。http.Handle调用应该很熟悉:我们指定要用指定的处理程序映射/avatars/路径,这就是有趣的地方。http.StripPrefixhttp.FileServer都返回Handler,并且它们使用了我们在上一章中学习的装饰模式。StripPrefix函数接受Handler,通过删除指定前缀来修改路径,并将功能传递给内部处理程序。在我们的例子中,内部处理程序是一个http.FileServer处理程序,它只提供静态文件,提供索引列表,如果找不到文件,则生成404 Not Found错误。http.Dir函数允许我们指定要公开的文件夹。

如果我们没有从带有http.StripPrefix的请求中去掉/avatars/前缀,文件服务器将在实际的avatars文件夹中查找另一个名为avatars的文件夹,即/avatars/avatars/filename而不是/avatars/filename

在浏览器中打开http://localhost:8080/avatars/之前,让我们构建程序并运行它。您会注意到文件服务器已经生成了avatars文件夹中的文件列表。单击文件将下载该文件,如果是图像,则只需显示该文件。如果您还没有这样做,请转到http://localhost:8080/upload并上传一张图片,然后返回列表页面并单击它在浏览器中查看。

本地文件的化身实现

让文件系统化身工作的最后一步是编写Avatar接口的实现,该接口生成指向我们在上一节中创建的文件系统端点的 URL。

让我们在avatar_test.go文件中添加一个测试函数:

func TestFileSystemAvatar(t *testing.T) {

  // make a test avatar file
  filename := path.Join("avatars", "abc.jpg")
  ioutil.WriteFile(filename, []byte{}, 0777)
  defer func() { os.Remove(filename) }()

  var fileSystemAvatar FileSystemAvatar
  client := new(client)
  client.userData = map[string]interface{}{"userid": "abc"}
  url, err := fileSystemAvatar.GetAvatarURL(client)
  if err != nil {
    t.Error("FileSystemAvatar.GetAvatarURL should not return an error")
  }
  if url != "/avatars/abc.jpg" {
    t.Errorf("FileSystemAvatar.GetAvatarURL wrongly returned %s", url)
  }
}

此测试与GravatarAvatar测试类似,但比之稍微复杂一些,因为我们还在avatars文件夹中创建一个测试文件,然后将其删除。

提示

defer关键字是确保代码运行的好方法,无论函数的其余部分发生了什么。即使我们的测试代码陷入恐慌,延迟函数仍将被调用。

测试的其余部分很简单:我们在client.userData中设置一个userid字段并调用GetAvatarURL以确保返回正确的值。当然,运行这个测试会失败,所以我们来添加下面的代码,让它通过avatar.go

type FileSystemAvatar struct{}
var UseFileSystemAvatar FileSystemAvatar
func (_ FileSystemAvatar) GetAvatarURL(c *client) (string, error) {
  if userid, ok := c.userData["userid"]; ok {
    if useridStr, ok := userid.(string); ok {
      return "/avatars/" + useridStr + ".jpg", nil
    }
  }
  return "", ErrNoAvatarURL
}

正如我们在这里看到的,为了生成正确的 URL,我们只需获得userid值,并通过将适当的段添加在一起来构建最终的字符串。您可能已经注意到,我们已将文件扩展名硬编码为.jpg,这意味着聊天应用程序的初始版本将只支持 JPEG。

提示

仅支持 JPEG 可能看起来是一个半生不熟的解决方案,但遵循敏捷方法,这是非常好的;毕竟,自定义 JPEG 配置文件图片比根本没有自定义配置文件图片要好。

让我们通过更新main.go以使用新的Avatar实现来查看我们的新代码:

r := newRoom(UseFileSystemAvatar)

现在,像往常一样构建并运行应用程序,然后转到http://localhost:8080/upload并使用 web 表单上传 JPEG 图像,用作您的个人资料图片。要确保其正常工作,请选择一个唯一的图像,该图像不是您的 Gravatar 图像或来自身份验证服务的图像。点击上传看到成功消息后,进入http://localhost:8080/chat发布消息。您会注意到应用程序确实使用了您上载的配置文件图片。

要更改您的个人资料图片,请返回/upload页面并上载其他图片,然后跳回/chat页面并发布更多消息。

支持不同的文件类型

为了支持不同的文件类型,我们必须使FileSystemAvatar类型的GetAvatarURL方法更智能一些。

我们将使用非常有用的ioutil.ReadDir方法获取文件列表,而不是盲目地构建字符串。该列表还包括目录,因此我们将使用IsDir方法来确定是否应该跳过它。

然后,我们将通过调用path.Match来检查每个文件是否以userid字段开头(请记住,我们是以这种方式命名文件的)。如果文件名与userid字段匹配,则我们已找到该用户的文件,并返回路径。如果出现任何错误或者我们找不到文件,我们会像往常一样返回ErrNoAvatarURL错误。

用以下代码更新avatar.go中的适当方法:

func (_ FileSystemAvatar) GetAvatarURL(c *client) (string, error) {
  if userid, ok := c.userData["userid"]; ok {
    if useridStr, ok := userid.(string); ok {
      if files, err := ioutil.ReadDir("avatars"); err == nil {
        for _, file := range files {
          if file.IsDir() {
            continue
          }
          if match, _ := path.Match(useridStr+"*", file.Name()); match {
            return "/avatars/" + file.Name(), nil
          }
        }
      }
    }
  }
  return "", ErrNoAvatarURL
}

删除avatar文件夹中的所有文件,以防混淆并重建程序。这次上传一个不同类型的图像,注意我们的应用程序处理它没有困难。

重构和优化我们的代码

当回顾我们的Avatar类型是如何使用的时,您会注意到每次有人发送消息时,应用程序都会调用GetAvatarURL。在我们最新的实现中,每次调用该方法时,我们都会迭代avatars文件夹中的所有文件。对于一个特别健谈的用户来说,这可能意味着我们每分钟都要重复很多次。这显然是对资源的浪费,很快就会成为一个规模问题。

我们将在用户首次登录并将其缓存在authcookie 中时只获取一次,而不是获取每条消息的化身 URL。不幸的是,我们的Avatar接口类型要求我们将client对象传递给GetAvatarURL方法,而我们在验证用户时没有这样的对象。

提示

那么,我们在设计Avatar接口时是否犯了错误?虽然这是一个自然的结论,但事实上我们做了正确的事情。我们利用当时可用的最佳信息设计了解决方案,因此,与我们为未来可能出现的每一种情况进行设计相比,我们的聊天应用程序可以更快地工作。软件在开发过程中不断发展和变化,并且在代码的整个生命周期中肯定会发生变化。

用接口代替混凝土类型

我们已经得出结论,我们的GetAvatarURL方法取决于我们在需要时无法使用的类型,那么什么是好的替代方法呢?我们可以将每个必填字段作为单独的参数传递,但这会使接口变得脆弱,因为一旦Avatar实现需要新的信息,我们就必须更改方法签名。相反,我们将创建一个新类型,它将封装Avatar实现所需的信息,同时在概念上保持与特定案例的解耦。

auth.go中,将以下代码添加到页面顶部(当然在package关键字下面):

import gomniauthcommon "github.com/stretchr/gomniauth/common"
type ChatUser interface {
  UniqueID() string
  AvatarURL() string
}
type chatUser struct {
  gomniauthcommon.User
  uniqueID string
}
func (u chatUser) UniqueID() string {
  return u.uniqueID
}

在这里,import语句从 Gomniauth 导入common包,同时给它一个特定的名称,通过该名称可以访问它:gomniauthcommon。这不是完全必要的,因为我们没有包名冲突。但是,它使代码更容易理解。

在前面的代码片段中,我们还定义了一个名为ChatUser的新接口类型,它公开了我们的Avatar实现生成正确 URL 所需的信息。然后,我们定义了一个名为chatUser(注意小写起始字母)的实际实现来实现接口。它还利用了 Go 中一个非常有趣的特性:类型嵌入。我们实际上嵌入了接口类型gomniauth/common.User,这意味着我们的struct自动实现了接口。

您可能已经注意到,为了满足ChatUser接口,我们实际上只实现了两个必需方法中的一个。我们侥幸成功,因为 GomniauthUser接口恰好定义了相同的AvatarURL方法。实际上,当我们实例化我们的chatUser结构时,只要我们为隐含的 GomniauthUser字段设置了适当的值,我们的对象就会同时实现 Gomniauth 的User接口和我们自己的ChatUser接口。

以测试驱动的方式更改接口

在可以使用我们的新类型之前,我们必须更新Avatar接口和适当的实现来使用它。正如我们将遵循 TDD 实践一样,我们将在测试文件中进行这些更改,在尝试构建代码时查看编译器错误,并在最终通过测试之前修复这些错误后查看失败的测试。

打开avatar_test.go并用以下代码替换TestAuthAvatar

func TestAuthAvatar(t *testing.T) {
  var authAvatar AuthAvatar
  testUser := &gomniauthtest.TestUser{}
  testUser.On("AvatarURL").Return("", ErrNoAvatarURL)
  testChatUser := &chatUser{User: testUser}
  url, err := authAvatar.GetAvatarURL(testChatUser)
  if err != ErrNoAvatarURL {
    t.Error("AuthAvatar.GetAvatarURL should return ErrNoAvatarURL when no value present")
  }
  testUrl := "http://url-to-gravatar/"
  testUser = &gomniauthtest.TestUser{}
  testChatUser.User = testUser
  testUser.On("AvatarURL").Return(testUrl, nil)
  url, err = authAvatar.GetAvatarURL(testChatUser)
  if err != nil {
    t.Error("AuthAvatar.GetAvatarURL should return no error when value present")
  } else {
    if url != testUrl {
      t.Error("AuthAvatar.GetAvatarURL should return correct URL")
    }
  }
}

提示

您还需要像上一节一样导入gomniauth/testgomniauthtest

在我们定义之前使用新的接口是检查我们思维是否健全的一个好方法,这是练习 TDD 的另一个优势。在这个新的测试中,我们创建了 Gomniauth 提供的TestUser并将其嵌入到chatUser类型中。然后,我们将新的chatUser类型传递到我们的GetAvatarURL调用中,并像往常一样对输出做出相同的断言。

提示

Gomniauth 的TestUser类型很有趣,因为它利用了Testify包的模拟功能。参见https://github.com/stretchr/testify 了解更多信息。

OnReturn方法允许我们告诉TestUser在调用特定方法时要做什么。在第一种情况下,我们告诉AvatarURL方法返回错误,在第二种情况下,我们要求它返回testUrl值,这模拟了我们在本测试中讨论的两种可能结果。

更新TestGravatarAvatarTestFileSystemAvatar测试要简单得多,因为它们只依赖UniqueID方法,我们可以直接控制其值。

用以下代码替换avatar_test.go中的其他两项测试:

func TestGravatarAvatar(t *testing.T) {
  var gravatarAvitar GravatarAvatar
  user := &chatUser{uniqueID: "abc"}
  url, err := gravatarAvitar.GetAvatarURL(user)
  if err != nil {
    t.Error("GravatarAvitar.GetAvatarURL should not return an error")
  }
  if url != "//www.gravatar.com/avatar/abc" {
    t.Errorf("GravatarAvitar.GetAvatarURL wrongly returned %s", url)
  }
}
func TestFileSystemAvatar(t *testing.T) {
  // make a test avatar file
  filename := path.Join("avatars", "abc.jpg")
  ioutil.WriteFile(filename, []byte{}, 0777)
  defer func() { os.Remove(filename) }()
  var fileSystemAvatar FileSystemAvatar
  user := &chatUser{uniqueID: "abc"}
  url, err := fileSystemAvatar.GetAvatarURL(user)
  if err != nil {
    t.Error("FileSystemAvatar.GetAvatarURL should not return an error")
  }
  if url != "/avatars/abc.jpg" {
    t.Errorf("FileSystemAvatar.GetAvatarURL wrongly returned %s", url)
  }
}

当然,这个测试代码甚至不会编译,因为我们还没有更新Avatar接口。在avatar.go中,将Avatar接口类型中的GetAvatarURL签名更新为ChatUser类型而不是client类型:

GetAvatarURL(ChatUser) (string, error)

提示

请注意,我们使用的是ChatUser接口(大写起始字母),而不是我们内部的chatUser实现结构。毕竟,我们希望灵活处理GetAvatarURL方法接受的类型。

尝试构建它会发现我们现在已经破坏了实现,因为所有的GetAvatarURL方法仍然要求一个client对象。

修复现有实现

更改我们现有的接口是自动查找代码中受影响部分的好方法,因为它们会导致编译器错误。当然,如果我们正在编写一个其他人会使用的包,我们必须对更改接口更加严格。

我们现在将更新三个实现签名以满足新接口,并更改方法体以使用新类型。将FileSystemAvatar的实现替换为以下内容:

func (_ FileSystemAvatar) GetAvatarURL(u ChatUser) (string, error) {
  if files, err := ioutil.ReadDir("avatars"); err == nil {
    for _, file := range files {
      if file.IsDir() {
        continue
      }
      if match, _ := path.Match(u.UniqueID()+"*", file.Name()); match {
        return "/avatars/" + file.Name(), nil
      }
    }
  }
  return "", ErrNoAvatarURL
}

这里的关键变化是我们不再访问客户端上的userData字段,而是直接在ChatUser接口上调用UniqueID

接下来,我们用以下代码更新AuthAvatar实现:

func (_ AuthAvatar) GetAvatarURL(u ChatUser) (string, error) {
  url := u.AvatarURL()
  if len(url) > 0 {
    return url, nil
  }
  return "", ErrNoAvatarURL
}

事实证明,我们的新设计要简单得多;如果我们能减少所需的代码量,这总是一件好事。前面的代码调用获取AvatarURL值,如果它不是空的(或len(url) > 0,我们将返回它;否则,我们将返回ErrNoAvatarURL错误。

最后,更新GravatarAvatar实现:

func (_ GravatarAvatar) GetAvatarURL(u ChatUser) (string, error) {
  return "//www.gravatar.com/avatar/" + u.UniqueID(), nil
}

全局变量与字段

到目前为止,我们已经将Avatar实现分配给room类型,这使我们能够为不同的房间使用不同的化身。然而,这暴露了一个问题:当我们的用户登录时,不知道他们要去哪个房间,因此我们无法知道使用哪个Avatar实现。因为我们的应用程序只支持一个房间,所以我们将研究另一种选择实现的方法:使用全局变量。

全局变量只是一个在任何类型定义之外定义的变量,可以从包的每个部分访问(如果导出,也可以从包的外部访问)。对于一个简单的配置,例如使用哪种类型的Avatar实现,它们是一个简单易行的解决方案。在main.go中的import语句下方,添加以下行:

// set the active Avatar implementation
var avatars Avatar = UseFileSystemAvatar

这将avatars定义为一个全局变量,我们可以在需要获取特定用户的化身 URL 时使用它。

实施我们的新设计

我们需要更改为每条消息调用GetAvatarURL的代码,以便只访问我们放入userData缓存中的值(通过authcookie)。更改分配了msg.AvatarURL的行,如下所示:

if avatarUrl, ok := c.userData["avatar_url"]; ok {
  msg.AvatarURL = avatarUrl.(string)
}

auth.go中我们调用provider.GetUser的地方找到loginHandler中的代码,并将其替换到我们用以下代码设置authCookieValue对象的地方:

user, err := provider.GetUser(creds)
if err != nil {
  log.Fatalln("Error when trying to get user from", provider, "-", err)
}
chatUser := &chatUser{User: user}
m := md5.New()
io.WriteString(m, strings.ToLower(user.Name()))
chatUser.uniqueID = fmt.Sprintf("%x", m.Sum(nil))
avatarURL, err := avatars.GetAvatarURL(chatUser)
if err != nil {
  log.Fatalln("Error when trying to GetAvatarURL", "-", err)
}

在这里,我们创建了一个新的chatUser变量,同时将User字段(表示嵌入式接口)设置为从 Gomniauth 返回的User值。然后我们将useridMD5 散列保存到uniqueID字段。

调用avatars.GetAvatarURL是我们所有努力的回报,因为我们现在在这个过程的早期就为用户获得了化身 URL。更新auth.go中的authCookieValue行以缓存 cookie 中的化身 URL,并删除电子邮件地址,因为不再需要它:

authCookieValue := objx.New(map[string]interface{}{
  "userid":     chatUser.uniqueID,
  "name":       user.Name(),
  "avatar_url": avatarURL,
}).MustBase64()

无论Avatar实现需要做多么昂贵的工作,比如迭代文件系统上的文件,但由于该实现只在用户首次登录时才这样做,而不是每次用户发送消息时都这样做,这一点减轻了它的负担。

整理测试

最后,我们要剪掉重构过程中积累的一些脂肪。

由于不再将Avatar实现存储在room中,所以让我们从类型中删除该字段及其所有引用。在room.go中,从room结构中删除avatar Avatar定义,更新newRoom方法:

func newRoom() *room {
  return &room{
    forward: make(chan *message),
    join:    make(chan *client),
    leave:   make(chan *client),
    clients: make(map[*client]bool),
    tracer:  trace.Off(),
  }
}

提示

请记住尽可能使用编译器作为您的待办事项列表,并根据错误查找影响其他代码的地方。

main.go中,删除传递到newRoom函数调用中的参数,因为我们使用的是全局变量而不是此变量。

此练习后,最终用户体验保持不变。通常,在重构代码时,修改的是内部构件,而面向公众的接口保持稳定和不变。

提示

通常最好对代码运行golintgo vet等工具,以确保代码遵循良好的实践,并且不包含任何错误,例如缺少注释或名称不正确的函数。

结合所有三种实现

为了圆满结束本章,我们将实现一种机制,其中每个Avatar实现轮流尝试获取值。如果第一个实现返回ErrNoAvatarURL错误,我们将尝试下一个,以此类推,直到找到可用的值。

avatar.go中,在Avatar类型下添加以下类型定义:

type TryAvatars []Avatar

TryAvatars类型只是Avatar对象的一片;因此,我们将增加以下GetAvatarURL方法:

func (a TryAvatars) GetAvatarURL(u ChatUser) (string, error) {
  for _, avatar := range a {
    if url, err := avatar.GetAvatarURL(u); err == nil {
      return url, nil
    }
  }
  return "", ErrNoAvatarURL
}

这意味着TryAvatars现在是一个有效的Avatar实现,可以用来代替任何特定的实现。在前面的方法中,我们按顺序迭代了Avatar对象的切片,为每个对象调用GetAvatarURL。如果没有返回错误,则返回 URL;否则,我们继续寻找。最后,如果我们找不到一个值,我们只需按照接口设计返回ErrNoAvatarURL

更新main.go中的avatars全局变量以使用我们的新实现:

var avatars Avatar = TryAvatars{
  UseFileSystemAvatar,
  UseAuthAvatar,
  UseGravatar}

在这里,我们创建了一个TryAvatars切片类型的新实例,同时将其他Avatar实现放入其中。顺序很重要,因为它以对象在切片中出现的顺序在对象上迭代。所以,首先我们的代码会检查用户是否上传了图片;如果没有,代码将检查身份验证服务是否有图片供我们使用。如果两种方法都失败,将生成一个 Gravatar URL,在最坏的情况下(例如,如果用户没有添加 Gravatar 图片),该 URL 将呈现一个默认占位符图像。

要查看新功能的运行情况,请执行以下步骤:

  1. 构建并重新运行应用程序:

    go buildo chat
    ./chathost=:8080
  2. 通过访问http://localhost:8080/logout注销。

  3. avatars文件夹中删除所有图片。

  4. 导航至http://localhost:8080/chat重新登录。

  5. 发送一些信息并记下您的个人资料图片。

  6. 访问http://localhost:8080/upload并上传新的个人资料图片。

  7. 再次注销,然后像以前一样重新登录。

  8. 发送更多消息,注意您的个人资料图片已更新。

总结

在本章中,我们向聊天应用程序添加了三种不同的配置文件图片实现。首先,我们要求身份验证服务提供一个 URL 供我们使用。我们通过使用 Gomniauth 对用户资源数据的抽象实现了这一点,然后每次用户发送消息时,我们都将其作为用户界面的一部分包含进来。使用 Go 的零(或默认)初始化模式,我们能够参考Avatar接口的不同实现,而不需要实际创建任何实例。

我们将数据存储在 cookie 中,以便用户登录。因此,同时考虑到 cookie 在代码构建之间存在的事实,我们添加了一个方便的注销功能来帮助我们验证我们的更改,我们还向用户公开了这些更改,以便他们也可以注销。代码的其他小改动以及聊天页面上的引导功能极大地改善了应用程序的外观。

我们在 Go 中使用 MD5 哈希,通过哈希验证服务提供的电子邮件地址来实现Gravatar.comAPI。如果 Gravatar 不知道电子邮件地址,他们将为我们提供一个很好的默认占位符图像,这意味着我们的用户界面永远不会因为缺少图像而中断。

然后,我们构建并完成了一个上传表单,并关联了将上传的图片保存在avatars文件夹中的服务器功能。我们看到了如何通过标准库的http.FileServer处理程序将保存的上传图片公开给用户。由于这会导致过多的文件系统访问,从而导致我们的设计效率低下,因此我们在单元测试的帮助下重构了解决方案。通过将GetAvatarURL调用移动到用户登录点,而不是每次发送消息时,我们使代码的可伸缩性大大提高。

我们的特殊ErrNoAvatarURL错误类型被用作接口设计的一部分,以允许我们在无法获得适当 URL 时通知调用代码。这在我们创建Avatars切片类型时变得特别有用。通过在一片Avatar类型上实现Avatar接口,我们能够实现一个新的实现,轮流尝试从每个可用的不同选项中获取有效的 URL,从文件系统开始,然后是身份验证服务,最后是 Gravatar。我们实现了这一点,对用户如何与界面交互没有任何影响。如果某个实现返回ErrNoAvatarURL,我们将尝试下一个。

我们的聊天应用程序已准备好上线,因此我们可以邀请朋友进行真正的对话。但是首先我们需要选择一个域名来承载它,这一点我们将在下一章中讨论。