蚊章汇 - 即时消息
type: channel
link: https://linuxcn.bwsl.wang/tag/%E5%8D%B3%E6%97%B6%E6%B6%88%E6%81%AF/
items: 硬核观察 #353 X.org “大眼睛” 11 年来发布第一个小版本
items: 构建一个即时消息应用（九）：Conversation 页面
items: 构建一个即时消息应用（八）：Home 页面
items: 构建一个即时消息应用（七）：Access 页面
items: 构建一个即时消息应用（六）：仅用于开发的登录
items: 构建一个即时消息应用（五）：实时消息
items: 构建一个即时消息应用（四）：消息
items: 构建一个即时消息应用（三）：对话
items: 构建一个即时消息应用（二）：OAuth

硬核观察 #353 X.org “大眼睛” 11 年来发布第一个小版本
type: item
date: 2021-08-04T23:02:46+08:00
description: 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 中移除。”当然，你仍然可以手动安装它。虽说是由于各个聊天服务逐渐封闭，但是另外一方面也是没有与时俱进，推出更好的聊天服务的原因。

构建一个即时消息应用（九）：Conversation 页面
type: item
date: 2020-10-19T06:02:00+08:00
description: 本文是该系列的第九篇，也是最后一篇。第一篇: 模式第二篇: 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('mes…

构建一个即时消息应用（八）：Home 页面
type: item
date: 2020-10-16T05:32:00+08:00
description: 本文是该系列的第八篇。第一篇: 模式第二篇: 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.otherParticip…

构建一个即时消息应用（七）：Access 页面
type: item
date: 2020-10-10T18:15:00+08:00
description: 本文是该系列的第七篇。第一篇: 模式第二篇: 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')…

构建一个即时消息应用（六）：仅用于开发的登录
type: item
date: 2020-10-07T18:15:00+08:00
description: 本文是该系列的第六篇。第一篇: 模式第二篇: 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-messen…

构建一个即时消息应用（五）：实时消息
type: item
date: 2020-10-05T17:17:27+08:00
description: 本文是该系列的第五篇。第一篇: 模式第二篇: 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: …

构建一个即时消息应用（四）：消息
type: item
date: 2020-10-04T19:45:50+08:00
description: 本文是该系列的第四篇。第一篇: 模式第二篇: 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 me…

构建一个即时消息应用（三）：对话
type: item
date: 2020-03-31T03:40:01+08:00
description: 本文是该系列的第三篇。第一篇：模式第二篇：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, Err…

构建一个即时消息应用（二）：OAuth
type: item
date: 2019-10-28T15:03:29+08:00
description: 上一篇：模式。在这篇帖子中，我们将会通过为应用添加社交登录功能进入后端开发。社交登录的工作方式十分简单：用户点击链接，然后重定向到 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: githubClientSecre…