X.org “大眼睛” 11 年来发布第一个小版本X.Org/X11 发布了几个组件的新版本，其中，XEyes 在 11 年后首次出现了非修订版本。这个有趣的 XEyes 演示程序长期以来一直在演示一个跟随鼠标并使用 X SHAPE 渲染的应用程序。xeyes 1.2 版本现在使用了一些发布已久的“新”特性，可以获得更流畅的体验。由于不再做大型的 X.Org 发布，这些 X.Org 组件中有许多已经多年没有新的版本了。太怀念了，当年初次使用 Linux 的 X Window 时，一个个点开的应用里面最有趣的就是这个大眼睛。澳大利亚法院裁决 AI 可作为专利申请的发明人法官指出，澳大利亚法律并没有明确规定专利的申请人必须是人类，而在法官眼里就法律的意图而言 AI 系统可作为发明者。首先发明者是一个施动者名词，施动者可以是发明的人或者东西；其次许多本可以专利的发明因无法确认人类是发明者而不能获得专利；第三法律中没有任何条文能得出相反的结论。或许，越来越充满的 AI 很快会获得比宠物更高的权利，包括财产权等等。Linux 上的即时消息软件 Pidgin 走向末路Pidgin 曾经是 Linux 上的最流行的即时消息软件，原名 Gaim，它能够同时连接多个聊天平台。虽然 Pidgin 软件至今仍在开发中，并且支持 Linux 和 Windows 操作系统，但其使用率已在过去 10 年里急剧下降。而现在，Xubuntu 发行版宣布放弃了 Pidgin。Xubuntu 开发团队称，“随着聊天服务逐渐转向专有和锁定的协议，Pidgin 的用户活跃度已经越来越低，最终迫使我们将之从 Xubuntu 中移除。”当然，你仍然可以手动安装它。虽说是由于各个聊天服务逐渐封闭，但是另外一方面也是没有与时俱进，推出更好的聊天服务的原因。
本文是该系列的第九篇，也是最后一篇。第一篇: 模式第二篇: 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中国 荣誉推出