本文是该系列的第九篇，也是最后一篇。第一篇: 模式第二篇: OAuth第三篇: 对话第四篇: 消息第五篇: 实时消息第六篇: 仅用于开发的登录第七篇: Access 页面第八篇: Home 页面在这篇文章中，我们将对 对话 conversation 页面进行编码。此页面是两个用户之间的聊天室。在顶部我们将显示其他参与者的信息，下面接着的是最新消息列表，以及底部的消息表单。聊天标题让我们从创建 static/pages/conversation-page.js 文件开始，它包含以下内容:import http from '../http.js' import { navigate } from '../router.js' import { avatar, escapeHTML } from '../shared.js' export default async function conversationPage(conversationID) { let conversation try { conversation = await getConversation(conversationID) } catch (err) { alert(err.message) navigate('/', true) return } const template = document.createElement('template') template.innerHTML = ` ← Back ${avatar(conversation.otherParticipant)} ${conversation.otherParticipant.username} ` const page = template.content return page } function getConversation(id) { return http.get('/api/conversations/' + id) } 此页面接收路由从 URL 中提取的会话 ID。首先，它向 /api/ conversations/{conversationID} 发起一个 GET 请求，以获取有关对话的信息。 如果出现错误，我们会将其显示，并重定向回 /。然后我们呈现有关其他参与者的信息。对话列表我们也会获取最新的消息并显示它们。let conversation, messages try { [conversation, messages] = await Promise.all([ getConversation(conversationID), getMessages(conversationID), ]) } 更新 conversationPage() 函数以获取消息。我们使用 Promise.all() 同时执行这两个请求。function getMessages(conversationID) { return http.get(`/api/conversations/${conversationID}/messages`) } 发起对 /api/conversations/{conversationID}/messages 的 GET 请求可以获取对话中的最新消息。 现在，将该列表添加到标记中。const messagesOList = page.getElementById('messages') for (const message of messages.reverse()) { messagesOList.appendChild(renderMessage(message)) } 这样我们就可以将消息附加到列表中了。我们以时间倒序来显示它们。function renderMessage(message) { const messageContent = escapeHTML(message.content) const messageDate = new Date(message.createdAt).toLocaleString() const li = document.createElement('li') if (message.mine) { li.classList.add('owned') } li.innerHTML = ` ${messageContent} ${messageDate} ` return li } 每个消息条目显示消息内容本身及其时间戳。使用 .mine，我们可以将不同的 css 类附加到条目，这样您就可以将消息显示在右侧。消息表单 Send 将该表单添加到当前标记中。page.getElementById('message-form').onsubmit = messageSubmitter(conversationID) 将事件监听器附加到 “submit” 事件。function messageSubmitter(conversationID) { return async ev => { ev.preventDefault() const form = ev.currentTarget const input = form.querySelector('input') const submitButton = form.querySelector('button') input.disabled = true submitButton.disabled = true try { const message = await createMessage(input.value, conversationID) input.value = '' const messagesOList = document.getElementById('messages') if (messagesOList === null) { return } messagesOList.appendChild(renderMessage(message)) } catch (err) { if (err.statusCode === 422) { input.setCustomValidity(err.body.errors.content) } else { alert(err.message) } } finally { input.disabled = false submitButton.disabled = false setTimeout(() => { input.focus() }, 0) } } } function createMessage(content, conversationID) { return http.post(`/api/conversations/${conversationID}/messages`, { content }) } 我们利用 partial application 在 “submit” 事件处理程序中获取对话 ID。它 从输入中获取消息内容，并用它对 /api/conversations/{conversationID}/messages 发出 POST 请求。 然后将新创建的消息添加到列表中。消息订阅为了实现实时，我们还将订阅此页面中的消息流。page.addEventListener('disconnect', subscribeToMessages(messageArriver(conversationID))) 将该行添加到 conversationPage() 函数中。function subscribeToMessages(cb) { return http.subscribe('/api/messages', cb) } function messageArriver(conversationID) { return message => { if (message.conversationID !== conversationID) { return } const messagesOList = document.getElementById('messages') if (messagesOList === null) { return } messagesOList.appendChild(renderMessage(message)) readMessages(message.conversationID) } } function readMessages(conversationID) { return http.post(`/api/conversations/${conversationID}/read\_messages`) } 在这里我们仍然使用这个应用的部分来获取会话 ID。 当新消息到达时，我们首先检查它是否来自此对话。如果是，我们会将消息条目预先添加到列表中，并向 /api/conversations/{conversationID}/read_messages 发起 POST 一个请求，以更新参与者上次阅读消息的时间。本系列到此结束。 消息应用现在可以运行了。源代码演示via: https://nicolasparada.netlify.com/posts/go-messenger-conversation-page/作者：Nicolás Parada 选题：lujun9972 译者：gxlct008 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
本文是该系列的第八篇。第一篇: 模式第二篇: OAuth第三篇: 对话第四篇: 消息第五篇: 实时消息第六篇: 仅用于开发的登录第七篇: Access 页面继续前端部分，让我们在本文中完成 home 页面的开发。 我们将添加一个开始对话的表单和一个包含最新对话的列表。对话表单转到 static/ages/home-page.js 文件，在 HTML 视图中添加一些标记。 将该表单添加到我们显示 “auth user” 和 “logout” 按钮部分的下方。page.getElementById('conversation-form').onsubmit = onConversationSubmit 现在我们可以监听 “submit” 事件来创建对话了。import http from '../http.js' import { navigate } from '../router.js' async function onConversationSubmit(ev) { ev.preventDefault() const form = ev.currentTarget const input = form.querySelector('input') input.disabled = true try { const conversation = await createConversation(input.value) input.value = '' navigate('/conversations/' + conversation.id) } catch (err) { if (err.statusCode === 422) { input.setCustomValidity(err.body.errors.username) } else { alert(err.message) } setTimeout(() => { input.focus() }, 0) } finally { input.disabled = false } } function createConversation(username) { return http.post('/api/conversations', { username }) } 在提交时，我们使用用户名对 /api/conversations 进行 POST 请求，并重定向到 conversation 页面（用于下一篇文章）。对话列表还是在这个文件中，我们将创建 homePage() 函数用来先异步加载对话。export default async function homePage() { const conversations = await getConversations().catch(err => { console.error(err) return [] }) /\*...\*/ } function getConversations() { return http.get('/api/conversations') } 然后，在标记中添加一个列表来渲染对话。 将其添加到当前标记的正下方。const conversationsOList = page.getElementById('conversations') for (const conversation of conversations) { conversationsOList.appendChild(renderConversation(conversation)) } 因此，我们可以将每个对话添加到这个列表中。import { avatar, escapeHTML } from '../shared.js' function renderConversation(conversation) { const messageContent = escapeHTML(conversation.lastMessage.content) const messageDate = new Date(conversation.lastMessage.createdAt).toLocaleString() const li = document.createElement('li') li.dataset['id'] = conversation.id if (conversation.hasUnreadMessages) { li.classList.add('has-unread-messages') } li.innerHTML = ` ${avatar(conversation.otherParticipant)} ${conversation.otherParticipant.username} ${messageContent} ${messageDate} ` return li } 每个对话条目都包含一个指向对话页面的链接，并显示其他参与者信息和最后一条消息的预览。另外，您可以使用 .hasUnreadMessages 向该条目添加一个类，并使用 CSS 进行一些样式设置。也许是粗体字体或强调颜色。请注意，我们需要转义信息的内容。该函数来自于 static/shared.js 文件：export function escapeHTML(str) { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } 这会阻止将用户编写的消息显示为 HTML。如果用户碰巧编写了类似以下内容的代码： 这将非常烦人，因为该脚本将被执行?。所以，永远记住要转义来自不可信来源的内容。消息订阅最后但并非最不重要的一点，我想在这里订阅消息流。const unsubscribe = subscribeToMessages(onMessageArrive) page.addEventListener('disconnect', unsubscribe) 在 homePage() 函数中添加这一行。function subscribeToMessages(cb) { return http.subscribe('/api/messages', cb) } 函数 subscribe() 返回一个函数，该函数一旦调用就会关闭底层连接。这就是为什么我把它传递给 “断开连接” disconnect 事件的原因；因此，当用户离开页面时，事件流将被关闭。async function onMessageArrive(message) { const conversationLI = document.querySelector(`li[data-id="${message.conversationID}"]`) if (conversationLI !== null) { conversationLI.classList.add('has-unread-messages') conversationLI.querySelector('a > div > p').textContent = message.content conversationLI.querySelector('a > div > time').textContent = new Date(message.createdAt).toLocaleString() return } let conversation try { conversation = await getConversation(message.conversationID) conversation.lastMessage = message } catch (err) { console.error(err) return } const conversationsOList = document.getElementById('conversations') if (conversationsOList === null) { return } conversationsOList.insertAdjacentElement('afterbegin', renderConversation(conversation)) } function getConversation(id) { return http.get('/api/conversations/' + id) } 每次有新消息到达时，我们都会在 DOM 中查询会话条目。如果找到，我们会将 has-unread-messages 类添加到该条目中，并更新视图。如果未找到，则表示该消息来自刚刚创建的新对话。我们去做一个对 /api/conversations/{conversationID} 的 GET 请求，以获取在其中创建消息的对话，并将其放在对话列表的前面。以上这些涵盖了主页的所有内容 ?。 在下一篇文章中，我们将对 conversation 页面进行编码。源代码via: https://nicolasparada.netlify.com/posts/go-messenger-home-page/作者：Nicolás Parada 选题：lujun9972 译者：gxlct008 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
本文是该系列的第七篇。第一篇: 模式第二篇: OAuth第三篇: 对话第四篇: 消息第五篇: 实时消息第六篇: 仅用于开发的登录现在我们已经完成了后端，让我们转到前端。 我将采用单页应用程序方案。首先，我们创建一个 static/index.html 文件，内容如下。 Messenger 这个 HTML 文件必须为每个 URL 提供服务，并且使用 JavaScript 负责呈现正确的页面。因此，让我们将注意力转到 main.go 片刻，然后在 main() 函数中添加以下路由：router.Handle("GET", "/...", http.FileServer(SPAFileSystem{http.Dir("static")})) type SPAFileSystem struct { fs http.FileSystem } func (spa SPAFileSystem) Open(name string) (http.File, error) { f, err := spa.fs.Open(name) if err != nil { return spa.fs.Open("index.html") } return f, nil } 我们使用一个自定义的文件系统，因此它不是为未知的 URL 返回 404 Not Found，而是转到 index.html。路由器在 index.html 中我们加载了两个文件：styles.css 和 main.js。我把样式留给你自由发挥。让我们移动到 main.js。 创建一个包含以下内容的 static/main.js 文件：import { guard } from './auth.js' import Router from './router.js' let currentPage const disconnect = new CustomEvent('disconnect') const router = new Router() router.handle('/', guard(view('home'), view('access'))) router.handle('/callback', view('callback')) router.handle(/^\/conversations\/([^\/]+)$/, guard(view('conversation'), view('access'))) router.handle(/^\//, view('not-found')) router.install(async result => { document.body.innerHTML = '' if (currentPage instanceof Node) { currentPage.dispatchEvent(disconnect) } currentPage = await result if (currentPage instanceof Node) { document.body.appendChild(currentPage) } }) function view(pageName) { return (...args) => import(`/pages/${pageName}-page.js`) .then(m => m.default(...args)) } 如果你是这个博客的关注者，你已经知道它是如何工作的了。 该路由器就是在 这里 显示的那个。 只需从 @nicolasparada/router 下载并保存到 static/router.js 即可。我们注册了四条路由。 在根路由 / 处，我们展示 home 或 access 页面，无论用户是否通过身份验证。 在 /callback 中，我们展示 callback 页面。 在 /conversations/{conversationID} 上，我们展示对话或 access 页面，无论用户是否通过验证，对于其他 URL，我们展示一个 not-found 页面。我们告诉路由器将结果渲染为文档主体，并在离开之前向每个页面调度一个 disconnect 事件。我们将每个页面放在不同的文件中，并使用新的动态 import() 函数导入它们。身份验证guard() 是一个函数，给它两个函数作为参数，如果用户通过了身份验证，则执行第一个函数，否则执行第二个。它来自 auth.js，所以我们创建一个包含以下内容的 static/auth.js 文件：export function isAuthenticated() { const token = localStorage.getItem('token') const expiresAtItem = localStorage.getItem('expires\_at') if (token === null || expiresAtItem === null) { return false } const expiresAt = new Date(expiresAtItem) if (isNaN(expiresAt.valueOf()) || expiresAt <= new Date()) { return false } return true } export function guard(fn1, fn2) { return (...args) => isAuthenticated() ? fn1(...args) : fn2(...args) } export function getAuthUser() { if (!isAuthenticated()) { return null } const authUser = localStorage.getItem('auth\_user') if (authUser === null) { return null } try { return JSON.parse(authUser) } catch (_) { return null } } isAuthenticated() 检查 localStorage 中的 token 和 expires_at，以判断用户是否已通过身份验证。getAuthUser() 从 localStorage 中获取经过身份验证的用户。当我们登录时，我们会将所有的数据保存到 localStorage，这样才有意义。Access 页面让我们从 access 页面开始。 创建一个包含以下内容的文件 static/pages/access-page.js：const template = document.createElement('template') template.innerHTML = ` Messenger Access with GitHub ` export default function accessPage() { return template.content } 因为路由器会拦截所有链接点击来进行导航，所以我们必须特别阻止此链接的事件传播。单击该链接会将我们重定向到后端，然后重定向到 GitHub，再重定向到后端，然后再次重定向到前端； 到 callback 页面。Callback 页面创建包括以下内容的 static/pages/callback-page.js 文件：import http from '../http.js' import { navigate } from '../router.js' export default async function callbackPage() { const url = new URL(location.toString()) const token = url.searchParams.get('token') const expiresAt = url.searchParams.get('expires\_at') try { if (token === null || expiresAt === null) { throw new Error('Invalid URL') } const authUser = await getAuthUser(token) localStorage.setItem('auth\_user', JSON.stringify(authUser)) localStorage.setItem('token', token) localStorage.setItem('expires\_at', expiresAt) } catch (err) { alert(err.message) } finally { navigate('/', true) } } function getAuthUser(token) { return http.get('/api/auth\_user', { authorization: `Bearer ${token}` }) } callback 页面不呈现任何内容。这是一个异步函数，它使用 URL 查询字符串中的 token 向 /api/auth_user 发出 GET 请求，并将所有数据保存到 localStorage。 然后重定向到 /。HTTP这里是一个 HTTP 模块。 创建一个包含以下内容的 static/http.js 文件：import { isAuthenticated } from './auth.js' async function handleResponse(res) { const body = await res.clone().json().catch(() => res.text()) if (res.status === 401) { localStorage.removeItem('auth\_user') localStorage.removeItem('token') localStorage.removeItem('expires\_at') } if (!res.ok) { const message = typeof body === 'object' && body !== null && 'message' in body ? body.message : typeof body === 'string' && body !== '' ? body : res.statusText throw Object.assign(new Error(message), { url: res.url, statusCode: res.status, statusText: res.statusText, headers: res.headers, body, }) } return body } function getAuthHeader() { return isAuthenticated() ? { authorization: `Bearer ${localStorage.getItem('token')}` } : {} } export default { get(url, headers) { return fetch(url, { headers: Object.assign(getAuthHeader(), headers), }).then(handleResponse) }, post(url, body, headers) { const init = { method: 'POST', headers: getAuthHeader(), } if (typeof body === 'object' && body !== null) { init.body = JSON.stringify(body) init.headers['content-type'] = 'application/json; charset=utf-8' } Object.assign(init.headers, headers) return fetch(url, init).then(handleResponse) }, subscribe(url, callback) { const urlWithToken = new URL(url, location.origin) if (isAuthenticated()) { urlWithToken.searchParams.set('token', localStorage.getItem('token')) } const eventSource = new EventSource(urlWithToken.toString()) eventSource.onmessage = ev => { let data try { data = JSON.parse(ev.data) } catch (err) { console.error('could not parse message data as JSON:', err) return } callback(data) } const unsubscribe = () => { eventSource.close() } return unsubscribe }, } 这个模块是 fetch 和 EventSource API 的包装器。最重要的部分是它将 JSON web 令牌添加到请求中。Home 页面因此，当用户登录时，将显示 home 页。 创建一个具有以下内容的 static/pages/home-page.js 文件：import { getAuthUser } from '../auth.js' import { avatar } from '../shared.js' export default function homePage() { const authUser = getAuthUser() const template = document.createElement('template') template.innerHTML = ` ${avatar(authUser)} ${authUser.username} Logout ` const page = template.content page.getElementById('logout-button').onclick = onLogoutClick return page } function onLogoutClick() { localStorage.clear() location.reload() } 对于这篇文章，这是我们在 home 页上呈现的唯一内容。我们显示当前经过身份验证的用户和注销按钮。当用户单击注销时，我们清除 localStorage 中的所有内容并重新加载页面。Avatar那个 avatar() 函数用于显示用户的头像。 由于已在多个地方使用，因此我将它移到 shared.js 文件中。 创建具有以下内容的文件 static/shared.js：export function avatar(user) { return user.avatarUrl === null ? ` ` : ` ` } 如果头像网址为 null，我们将使用用户的姓名首字母作为初始头像。你可以使用 attr() 函数显示带有少量 CSS 样式的首字母。.avatar[data-initial]::after { content: attr(data-initial); } 仅开发使用的登录在上一篇文章中，我们为编写了一个登录代码。让我们在 access 页面中为此添加一个表单。 进入 static/ages/access-page.js，稍微修改一下。import http from '../http.js' const template = document.createElement('template') template.innerHTML = ` Messenger Login Access with GitHub ` export default function accessPage() { const page = template.content.cloneNode(true) page.getElementById('login-form').onsubmit = onLoginSubmit return page } async function onLoginSubmit(ev) { ev.preventDefault() const form = ev.currentTarget const input = form.querySelector('input') const submitButton = form.querySelector('button') input.disabled = true submitButton.disabled = true try { const payload = await login(input.value) input.value = '' localStorage.setItem('auth\_user', JSON.stringify(payload.authUser)) localStorage.setItem('token', payload.token) localStorage.setItem('expires\_at', payload.expiresAt) location.reload() } catch (err) { alert(err.message) setTimeout(() => { input.focus() }, 0) } finally { input.disabled = false submitButton.disabled = false } } function login(username) { return http.post('/api/login', { username }) } 我添加了一个登录表单。当用户提交表单时。它使用用户名对 /api/login 进行 POST 请求。将所有数据保存到 localStorage 并重新加载页面。记住在前端完成后删除此表单。这就是这篇文章的全部内容。在下一篇文章中，我们将继续使用主页添加一个表单来开始对话，并显示包含最新对话的列表。源代码via: https://nicolasparada.netlify.com/posts/go-messenger-access-page/作者：Nicolás Parada 选题：lujun9972 译者：gxlct008 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
构建一个即时消息应用（六）：仅用于开发的登录
本文是该系列的第六篇。第一篇: 模式第二篇: OAuth第三篇: 对话第四篇: 消息第五篇: 实时消息我们已经实现了通过 GitHub 登录，但是如果想把玩一下这个 app，我们需要几个用户来测试它。在这篇文章中，我们将添加一个为任何用户提供登录的端点，只需提供用户名即可。该端点仅用于开发。首先在 main() 函数中添加此路由。router.HandleFunc("POST", "/api/login", requireJSON(login)) 登录此函数处理对 /api/login 的 POST 请求，其中 JSON body 只包含用户名，并以 JSON 格式返回通过认证的用户、令牌和过期日期。func login(w http.ResponseWriter, r \*http.Request) { if origin.Hostname() != "localhost" { http.NotFound(w, r) return } var input struct { Username string `json:"username"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } defer r.Body.Close() var user User if err := db.QueryRowContext(r.Context(), ` SELECT id, avatar\_url FROM users WHERE username = $1 `, input.Username).Scan( &user.ID, &user.AvatarURL, ); err == sql.ErrNoRows { http.Error(w, "User not found", http.StatusNotFound) return } else if err != nil { respondError(w, fmt.Errorf("could not query user: %v", err)) return } user.Username = input.Username exp := time.Now().Add(jwtLifetime) token, err := issueToken(user.ID, exp) if err != nil { respondError(w, fmt.Errorf("could not create token: %v", err)) return } respond(w, map[string]interface{}{ "authUser": user, "token": token, "expiresAt": exp, }, http.StatusOK) } 首先，它检查我们是否在本地主机上，或者响应为 404 Not Found。它解码主体跳过验证，因为这只是为了开发。然后在数据库中查询给定用户名的用户，如果没有，则返回 404 NOT Found。然后，它使用用户 ID 作为主题发布一个新的 JSON Web 令牌。func issueToken(subject string, exp time.Time) (string, error) { token, err := jwtSigner.Encode(jwt.Claims{ Subject: subject, Expiration: json.Number(strconv.FormatInt(exp.Unix(), 10)), }) if err != nil { return "", err } return string(token), nil } 该函数执行的操作与 前文 相同。我只是将其移过来以重用代码。创建令牌后，它将使用用户、令牌和到期日期进行响应。种子用户现在，你可以将要操作的用户添加到数据库中。INSERT INTO users (id, username) VALUES (1, 'john'), (2, 'jane'); 你可以将其保存到文件中，并通过管道将其传送到 Cockroach CLI。cat seed_users.sql | cockroach sql --insecure -d messenger 就是这样。一旦将代码部署到生产环境并使用自己的域后，该登录功能将不可用。本文也结束了所有的后端开发部分。源代码via: https://nicolasparada.netlify.com/posts/go-messenger-dev-login/作者：Nicolás Parada 选题：lujun9972 译者：gxlct008 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
构建一个即时消息应用（五）：实时消息
本文是该系列的第五篇。第一篇: 模式第二篇: OAuth第三篇: 对话第四篇: 消息对于实时消息，我们将使用 服务器发送事件 Server-Sent Events 。这是一个打开的连接，我们可以在其中传输数据流。我们会有个端点，用户会在其中订阅发送给他的所有消息。消息户端在 HTTP 部分之前，让我们先编写一个 映射 map ，让所有客户端都监听消息。 像这样全局初始化：type MessageClient struct { Messages chan Message UserID string } var messageClients sync.Map 已创建的新消息还记得在 上一篇文章 中，当我们创建这条消息时，我们留下了一个 “TODO” 注释。在那里，我们将使用这个函数来调度一个 goroutine。go messageCreated(message) 把这行代码插入到我们留注释的位置。func messageCreated(message Message) error { if err := db.QueryRow(` SELECT user\_id FROM participants WHERE user\_id != $1 and conversation\_id = $2 `, message.UserID, message.ConversationID). Scan(&message.ReceiverID); err != nil { return err } go broadcastMessage(message) return nil } func broadcastMessage(message Message) { messageClients.Range(func(key, \_ interface{}) bool { client := key.(\*MessageClient) if client.UserID == message.ReceiverID { client.Messages <- message } return true }) } 该函数查询接收者 ID（其他参与者 ID），并将消息发送给所有客户端。订阅消息让我们转到 main() 函数并添加以下路由：router.HandleFunc("GET", "/api/messages", guard(subscribeToMessages)) 此端点处理 /api/messages 上的 GET 请求。请求应该是一个 EventSource 连接。它用一个事件流响应，其中的数据是 JSON 格式的。func subscribeToMessages(w http.ResponseWriter, r \*http.Request) { if a := r.Header.Get("Accept"); !strings.Contains(a, "text/event-stream") { http.Error(w, "This endpoint requires an EventSource connection", http.StatusNotAcceptable) return } f, ok := w.(http.Flusher) if !ok { respondError(w, errors.New("streaming unsupported")) return } ctx := r.Context() authUserID := ctx.Value(keyAuthUserID).(string) h := w.Header() h.Set("Cache-Control", "no-cache") h.Set("Connection", "keep-alive") h.Set("Content-Type", "text/event-stream") messages := make(chan Message) defer close(messages) client := &MessageClient{Messages: messages, UserID: authUserID} messageClients.Store(client, nil) defer messageClients.Delete(client) for { select { case <-ctx.Done(): return case message := <-messages: if b, err := json.Marshal(message); err != nil { log.Printf("could not marshall message: %v\n", err) fmt.Fprintf(w, "event: error\ndata: %v\n\n", err) } else { fmt.Fprintf(w, "data: %s\n\n", b) } f.Flush() } } } 首先，它检查请求头是否正确，并检查服务器是否支持流式传输。我们创建一个消息通道，用它来构建一个客户端，并将其存储在客户端映射中。每当创建新消息时，它都会进入这个通道，因此我们可以通过 for-select 循环从中读取。 服务器发送事件 Server-Sent Events 使用以下格式发送数据：data: some data here\n\n 我们以 JSON 格式发送：data: {"foo":"bar"}\n\n 我们使用 fmt.Fprintf() 以这种格式写入响应 写入器 writter ，并在循环的每次迭代中刷新数据。这个循环会一直运行，直到使用请求上下文关闭连接为止。我们延迟了通道的关闭和客户端的删除，因此，当循环结束时，通道将被关闭，客户端不会收到更多的消息。注意， 服务器发送事件 Server-Sent Events （EventSource）的 JavaScript API 不支持设置自定义请求头?，所以我们不能设置 Authorization: Bearer <token>。这就是为什么 guard() 中间件也会从 URL 查询字符串中读取令牌的原因。实时消息部分到此结束。我想说的是，这就是后端的全部内容。但是为了编写前端代码，我将再增加一个登录端点：一个仅用于开发的登录。源代码via: https://nicolasparada.netlify.com/posts/go-messenger-realtime-messages/作者：Nicolás Parada 选题：lujun9972 译者：gxlct008 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
本文是该系列的第四篇。第一篇: 模式第二篇: OAuth第三篇: 对话在这篇文章中，我们将对端点进行编码，以创建一条消息并列出它们，同时还将编写一个端点以更新参与者上次阅读消息的时间。 首先在 main() 函数中添加这些路由。router.HandleFunc("POST", "/api/conversations/:conversationID/messages", requireJSON(guard(createMessage))) router.HandleFunc("GET", "/api/conversations/:conversationID/messages", guard(getMessages)) router.HandleFunc("POST", "/api/conversations/:conversationID/read_messages", guard(readMessages)) 消息会进入对话，因此端点包含对话 ID。创建消息该端点处理对 /api/conversations/{conversationID}/messages 的 POST 请求，其 JSON 主体仅包含消息内容，并返回新创建的消息。它有两个副作用：更新对话 last_message_id 以及更新参与者 messages_read_at。func createMessage(w http.ResponseWriter, r *http.Request) { var input struct { Content string `json:"content"` } defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } errs := make(map[string]string) input.Content = removeSpaces(input.Content) if input.Content == "" { errs["content"] = "Message content required" } else if len([]rune(input.Content)) > 480 { errs["content"] = "Message too long. 480 max" } if len(errs) != 0 { respond(w, Errors{errs}, http.StatusUnprocessableEntity) return } ctx := r.Context() authUserID := ctx.Value(keyAuthUserID).(string) conversationID := way.Param(ctx, "conversationID") tx, err := db.BeginTx(ctx, nil) if err != nil { respondError(w, fmt.Errorf("could not begin tx: %v", err)) return } defer tx.Rollback() isParticipant, err := queryParticipantExistance(ctx, tx, authUserID, conversationID) if err != nil { respondError(w, fmt.Errorf("could not query participant existance: %v", err)) return } if !isParticipant { http.Error(w, "Conversation not found", http.StatusNotFound) return } var message Message if err := tx.QueryRowContext(ctx, ` INSERT INTO messages (content, user_id, conversation_id) VALUES ($1, $2, $3) RETURNING id, created_at `, input.Content, authUserID, conversationID).Scan( &message.ID, &message.CreatedAt, ); err != nil { respondError(w, fmt.Errorf("could not insert message: %v", err)) return } if _, err := tx.ExecContext(ctx, ` UPDATE conversations SET last_message_id = $1 WHERE id = $2 `, message.ID, conversationID); err != nil { respondError(w, fmt.Errorf("could not update conversation last message ID: %v", err)) return } if err = tx.Commit(); err != nil { respondError(w, fmt.Errorf("could not commit tx to create a message: %v", err)) return } go func() { if err = updateMessagesReadAt(nil, authUserID, conversationID); err != nil { log.Printf("could not update messages read at: %v\n", err) } }() message.Content = input.Content message.UserID = authUserID message.ConversationID = conversationID // TODO: notify about new message. message.Mine = true respond(w, message, http.StatusCreated) } 首先，它将请求正文解码为包含消息内容的结构。然后，它验证内容不为空并且少于 480 个字符。var rxSpaces = regexp.MustCompile("\\s+") func removeSpaces(s string) string { if s == "" { return s } lines := make([]string, 0) for _, line := range strings.Split(s, "\n") { line = rxSpaces.ReplaceAllLiteralString(line, " ") line = strings.TrimSpace(line) if line != "" { lines = append(lines, line) } } return strings.Join(lines, "\n") } 这是删除空格的函数。它遍历每一行，删除两个以上的连续空格，然后回非空行。验证之后，它将启动一个 SQL 事务。首先，它查询对话中的参与者是否存在。func queryParticipantExistance(ctx context.Context, tx *sql.Tx, userID, conversationID string) (bool, error) { if ctx == nil { ctx = context.Background() } var exists bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( SELECT 1 FROM participants WHERE user_id = $1 AND conversation_id = $2 )`, userID, conversationID).Scan(&exists); err != nil { return false, err } return exists, nil } 我将其提取到一个函数中，因为稍后可以重用。如果用户不是对话参与者，我们将返回一个 404 NOT Found 错误。然后，它插入消息并更新对话 last_message_id。从这时起，由于我们不允许删除消息，因此 last_message_id 不能为 NULL。接下来提交事务，并在 goroutine 中更新参与者 messages_read_at。func updateMessagesReadAt(ctx context.Context, userID, conversationID string) error { if ctx == nil { ctx = context.Background() } if _, err := db.ExecContext(ctx, ` UPDATE participants SET messages_read_at = now() WHERE user_id = $1 AND conversation_id = $2 `, userID, conversationID); err != nil { return err } return nil } 在回复这条新消息之前，我们必须通知一下。这是我们将要在下一篇文章中编写的实时部分，因此我在那里留一了个注释。获取消息这个端点处理对 /api/conversations/{conversationID}/messages 的 GET 请求。 它用一个包含会话中所有消息的 JSON 数组进行响应。它还具有更新参与者 messages_read_at 的副作用。func getMessages(w http.ResponseWriter, r *http.Request) { ctx := r.Context() authUserID := ctx.Value(keyAuthUserID).(string) conversationID := way.Param(ctx, "conversationID") tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { respondError(w, fmt.Errorf("could not begin tx: %v", err)) return } defer tx.Rollback() isParticipant, err := queryParticipantExistance(ctx, tx, authUserID, conversationID) if err != nil { respondError(w, fmt.Errorf("could not query participant existance: %v", err)) return } if !isParticipant { http.Error(w, "Conversation not found", http.StatusNotFound) return } rows, err := tx.QueryContext(ctx, ` SELECT id, content, created_at, user_id = $1 AS mine FROM messages WHERE messages.conversation_id = $2 ORDER BY messages.created_at DESC `, authUserID, conversationID) if err != nil { respondError(w, fmt.Errorf("could not query messages: %v", err)) return } defer rows.Close() messages := make([]Message, 0) for rows.Next() { var message Message if err = rows.Scan( &message.ID, &message.Content, &message.CreatedAt, &message.Mine, ); err != nil { respondError(w, fmt.Errorf("could not scan message: %v", err)) return } messages = append(messages, message) } if err = rows.Err(); err != nil { respondError(w, fmt.Errorf("could not iterate over messages: %v", err)) return } if err = tx.Commit(); err != nil { respondError(w, fmt.Errorf("could not commit tx to get messages: %v", err)) return } go func() { if err = updateMessagesReadAt(nil, authUserID, conversationID); err != nil { log.Printf("could not update messages read at: %v\n", err) } }() respond(w, messages, http.StatusOK) } 首先，它以只读模式开始一个 SQL 事务。检查参与者是否存在，并查询所有消息。在每条消息中，我们使用当前经过身份验证的用户 ID 来了解用户是否拥有该消息（mine）。 然后，它提交事务，在 goroutine 中更新参与者 messages_read_at 并以消息响应。读取消息该端点处理对 /api/conversations/{conversationID}/read_messages 的 POST 请求。 没有任何请求或响应主体。 在前端，每次有新消息到达实时流时，我们都会发出此请求。func readMessages(w http.ResponseWriter, r *http.Request) { ctx := r.Context() authUserID := ctx.Value(keyAuthUserID).(string) conversationID := way.Param(ctx, "conversationID") if err := updateMessagesReadAt(ctx, authUserID, conversationID); err != nil { respondError(w, fmt.Errorf("could not update messages read at: %v", err)) return } w.WriteHeader(http.StatusNoContent) } 它使用了与更新参与者 messages_read_at 相同的函数。到此为止。实时消息是后台仅剩的部分了。请等待下一篇文章。源代码via: https://nicolasparada.netlify.com/posts/go-messenger-messages/作者：Nicolás Parada 选题：lujun9972 译者：gxlct008 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
本文是该系列的第三篇。第一篇：模式第二篇：OAuth在我们的即时消息应用中，消息表现为两个参与者对话的堆叠。如果你想要开始一场对话，就应该向应用提供你想要交谈的用户，而当对话创建后（如果该对话此前并不存在），就可以向该对话发送消息。就前端而言，我们可能想要显示一份近期对话列表。并在此处显示对话的最后一条消息以及另一个参与者的姓名和头像。在这篇帖子中，我们将会编写一些 端点 endpoint 来完成像“创建对话”、“获取对话列表”以及“找到单个对话”这样的任务。首先，要在主函数 main() 中添加下面的路由。router.HandleFunc("POST", "/api/conversations", requireJSON(guard(createConversation))) router.HandleFunc("GET", "/api/conversations", guard(getConversations)) router.HandleFunc("GET", "/api/conversations/:conversationID", guard(getConversation))这三个端点都需要进行身份验证，所以我们将会使用 guard() 中间件。我们也会构建一个新的中间件，用于检查请求内容是否为 JSON 格式。JSON 请求检查中间件func requireJSON(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { http.Error(w, "Content type of application/json required", http.StatusUnsupportedMediaType) return } handler(w, r) } }如果 请求 request 不是 JSON 格式，那么它会返回 415 Unsupported Media Type（不支持的媒体类型）错误。创建对话type Conversation struct { ID string `json:"id"` OtherParticipant *User `json:"otherParticipant"` LastMessage *Message `json:"lastMessage"` HasUnreadMessages bool `json:"hasUnreadMessages"` }就像上面的代码那样，对话中保持对另一个参与者和最后一条消息的引用，还有一个 bool 类型的字段，用来告知是否有未读消息。type Message struct { ID string `json:"id"` Content string `json:"content"` UserID string `json:"-"` ConversationID string `json:"conversationID,omitempty"` CreatedAt time.Time `json:"createdAt"` Mine bool `json:"mine"` ReceiverID string `json:"-"` }我们会在下一篇文章介绍与消息相关的内容，但由于我们这里也需要用到它，所以先定义了 Message 结构体。其中大多数字段与数据库表一致。我们需要使用 Mine 来断定消息是否属于当前已验证用户所有。一旦加入实时功能，ReceiverID 可以帮助我们过滤消息。接下来让我们编写 HTTP 处理程序。尽管它有些长，但也没什么好怕的。func createConversation(w http.ResponseWriter, r *http.Request) { var input struct { Username string `json:"username"` } defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } input.Username = strings.TrimSpace(input.Username) if input.Username == "" { respond(w, Errors{map[string]string{ "username": "Username required", }}, http.StatusUnprocessableEntity) return } ctx := r.Context() authUserID := ctx.Value(keyAuthUserID).(string) tx, err := db.BeginTx(ctx, nil) if err != nil { respondError(w, fmt.Errorf("could not begin tx: %v", err)) return } defer tx.Rollback() var otherParticipant User if err := tx.QueryRowContext(ctx, ` SELECT id, avatar_url FROM users WHERE username = $1 `, input.Username).Scan( &otherParticipant.ID, &otherParticipant.AvatarURL, ); err == sql.ErrNoRows { http.Error(w, "User not found", http.StatusNotFound) return } else if err != nil { respondError(w, fmt.Errorf("could not query other participant: %v", err)) return } otherParticipant.Username = input.Username if otherParticipant.ID == authUserID { http.Error(w, "Try start a conversation with someone else", http.StatusForbidden) return } var conversationID string if err := tx.QueryRowContext(ctx, ` SELECT conversation_id FROM participants WHERE user_id = $1 INTERSECT SELECT conversation_id FROM participants WHERE user_id = $2 `, authUserID, otherParticipant.ID).Scan(&conversationID); err != nil && err != sql.ErrNoRows { respondError(w, fmt.Errorf("could not query common conversation id: %v", err)) return } else if err == nil { http.Redirect(w, r, "/api/conversations/"+conversationID, http.StatusFound) return } var conversation Conversation if err = tx.QueryRowContext(ctx, ` INSERT INTO conversations DEFAULT VALUES RETURNING id `).Scan(&conversation.ID); err != nil { respondError(w, fmt.Errorf("could not insert conversation: %v", err)) return } if _, err = tx.ExecContext(ctx, ` INSERT INTO participants (user_id, conversation_id) VALUES ($1, $2), ($3, $2) `, authUserID, conversation.ID, otherParticipant.ID); err != nil { respondError(w, fmt.Errorf("could not insert participants: %v", err)) return } if err = tx.Commit(); err != nil { respondError(w, fmt.Errorf("could not commit tx to create conversation: %v", err)) return } conversation.OtherParticipant = &otherParticipant respond(w, conversation, http.StatusCreated) }在此端点，你会向 /api/conversations 发送 POST 请求，请求的 JSON 主体中包含要对话的用户的用户名。因此，首先需要将请求主体解析成包含用户名的结构。然后，校验用户名不能为空。type Errors struct { Errors map[string]string `json:"errors"` }这是错误消息的结构体 Errors，它仅仅是一个映射。如果输入空用户名，你就会得到一段带有 422 Unprocessable Entity（无法处理的实体）错误消息的 JSON 。{ "errors": { "username": "Username required" } }然后，我们开始执行 SQL 事务。收到的仅仅是用户名，但事实上，我们需要知道实际的用户 ID 。因此，事务的第一项内容是查询另一个参与者的 ID 和头像。如果找不到该用户，我们将会返回 404 Not Found（未找到） 错误。另外，如果找到的用户恰好和“当前已验证用户”相同，我们应该返回 403 Forbidden（拒绝处理）错误。这是由于对话只应当在两个不同的用户之间发起，而不能是同一个。然后，我们试图找到这两个用户所共有的对话，所以需要使用 INTERSECT 语句。如果存在，只需要通过 /api/conversations/{conversationID} 重定向到该对话并将其返回。如果未找到共有的对话，我们需要创建一个新的对话并添加指定的两个参与者。最后，我们 COMMIT 该事务并使用新创建的对话进行响应。获取对话列表端点 /api/conversations 将获取当前已验证用户的所有对话。func getConversations(w http.ResponseWriter, r *http.Request) { ctx := r.Context() authUserID := ctx.Value(keyAuthUserID).(string) rows, err := db.QueryContext(ctx, ` SELECT conversations.id, auth_user.messages_read_at < messages.created_at AS has_unread_messages, messages.id, messages.content, messages.created_at, messages.user_id = $1 AS mine, other_users.id, other_users.username, other_users.avatar_url FROM conversations INNER JOIN messages ON conversations.last_message_id = messages.id INNER JOIN participants other_participants ON other_participants.conversation_id = conversations.id AND other_participants.user_id != $1 INNER JOIN users other_users ON other_participants.user_id = other_users.id INNER JOIN participants auth_user ON auth_user.conversation_id = conversations.id AND auth_user.user_id = $1 ORDER BY messages.created_at DESC `, authUserID) if err != nil { respondError(w, fmt.Errorf("could not query conversations: %v", err)) return } defer rows.Close() conversations := make([]Conversation, 0) for rows.Next() { var conversation Conversation var lastMessage Message var otherParticipant User if err = rows.Scan( &conversation.ID, &conversation.HasUnreadMessages, &lastMessage.ID, &lastMessage.Content, &lastMessage.CreatedAt, &lastMessage.Mine, &otherParticipant.ID, &otherParticipant.Username, &otherParticipant.AvatarURL, ); err != nil { respondError(w, fmt.Errorf("could not scan conversation: %v", err)) return } conversation.LastMessage = &lastMessage conversation.OtherParticipant = &otherParticipant conversations = append(conversations, conversation) } if err = rows.Err(); err != nil { respondError(w, fmt.Errorf("could not iterate over conversations: %v", err)) return } respond(w, conversations, http.StatusOK) }该处理程序仅对数据库进行查询。它通过一些联接来查询对话表……首先，从消息表中获取最后一条消息。然后依据“ID 与当前已验证用户不同”的条件，从参与者表找到对话的另一个参与者。然后联接到用户表以获取该用户的用户名和头像。最后，再次联接参与者表，并以相反的条件从该表中找出参与对话的另一个用户，其实就是当前已验证用户。我们会对比消息中的 messages_read_at 和 created_at 两个字段，以确定对话中是否存在未读消息。然后，我们通过 user_id 字段来判定该消息是否属于“我”（指当前已验证用户）。注意，此查询过程假定对话中只有两个用户参与，它也仅仅适用于这种情况。另外,该设计也不很适用于需要显示未读消息数量的情况。如果需要显示未读消息的数量，我认为可以在 participants 表上添加一个unread_messages_count INT 字段，并在每次创建新消息的时候递增它，如果用户已读则重置该字段。接下来需要遍历每一条记录，通过扫描每一个存在的对话来建立一个 对话切片 slice of conversations 并在最后进行响应。找到单个对话端点 /api/conversations/{conversationID} 会根据 ID 对单个对话进行响应。func getConversation(w http.ResponseWriter, r *http.Request) { ctx := r.Context() authUserID := ctx.Value(keyAuthUserID).(string) conversationID := way.Param(ctx, "conversationID") var conversation Conversation var otherParticipant User if err := db.QueryRowContext(ctx, ` SELECT IFNULL(auth_user.messages_read_at < messages.created_at, false) AS has_unread_messages, other_users.id, other_users.username, other_users.avatar_url FROM conversations LEFT JOIN messages ON conversations.last_message_id = messages.id INNER JOIN participants other_participants ON other_participants.conversation_id = conversations.id AND other_participants.user_id != $1 INNER JOIN users other_users ON other_participants.user_id = other_users.id INNER JOIN participants auth_user ON auth_user.conversation_id = conversations.id AND auth_user.user_id = $1 WHERE conversations.id = $2 `, authUserID, conversationID).Scan( &conversation.HasUnreadMessages, &otherParticipant.ID, &otherParticipant.Username, &otherParticipant.AvatarURL, ); err == sql.ErrNoRows { http.Error(w, "Conversation not found", http.StatusNotFound) return } else if err != nil { respondError(w, fmt.Errorf("could not query conversation: %v", err)) return } conversation.ID = conversationID conversation.OtherParticipant = &otherParticipant respond(w, conversation, http.StatusOK) }这里的查询与之前有点类似。尽管我们并不关心最后一条消息的显示问题，并因此忽略了与之相关的一些字段，但是我们需要根据这条消息来判断对话中是否存在未读消息。此时，我们使用 LEFT JOIN 来代替 INNER JOIN，因为 last_message_id 字段是 NULLABLE（可以为空）的；而其他情况下，我们无法得到任何记录。基于同样的理由，我们在 has_unread_messages 的比较中使用了 IFNULL 语句。最后，我们按 ID 进行过滤。如果查询没有返回任何记录，我们的响应会返回 404 Not Found 错误，否则响应将会返回 200 OK 以及找到的对话。本篇帖子以创建了一些对话端点结束。在下一篇帖子中，我们将会看到如何创建并列出消息。源代码via: https://nicolasparada.netlify.com/posts/go-messenger-conversations/作者：Nicolás Parada 选题：lujun9972 译者：PsiACE 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
上一篇：模式。在这篇帖子中，我们将会通过为应用添加社交登录功能进入后端开发。社交登录的工作方式十分简单：用户点击链接，然后重定向到 GitHub 授权页面。当用户授予我们对他的个人信息的访问权限之后，就会重定向回登录页面。下一次尝试登录时，系统将不会再次请求授权，也就是说，我们的应用已经记住了这个用户。这使得整个登录流程看起来就和你用鼠标单击一样快。如果进一步考虑其内部实现的话，过程就会变得复杂起来。首先，我们需要注册一个新的 GitHub OAuth 应用。这一步中，比较重要的是回调 URL。我们将它设置为 http://localhost:3000/api/oauth/github/callback。这是因为，在开发过程中，我们总是在本地主机上工作。一旦你要将应用交付生产，请使用正确的回调 URL 注册一个新的应用。注册以后，你将会收到“客户端 id”和“安全密钥”。安全起见，请不要与任何人分享他们 ?顺便让我们开始写一些代码吧。现在，创建一个 main.go 文件：package main import ( "database/sql" "fmt" "log" "net/http" "net/url" "os" "strconv" "github.com/gorilla/securecookie" "github.com/joho/godotenv" "github.com/knq/jwt" _ "github.com/lib/pq" "github.com/matryer/way" "golang.org/x/oauth2" "golang.org/x/oauth2/github" ) var origin *url.URL var db *sql.DB var githubOAuthConfig *oauth2.Config var cookieSigner *securecookie.SecureCookie var jwtSigner jwt.Signer func main() { godotenv.Load() port := intEnv("PORT", 3000) originString := env("ORIGIN", fmt.Sprintf("http://localhost:%d/", port)) databaseURL := env("DATABASE_URL", "postgresql://root@127.0.0.1:26257/messenger?sslmode=disable") githubClientID := os.Getenv("GITHUB_CLIENT_ID") githubClientSecret := os.Getenv("GITHUB_CLIENT_SECRET") hashKey := env("HASH_KEY", "secret") jwtKey := env("JWT_KEY", "secret") var err error if origin, err = url.Parse(originString); err != nil || !origin.IsAbs() { log.Fatal("invalid origin") return } if i, err := strconv.Atoi(origin.Port()); err == nil { port = i } if githubClientID == "" || githubClientSecret == "" { log.Fatalf("remember to set both $GITHUB_CLIENT_ID and $GITHUB_CLIENT_SECRET") return } if db, err = sql.Open("postgres", databaseURL); err != nil { log.Fatalf("could not open database connection: %v\n", err) return } defer db.Close() if err = db.Ping(); err != nil { log.Fatalf("could not ping to db: %v\n", err) return } githubRedirectURL := *origin githubRedirectURL.Path = "/api/oauth/github/callback" githubOAuthConfig = &oauth2.Config{ ClientID: githubClientID, ClientSecret: githubClientSecret, Endpoint: github.Endpoint, RedirectURL: githubRedirectURL.String(), Scopes: []string{"read:user"}, } cookieSigner = securecookie.New([]byte(hashKey), nil).MaxAge(0) jwtSigner, err = jwt.HS256.New([]byte(jwtKey)) if err != nil { log.Fatalf("could not create JWT signer: %v\n", err) return } router := way.NewRouter() router.HandleFunc("GET", "/api/oauth/github", githubOAuthStart) router.HandleFunc("GET", "/api/oauth/github/callback", githubOAuthCallback) router.HandleFunc("GET", "/api/auth_user", guard(getAuthUser)) log.Printf("accepting connections on port %d\n", port) log.Printf("starting server at %s\n", origin.String()) addr := fmt.Sprintf(":%d", port) if err = http.ListenAndServe(addr, router); err != nil { log.Fatalf("could not start server: %v\n", err) } } func env(key, fallbackValue string) string { v, ok := os.LookupEnv(key) if !ok { return fallbackValue } return v } func intEnv(key string, fallbackValue int) int { v, ok := os.LookupEnv(key) if !ok { return fallbackValue } i, err := strconv.Atoi(v) if err != nil { return fallbackValue } return i }安装依赖项：go get -u github.com/gorilla/securecookie go get -u github.com/joho/godotenv go get -u github.com/knq/jwt go get -u github.com/lib/pq ge get -u github.com/matoous/go-nanoid go get -u github.com/matryer/way go get -u golang.org/x/oauth2我们将会使用 .env 文件来保存密钥和其他配置。请创建这个文件，并保证里面至少包含以下内容：GITHUB_CLIENT_ID=your_github_client_id GITHUB_CLIENT_SECRET=your_github_client_secret我们还要用到的其他环境变量有：PORT：服务器运行的端口，默认值是 3000。ORIGIN：你的域名，默认值是 http://localhost:3000/。我们也可以在这里指定端口。DATABASE_URL：Cockroach 数据库的地址。默认值是 postgresql://root@127.0.0.1:26257/messenger?sslmode=disable。HASH_KEY：用于为 cookie 签名的密钥。没错，我们会使用已签名的 cookie 来确保安全。JWT_KEY：用于签署 JSON 网络令牌 Web Token 的密钥。因为代码中已经设定了默认值，所以你也不用把它们写到 .env 文件中。在读取配置并连接到数据库之后，我们会创建一个 OAuth 配置。我们会使用 ORIGIN 信息来构建回调 URL（就和我们在 GitHub 页面上注册的一样）。我们的数据范围设置为 “read:user”。这会允许我们读取公开的用户信息，这里我们只需要他的用户名和头像就够了。然后我们会初始化 cookie 和 JWT 签名器。定义一些端点并启动服务器。在实现 HTTP 处理程序之前，让我们编写一些函数来发送 HTTP 响应。func respond(w http.ResponseWriter, v interface{}, statusCode int) { b, err := json.Marshal(v) if err != nil { respondError(w, fmt.Errorf("could not marshal response: %v", err)) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(statusCode) w.Write(b) } func respondError(w http.ResponseWriter, err error) { log.Println(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) }第一个函数用来发送 JSON，而第二个将错误记录到控制台并返回一个 500 Internal Server Error 错误信息。OAuth 开始所以，用户点击写着 “Access with GitHub” 的链接。该链接指向 /api/oauth/github，这将会把用户重定向到 github。func githubOAuthStart(w http.ResponseWriter, r *http.Request) { state, err := gonanoid.Nanoid() if err != nil { respondError(w, fmt.Errorf("could not generte state: %v", err)) return } stateCookieValue, err := cookieSigner.Encode("state", state) if err != nil { respondError(w, fmt.Errorf("could not encode state cookie: %v", err)) return } http.SetCookie(w, &http.Cookie{ Name: "state", Value: stateCookieValue, Path: "/api/oauth/github", HttpOnly: true, }) http.Redirect(w, r, githubOAuthConfig.AuthCodeURL(state), http.StatusTemporaryRedirect) }OAuth2 使用一种机制来防止 CSRF 攻击，因此它需要一个“状态”（state）。我们使用 Nanoid() 来创建一个随机字符串，并用这个字符串作为状态。我们也把它保存为一个 cookie。OAuth 回调一旦用户授权我们访问他的个人信息，他将会被重定向到这个端点。这个 URL 的查询字符串上将会包含状态（state）和授权码（code）： /api/oauth/github/callback?state=&code=。const jwtLifetime = time.Hour * 24 * 14 type GithubUser struct { ID int `json:"id"` Login string `json:"login"` AvatarURL *string `json:"avatar_url,omitempty"` } type User struct { ID string `json:"id"` Username string `json:"username"` AvatarURL *string `json:"avatarUrl"` } func githubOAuthCallback(w http.ResponseWriter, r *http.Request) { stateCookie, err := r.Cookie("state") if err != nil { http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot) return } http.SetCookie(w, &http.Cookie{ Name: "state", Value: "", MaxAge: -1, HttpOnly: true, }) var state string if err = cookieSigner.Decode("state", stateCookie.Value, &state); err != nil { http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot) return } q := r.URL.Query() if state != q.Get("state") { http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot) return } ctx := r.Context() t, err := githubOAuthConfig.Exchange(ctx, q.Get("code")) if err != nil { respondError(w, fmt.Errorf("could not fetch github token: %v", err)) return } client := githubOAuthConfig.Client(ctx, t) resp, err := client.Get("https://api.github.com/user") if err != nil { respondError(w, fmt.Errorf("could not fetch github user: %v", err)) return } var githubUser GithubUser if err = json.NewDecoder(resp.Body).Decode(&githubUser); err != nil { respondError(w, fmt.Errorf("could not decode github user: %v", err)) return } defer resp.Body.Close() tx, err := db.BeginTx(ctx, nil) if err != nil { respondError(w, fmt.Errorf("could not begin tx: %v", err)) return } var user User if err = tx.QueryRowContext(ctx, ` SELECT id, username, avatar_url FROM users WHERE github_id = $1 `, githubUser.ID).Scan(&user.ID, &user.Username, &user.AvatarURL); err == sql.ErrNoRows { if err = tx.QueryRowContext(ctx, ` INSERT INTO users (username, avatar_url, github_id) VALUES ($1, $2, $3) RETURNING id `, githubUser.Login, githubUser.AvatarURL, githubUser.ID).Scan(&user.ID); err != nil { respondError(w, fmt.Errorf("could not insert user: %v", err)) return } user.Username = githubUser.Login user.AvatarURL = githubUser.AvatarURL } else if err != nil { respondError(w, fmt.Errorf("could not query user by github ID: %v", err)) return } if err = tx.Commit(); err != nil { respondError(w, fmt.Errorf("could not commit to finish github oauth: %v", err)) return } exp := time.Now().Add(jwtLifetime) token, err := jwtSigner.Encode(jwt.Claims{ Subject: user.ID, Expiration: json.Number(strconv.FormatInt(exp.Unix(), 10)), }) if err != nil { respondError(w, fmt.Errorf("could not create token: %v", err)) return } expiresAt, _ := exp.MarshalText() data := make(url.Values) data.Set("token", string(token)) data.Set("expires_at", string(expiresAt)) http.Redirect(w, r, "/callback?"+data.Encode(), http.StatusTemporaryRedirect) }首先，我们会尝试使用之前保存的状态对 cookie 进行解码。并将其与查询字符串中的状态进行比较。如果它们不匹配，我们会返回一个 418 I'm teapot（未知来源）错误。接着，我们使用授权码生成一个令牌。这个令牌被用于创建 HTTP 客户端来向 GitHub API 发出请求。所以最终我们会向 https://api.github.com/user 发送一个 GET 请求。这个端点将会以 JSON 格式向我们提供当前经过身份验证的用户信息。我们将会解码这些内容，一并获取用户的 ID、登录名（用户名）和头像 URL。然后我们将会尝试在数据库上找到具有该 GitHub ID 的用户。如果没有找到，就使用该数据创建一个新的。之后，对于新创建的用户，我们会发出一个将用户 ID 作为主题（Subject）的 JSON 网络令牌，并使用该令牌重定向到前端，查询字符串中一并包含该令牌的到期日（Expiration）。这一 Web 应用也会被用在其他帖子，但是重定向的链接会是 /callback?token=&expires_at=。在那里，我们将会利用 JavaScript 从 URL 中获取令牌和到期日，并通过 Authorization 标头中的令牌以 Bearer token_here 的形式对 /api/auth_user 进行 GET 请求，来获取已认证的身份用户并将其保存到 localStorage。Guard 中间件为了获取当前已经过身份验证的用户，我们设计了 Guard 中间件。这是因为在接下来的文章中，我们会有很多需要进行身份认证的端点，而中间件将会允许我们共享这一功能。type ContextKey struct { Name string } var keyAuthUserID = ContextKey{"auth_user_id"} func guard(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var token string if a := r.Header.Get("Authorization"); strings.HasPrefix(a, "Bearer ") { token = a[7:] } else if t := r.URL.Query().Get("token"); t != "" { token = t } else { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } var claims jwt.Claims if err := jwtSigner.Decode([]byte(token), &claims); err != nil { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } ctx := r.Context() ctx = context.WithValue(ctx, keyAuthUserID, claims.Subject) handler(w, r.WithContext(ctx)) } }首先，我们尝试从 Authorization 标头或者是 URL 查询字符串中的 token 字段中读取令牌。如果没有找到，我们需要返回 401 Unauthorized（未授权）错误。然后我们将会对令牌中的申明进行解码，并使用该主题作为当前已经过身份验证的用户 ID。现在，我们可以用这一中间件来封装任何需要授权的 http.handlerFunc，并且在处理函数的上下文中保有已经过身份验证的用户 ID。var guarded = guard(func(w http.ResponseWriter, r *http.Request) { authUserID := r.Context().Value(keyAuthUserID).(string) })获取认证用户func getAuthUser(w http.ResponseWriter, r *http.Request) { ctx := r.Context() authUserID := ctx.Value(keyAuthUserID).(string) var user User if err := db.QueryRowContext(ctx, ` SELECT username, avatar_url FROM users WHERE id = $1 `, authUserID).Scan(&user.Username, &user.AvatarURL); err == sql.ErrNoRows { http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot) return } else if err != nil { respondError(w, fmt.Errorf("could not query auth user: %v", err)) return } user.ID = authUserID respond(w, user, http.StatusOK) }我们使用 Guard 中间件来获取当前经过身份认证的用户 ID 并查询数据库。这一部分涵盖了后端的 OAuth 流程。在下一篇帖子中，我们将会看到如何开始与其他用户的对话。源代码via: https://nicolasparada.netlify.com/posts/go-messenger-oauth/作者：Nicolás Parada 选题：lujun9972 译者：PsiACE 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
这是一系列关于构建“即时消息”应用的新帖子。你应该对这类应用并不陌生。有了它们的帮助，我们才可以与朋友畅聊无忌。Facebook Messenger、WhatsApp 和 Skype 就是其中的几个例子。正如你所看到的那样，这些应用允许我们发送图片、传输视频、录制音频、以及和一大帮子人聊天等等。当然，我们的教程应用将会尽量保持简单，只在两个用户之间发送文本消息。我们将会用 CockroachDB 作为 SQL 数据库，用 Go 作为后端语言，并且用 JavaScript 来制作 web 应用。这是第一篇帖子，我们将会讲述数据库的设计。CREATE TABLE users ( id SERIAL NOT NULL PRIMARY KEY, username STRING NOT NULL UNIQUE, avatar_url STRING, github_id INT NOT NULL UNIQUE );显然，这个应用需要一些用户。我们这里采用社交登录的形式。由于我选用了 GitHub，所以这里需要保存一个对 GitHub 用户 ID 的引用。CREATE TABLE conversations ( id SERIAL NOT NULL PRIMARY KEY, last_message_id INT, INDEX (last_message_id DESC) );每个对话都会引用最近一条消息。每当我们输入一条新消息时，我们都会更新这个字段。我会在后面添加外键约束。… 你可能会想，我们可以先对对话进行分组，然后再通过这样的方式获取最近一条消息。但这样做会使查询变得更加复杂。CREATE TABLE participants ( user_id INT NOT NULL REFERENCES users ON DELETE CASCADE, conversation_id INT NOT NULL REFERENCES conversations ON DELETE CASCADE, messages_read_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (user_id, conversation_id) );尽管之前我提到过对话只会在两个用户之间进行，但我们还是采用了允许向对话中添加多个参与者的设计。因此，在对话和用户之间有一个参与者表。为了知道用户是否有未读消息，我们在消息表中添加了“读取时间”（messages_read_at）字段。每当用户在对话中读取消息时，我们都会更新它的值，这样一来，我们就可以将它与对话中最后一条消息的“创建时间”（created_at）字段进行比较。CREATE TABLE messages ( id SERIAL NOT NULL PRIMARY KEY, content STRING NOT NULL, user_id INT NOT NULL REFERENCES users ON DELETE CASCADE, conversation_id INT NOT NULL REFERENCES conversations ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), INDEX(created_at DESC) );尽管我们将消息表放在最后，但它在应用中相当重要。我们用它来保存对创建它的用户以及它所出现的对话的引用。而且还可以根据“创建时间”（created_at）来创建索引以完成对消息的排序。ALTER TABLE conversations ADD CONSTRAINT fk_last_message_id_ref_messages FOREIGN KEY (last_message_id) REFERENCES messages ON DELETE SET NULL;我在前面已经提到过这个外键约束了，不是吗:D有这四张表就足够了。你也可以将这些查询保存到一个文件中，并将其通过管道传送到 Cockroach CLI。首先，我们需要启动一个新节点：cockroach start --insecure --host 127.0.0.1然后创建数据库和这些表：cockroach sql --insecure -e "CREATE DATABASE messenger" cat schema.sql | cockroach sql --insecure -d messenger这篇帖子就到这里。在接下来的部分中，我们将会介绍「登录」，敬请期待。源代码via: https://nicolasparada.netlify.com/posts/go-messenger-schema/作者：Nicolás Parada 选题：lujun9972 译者：PsiACE 校对：wxy本文由 LCTT 原创编译，Linux 中国 荣誉推出
我们继续 无密码验证 的文章。上一篇文章中，我们用 Go 写了一个 HTTP 服务，用这个服务来做无密码验证 API。今天，我们为它再写一个 JavaScript 客户端。我们将使用 这里的 这个单页面应用程序（SPA）来展示使用的技术。如果你还没有读过它，请先读它。记住流程：用户输入其 email。用户收到一个带有魔法链接的邮件。用户点击该链接、用户验证成功。对于根 URL（/），我们将根据验证的状态分别使用两个不同的页面：一个是带有访问表单的页面，或者是已验证通过的用户的欢迎页面。另一个页面是验证回调的重定向页面。伺服我们将使用相同的 Go 服务器来为客户端提供服务，因此，在我们前面的 main.go 中添加一些路由：router.Handle("GET", "/...", http.FileServer(SPAFileSystem{http.Dir("static")})) type SPAFileSystem struct { fs http.FileSystem } func (spa SPAFileSystem) Open(name string) (http.File, error) { f, err := spa.fs.Open(name) if err != nil { return spa.fs.Open("index.html") } return f, nil } 这个伺服文件放在 static 下，配合 static/index.html 作为回调。你可以使用你自己的服务器，但是你得在服务器上启用 CORS。HTML我们来看一下那个 static/index.html 文件。 Passwordless Demo 单页面应用程序的所有渲染由 JavaScript 来完成，因此，我们使用了一个空的 body 部分和一个 main.js 文件。我们将使用 上篇文章 中的 Router。渲染现在，我们使用下面的内容来创建一个 static/js/main.js 文件：import Router from 'https://unpkg.com/@nicolasparada/router' import { isAuthenticated } from './auth.js' const router = new Router() router.handle('/', guard(view('home'))) router.handle('/callback', view('callback')) router.handle(/^\//, view('not-found')) router.install(async resultPromise => { document.body.innerHTML = '' document.body.appendChild(await resultPromise) }) function view(name) { return (...args) => import(`/js/pages/${name}-page.js`) .then(m => m.default(...args)) } function guard(fn1, fn2 = view('welcome')) { return (...args) => isAuthenticated() ? fn1(...args) : fn2(...args) } 与上篇文章不同的是，我们实现了一个 isAuthenticated() 函数和一个 guard() 函数，使用它去渲染两种验证状态的页面。因此，当用户访问 / 时，它将根据用户是否通过了验证来展示主页或者是欢迎页面。验证现在，我们来编写 isAuthenticated() 函数。使用下面的内容来创建一个 static/js/auth.js 文件：export function getAuthUser() { const authUserItem = localStorage.getItem('auth_user') const expiresAtItem = localStorage.getItem('expires_at') if (authUserItem !== null && expiresAtItem !== null) { const expiresAt = new Date(expiresAtItem) if (!isNaN(expiresAt.valueOf()) && expiresAt > new Date()) { try { return JSON.parse(authUserItem) } catch (_) { } } } return null } export function isAuthenticated() { return localStorage.getItem('jwt') !== null && getAuthUser() !== null } 当有人登入时，我们将保存 JSON 格式的 web 令牌、它的过期日期，以及在 localStorage 上的当前已验证用户。这个模块就是这个用处。getAuthUser() 用于从 localStorage 获取已认证的用户，以确认 JSON 格式的 Web 令牌没有过期。isAuthenticated() 在前面的函数中用于去检查它是否没有返回 null。获取在继续这个页面之前，我将写一些与服务器 API 一起使用的 HTTP 工具。我们使用以下的内容去创建一个 static/js/http.js 文件：import { isAuthenticated } from './auth.js' function get(url, headers) { return fetch(url, { headers: Object.assign(getAuthHeader(), headers), }).then(handleResponse) } function post(url, body, headers) { return fetch(url, { method: 'POST', headers: Object.assign(getAuthHeader(), { 'content-type': 'application/json' }, headers), body: JSON.stringify(body), }).then(handleResponse) } function getAuthHeader() { return isAuthenticated() ? { authorization: `Bearer ${localStorage.getItem('jwt')}` } : {} } export async function handleResponse(res) { const body = await res.clone().json().catch(() => res.text()) const response = { statusCode: res.status, statusText: res.statusText, headers: res.headers, body, } if (!res.ok) { const message = typeof body === 'object' && body !== null && 'message' in body ? body.message : typeof body === 'string' && body !== '' ? body : res.statusText const err = new Error(message) throw Object.assign(err, response) } return response } export default { get, post, } 这个模块导出了 get() 和 post() 函数。它们是 fetch API 的封装。当用户是已验证的，这二个函数注入一个 Authorization: Bearer <token_here> 头到请求中；这样服务器就能对我们进行身份验证。欢迎页我们现在来到欢迎页面。用如下的内容创建一个 static/js/pages/welcome-page.js 文件：const template = document.createElement('template') template.innerHTML = ` Passwordless Demo Access Send Magic Link ` export default function welcomePage() { const page = template.content.cloneNode(true) page.getElementById('access-form') .addEventListener('submit', onAccessFormSubmit) return page } 这个页面使用一个 HTMLTemplateElement 作为视图。这只是一个输入用户 email 的简单表单。为了避免干扰，我将跳过错误处理部分，只是将它们输出到控制台上。现在，我们来写 onAccessFormSubmit() 函数。import http from '../http.js' function onAccessFormSubmit(ev) { ev.preventDefault() const form = ev.currentTarget const input = form.querySelector('input') const email = input.value sendMagicLink(email).catch(err => { console.error(err) if (err.statusCode === 404 && wantToCreateAccount()) { runCreateUserProgram(email) } }) } function sendMagicLink(email) { return http.post('/api/passwordless/start', { email, redirectUri: location.origin + '/callback', }).then(() => { alert('Magic link sent. Go check your email inbox.') }) } function wantToCreateAccount() { return prompt('No user found. Do you want to create an account?') } 它对 /api/passwordless/start 发起了 POST 请求，请求体中包含 email 和 redirectUri。在本例中它返回 404 Not Found 状态码时，我们将创建一个用户。function runCreateUserProgram(email) { const username = prompt("Enter username") if (username === null) return http.post('/api/users', { email, username }) .then(res => res.body) .then(user => sendMagicLink(user.email)) .catch(console.error) } 这个用户创建程序，首先询问用户名，然后使用 email 和用户名做一个 POST 请求到 /api/users。成功之后，给创建的用户发送一个魔法链接。回调页这是访问表单的全部功能，现在我们来做回调页面。使用如下的内容来创建一个 static/js/pages/callback-page.js 文件：import http from '../http.js' const template = document.createElement('template') template.innerHTML = ` Authenticating you ` export default function callbackPage() { const page = template.content.cloneNode(true) const hash = location.hash.substr(1) const fragment = new URLSearchParams(hash) for (const [k, v] of fragment.entries()) { fragment.set(decodeURIComponent(k), decodeURIComponent(v)) } const jwt = fragment.get('jwt') const expiresAt = fragment.get('expires_at') http.get('/api/auth_user', { authorization: `Bearer ${jwt}` }) .then(res => res.body) .then(authUser => { localStorage.setItem('jwt', jwt) localStorage.setItem('auth_user', JSON.stringify(authUser)) localStorage.setItem('expires_at', expiresAt) location.replace('/') }) .catch(console.error) return page } 请记住……当点击魔法链接时，我们会来到 /api/passwordless/verify_redirect，它将把我们重定向到重定向 URI，我们将放在哈希中的 JWT 和过期日期传递给 /callback。回调页面解码 URL 中的哈希，提取这些参数去做一个 GET 请求到 /api/auth_user，用 JWT 保存所有数据到 localStorage 中。最后，重定向到主页面。主页创建如下内容的 static/pages/home-page.js 文件：import { getAuthUser } from '../auth.js' export default function homePage() { const authUser = getAuthUser() const template = document.createElement('template') template.innerHTML = ` Passwordless Demo Welcome back, ${authUser.username}