蚊章汇 - Nicolás Parada
type: channel
link: https://linuxcn.bwsl.wang/author/981/
description: Nicolás Parada
items: 构建一个即时消息应用（九）：Conversation 页面
items: 构建一个即时消息应用（八）：Home 页面
items: 构建一个即时消息应用（七）：Access 页面
items: 构建一个即时消息应用（六）：仅用于开发的登录
items: 构建一个即时消息应用（五）：实时消息
items: 构建一个即时消息应用（四）：消息
items: 构建一个即时消息应用（三）：对话
items: 构建一个即时消息应用（二）：OAuth
items: 构建一个即时消息应用（一）：模式
items: 无密码验证：客户端

构建一个即时消息应用（九）：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…

构建一个即时消息应用（一）：模式
type: item
date: 2019-09-28T05:15:15+08:00
description: 这是一系列关于构建“即时消息”应用的新帖子。你应该对这类应用并不陌生。有了它们的帮助，我们才可以与朋友畅聊无忌。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-messen…

无密码验证：客户端
type: item
date: 2018-07-12T19:41:37+08:00
description: 我们继续 无密码验证 的文章。上一篇文章中，我们用 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…