到目前为止,我们的聊天应用程序已经使用了 OAuth2 协议,允许用户登录到我们的应用程序,以便我们知道谁在说什么。在本章中,我们将添加个人资料图片,使聊天体验更具吸引力。
我们将通过以下方式在应用程序中的消息旁边添加图片或化身:
- 使用由认证服务器提供的化身图片
- 使用Gravatar.comweb 服务通过用户的电子邮件地址查找图片
- 允许用户上传自己的图片并自己托管
前两个选项允许我们通过身份验证服务或Gravatar.com将图片托管委托给第三方,这非常好,因为它降低了托管应用程序的成本(就存储成本和带宽而言,因为用户的浏览器实际上会从身份验证服务的服务器下载图片,而不是从我们的服务器下载)。第三种选择要求我们自己将图片托管在可通过 web 访问的位置。
这些选项并不是相互排斥的;您很可能会在实际生产应用程序中使用它们的一些组合。在本章末尾,我们将看到出现的灵活设计如何允许我们依次尝试每个实现,直到找到合适的化身。
在本章中,我们将灵活地进行设计,尽可能少地完成每个里程碑所需的工作。这意味着在每一部分的末尾,我们都会有可以在浏览器中演示的工作实现。这也意味着,我们将在需要的时候重构代码,并在进行决策时讨论决策背后的基本原理。
具体而言,在本章中,您将学习以下内容:
- 即使在没有标准的情况下,从身份验证服务获取附加信息的良好做法是什么
- 在适当的时候将抽象构建到我们的代码中
- Go 的零初始化模式如何节省时间和内存
- 重用接口使我们能够以与现有接口相同的方式处理集合和单个对象
- 如何使用Gravatar.comweb 服务
- 如何在 Go 中进行 MD5 哈希
- 如何通过 HTTP 上传文件并将其存储在服务器上
- 如何通过 Go web 服务器提供静态文件
- 如何使用单元测试指导代码重构
- 如何以及何时将功能从
struct
类型抽象到接口中
事实证明,大多数身份验证服务器已经为其用户提供了图像,并且它们通过我们已经知道如何访问的受保护用户资源提供图像,以便获取我们用户的姓名。要使用这个化身图片,我们需要从提供者那里获取 URL,将其存储在用户的 cookie 中,并通过 web 套接字发送,这样每个客户端都可以将图片与相应的消息一起呈现。
用户或配置文件资源的模式不是 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 中接口的一个重要用途。
我们需要更新我们的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 的auth
cookie 仍然存在。我们没有被要求再次登录(因为我们已经登录),添加avatar_url
字段的代码永远不会运行。我们可以删除 cookie 并刷新页面,但无论何时在开发过程中进行更改,我们都必须继续这样做。让我们通过添加注销功能来正确解决这个问题。
让用户注销的最简单方法是去掉auth
cookie 并将用户重定向到聊天页面,这将导致重定向到登录页面,因为我们刚刚删除了 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.go
中authHandler
的ServeHTTP
第一行来稍微防弹您的应用程序,使其能够处理空值情况以及缺少的 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 build –o chat
./chat –host=:8080
如果需要,请注销并重新登录。当你点击发送时,你会看到你的头像图片出现在你的信息旁边。
我们的应用程序看起来有点难看,是时候做点什么了。在上一章中,我们在登录页面中实现了 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.onmessage
JavaScript 代码,将发送者的名字作为图像的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 build –o chat
./chat –host=:8080
前面的命令显示以下输出:
通过对代码进行相对较少的更改,我们极大地改善了应用程序的外观和感觉。
Gravatar 是一项 web 服务,允许用户上传一张个人资料图片,并将其与电子邮件地址关联,以便从任何网站获得。和我们一样,开发人员只需在特定的 API 端点上执行GET
操作,就可以为我们的应用程序访问这些映像。在本节中,我们将了解如何实现 Gravatar,而不是使用身份验证服务提供的图片。
由于在我们的应用程序中有三种不同的方式来获取化身 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
实现。
现在,让我们为房间添加另一个实现。
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 时,我们都会对电子邮件地址进行哈希运算。这是相当低效的,尤其是在规模上,但我们应该优先完成工作而不是优化。如果我们需要的话,我们可以随时回来改变工作方式。
现在运行测试表明我们的代码正在工作,但是我们还没有在auth
cookie 中包含电子邮件地址。我们通过定位分配给auth.go
中authCookieValue
对象的代码,并更新它以从 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 帐户,您可能会看到默认占位符图像代替您的个人资料图片。
在上传图片的第三种也是最后一种方法中,我们将研究如何允许用户从本地硬盘上传一张图片,作为聊天时的个人资料图片。我们需要一种将文件与特定用户关联的方法,以确保将正确的图片与相应的消息关联起来。
为了唯一地识别我们的用户,我们将复制 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 传输二进制数据。然后,有一个类型为file
的input
元素,它将包含对我们想要上传的文件的引用。还请注意,我们已将UserData
映射中的userid
值作为隐藏输入,这将告诉我们哪个用户正在上载文件。name
属性的正确性很重要,因为这是我们在服务器上实现处理程序时引用数据的方式。
现在让我们将新模板映射到main.go
中的/upload
路径:
http.Handle("/upload", &templateHandler{filename: "upload.html"})
当用户选择文件后点击上传时,浏览器会将该文件的数据以及用户 ID 发送到/uploader
,但目前,该数据实际上没有发送到任何地方。我们将实现一个新的HandlerFunc
,它能够接收文件,读取通过连接传输的字节,并将其保存为服务器上的新文件。在chat
文件夹中,让我们创建一个名为avatars
的新文件夹-我们将在这里保存化身图像文件。
接下来,创建一个名为upload.go
的新文件并插入以下代码,确保添加适当的包名和导入(即ioutils
、net/http
、io
和path
:
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.Join
和path.Ext
使用userid
构建一个新的文件名,并从multipart.FileHeader
获取的原始文件名复制扩展名。
然后我们使用ioutil.WriteFile
方法在avatars
文件夹中创建一个新文件。我们在文件名中使用userid
将图像与正确的用户相关联,这与 Gravatar 的方式非常相似。0777
值指定我们创建的新文件具有完整的文件权限,如果您不确定应该设置哪些其他权限,这是一个很好的默认设置。
如果在任何阶段发生错误,我们的代码都会将其写入响应,这将帮助我们调试它,或者如果一切顺利,它会将成功写入。
为了将这个新的处理函数映射到/uploader
,我们需要返回main.go
并将以下行添加到func main
:
http.HandleFunc("/uploader", uploaderHandler)
现在构建并运行应用程序,并记住注销并再次登录,以便让我们的代码有机会上传auth
cookie。
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.StripPrefix
和http.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
文件夹中的所有文件。对于一个特别健谈的用户来说,这可能意味着我们每分钟都要重复很多次。这显然是对资源的浪费,很快就会成为一个规模问题。
我们将在用户首次登录并将其缓存在auth
cookie 中时只获取一次,而不是获取每条消息的化身 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/test
包gomniauthtest
。
在我们定义之前使用新的接口是检查我们思维是否健全的一个好方法,这是练习 TDD 的另一个优势。在这个新的测试中,我们创建了 Gomniauth 提供的TestUser
并将其嵌入到chatUser
类型中。然后,我们将新的chatUser
类型传递到我们的GetAvatarURL
调用中,并像往常一样对输出做出相同的断言。
Gomniauth 的TestUser
类型很有趣,因为它利用了Testify
包的模拟功能。参见https://github.com/stretchr/testify 了解更多信息。
On
和Return
方法允许我们告诉TestUser
在调用特定方法时要做什么。在第一种情况下,我们告诉AvatarURL
方法返回错误,在第二种情况下,我们要求它返回testUrl
值,这模拟了我们在本测试中讨论的两种可能结果。
更新TestGravatarAvatar
和TestFileSystemAvatar
测试要简单得多,因为它们只依赖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
缓存中的值(通过auth
cookie)。更改分配了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
值。然后我们将userid
MD5 散列保存到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
函数调用中的参数,因为我们使用的是全局变量而不是此变量。
此练习后,最终用户体验保持不变。通常,在重构代码时,修改的是内部构件,而面向公众的接口保持稳定和不变。
通常最好对代码运行golint
和go 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 将呈现一个默认占位符图像。
要查看新功能的运行情况,请执行以下步骤:
-
构建并重新运行应用程序:
go build –o chat ./chat –host=:8080
-
通过访问
http://localhost:8080/logout
注销。 -
从
avatars
文件夹中删除所有图片。 -
导航至
http://localhost:8080/chat
重新登录。 -
发送一些信息并记下您的个人资料图片。
-
访问
http://localhost:8080/upload
并上传新的个人资料图片。 -
再次注销,然后像以前一样重新登录。
-
发送更多消息,注意您的个人资料图片已更新。
在本章中,我们向聊天应用程序添加了三种不同的配置文件图片实现。首先,我们要求身份验证服务提供一个 URL 供我们使用。我们通过使用 Gomniauth 对用户资源数据的抽象实现了这一点,然后每次用户发送消息时,我们都将其作为用户界面的一部分包含进来。使用 Go 的零(或默认)初始化模式,我们能够参考Avatar
接口的不同实现,而不需要实际创建任何实例。
我们将数据存储在 cookie 中,以便用户登录。因此,同时考虑到 cookie 在代码构建之间存在的事实,我们添加了一个方便的注销功能来帮助我们验证我们的更改,我们还向用户公开了这些更改,以便他们也可以注销。代码的其他小改动以及聊天页面上的引导功能极大地改善了应用程序的外观。
我们在 Go 中使用 MD5 哈希,通过哈希验证服务提供的电子邮件地址来实现Gravatar.comAPI。如果 Gravatar 不知道电子邮件地址,他们将为我们提供一个很好的默认占位符图像,这意味着我们的用户界面永远不会因为缺少图像而中断。
然后,我们构建并完成了一个上传表单,并关联了将上传的图片保存在avatars
文件夹中的服务器功能。我们看到了如何通过标准库的http.FileServer
处理程序将保存的上传图片公开给用户。由于这会导致过多的文件系统访问,从而导致我们的设计效率低下,因此我们在单元测试的帮助下重构了解决方案。通过将GetAvatarURL
调用移动到用户登录点,而不是每次发送消息时,我们使代码的可伸缩性大大提高。
我们的特殊ErrNoAvatarURL
错误类型被用作接口设计的一部分,以允许我们在无法获得适当 URL 时通知调用代码。这在我们创建Avatars
切片类型时变得特别有用。通过在一片Avatar
类型上实现Avatar
接口,我们能够实现一个新的实现,轮流尝试从每个可用的不同选项中获取有效的 URL,从文件系统开始,然后是身份验证服务,最后是 Gravatar。我们实现了这一点,对用户如何与界面交互没有任何影响。如果某个实现返回ErrNoAvatarURL
,我们将尝试下一个。
我们的聊天应用程序已准备好上线,因此我们可以邀请朋友进行真正的对话。但是首先我们需要选择一个域名来承载它,这一点我们将在下一章中讨论。