掃二維碼與項(xiàng)目經(jīng)理溝通
我們?cè)谖⑿派?4小時(shí)期待你的聲音
解答本文疑問/技術(shù)咨詢/運(yùn)營(yíng)咨詢/技術(shù)建議/互聯(lián)網(wǎng)交流
本文是該系列的第三篇。

公司主營(yíng)業(yè)務(wù):做網(wǎng)站、網(wǎng)站建設(shè)、移動(dòng)網(wǎng)站開發(fā)等業(yè)務(wù)。幫助企業(yè)客戶真正實(shí)現(xiàn)互聯(lián)網(wǎng)宣傳,提高企業(yè)的競(jìng)爭(zhēng)能力。創(chuàng)新互聯(lián)公司是一支青春激揚(yáng)、勤奮敬業(yè)、活力青春激揚(yáng)、勤奮敬業(yè)、活力澎湃、和諧高效的團(tuán)隊(duì)。公司秉承以“開放、自由、嚴(yán)謹(jǐn)、自律”為核心的企業(yè)文化,感謝他們對(duì)我們的高要求,感謝他們從不同領(lǐng)域給我們帶來的挑戰(zhàn),讓我們激情的團(tuán)隊(duì)有機(jī)會(huì)用頭腦與智慧不斷的給客戶帶來驚喜。創(chuàng)新互聯(lián)公司推出江南免費(fèi)做網(wǎng)站回饋大家。
在我們的即時(shí)消息應(yīng)用中,消息表現(xiàn)為兩個(gè)參與者對(duì)話的堆疊。如果你想要開始一場(chǎng)對(duì)話,就應(yīng)該向應(yīng)用提供你想要交談的用戶,而當(dāng)對(duì)話創(chuàng)建后(如果該對(duì)話此前并不存在),就可以向該對(duì)話發(fā)送消息。
就前端而言,我們可能想要顯示一份近期對(duì)話列表。并在此處顯示對(duì)話的最后一條消息以及另一個(gè)參與者的姓名和頭像。
在這篇帖子中,我們將會(huì)編寫一些端點(diǎn)endpoint來完成像“創(chuàng)建對(duì)話”、“獲取對(duì)話列表”以及“找到單個(gè)對(duì)話”這樣的任務(wù)。
首先,要在主函數(shù) main() 中添加下面的路由。
router.HandleFunc("POST", "/api/conversations", requireJSON(guard(createConversation)))router.HandleFunc("GET", "/api/conversations", guard(getConversations))router.HandleFunc("GET", "/api/conversations/:conversationID", guard(getConversation))
這三個(gè)端點(diǎn)都需要進(jìn)行身份驗(yàn)證,所以我們將會(huì)使用 guard() 中間件。我們也會(huì)構(gòu)建一個(gè)新的中間件,用于檢查請(qǐng)求內(nèi)容是否為 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)}}
如果請(qǐng)求request不是 JSON 格式,那么它會(huì)返回 415 Unsupported Media Type(不支持的媒體類型)錯(cuò)誤。
type Conversation struct {ID string `json:"id"`OtherParticipant *User `json:"otherParticipant"`LastMessage *Message `json:"lastMessage"`HasUnreadMessages bool `json:"hasUnreadMessages"`}
就像上面的代碼那樣,對(duì)話中保持對(duì)另一個(gè)參與者和最后一條消息的引用,還有一個(gè) 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:"-"`}
我們會(huì)在下一篇文章介紹與消息相關(guān)的內(nèi)容,但由于我們這里也需要用到它,所以先定義了 Message 結(jié)構(gòu)體。其中大多數(shù)字段與數(shù)據(jù)庫(kù)表一致。我們需要使用 Mine 來斷定消息是否屬于當(dāng)前已驗(yàn)證用戶所有。一旦加入實(shí)時(shí)功能,ReceiverID 可以幫助我們過濾消息。
接下來讓我們編寫 HTTP 處理程序。盡管它有些長(zhǎng),但也沒什么好怕的。
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 Userif 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.Usernameif otherParticipant.ID == authUserID {http.Error(w, "Try start a conversation with someone else", http.StatusForbidden)return}var conversationID stringif err := tx.QueryRowContext(ctx, `SELECT conversation_id FROM participants WHERE user_id = $1INTERSECTSELECT 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 Conversationif err = tx.QueryRowContext(ctx, `INSERT INTO conversations DEFAULT VALUESRETURNING 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 = &otherParticipantrespond(w, conversation, http.StatusCreated)}
在此端點(diǎn),你會(huì)向 /api/conversations 發(fā)送 POST 請(qǐng)求,請(qǐng)求的 JSON 主體中包含要對(duì)話的用戶的用戶名。
因此,首先需要將請(qǐng)求主體解析成包含用戶名的結(jié)構(gòu)。然后,校驗(yàn)用戶名不能為空。
type Errors struct {Errors map[string]string `json:"errors"`}
這是錯(cuò)誤消息的結(jié)構(gòu)體 Errors,它僅僅是一個(gè)映射。如果輸入空用戶名,你就會(huì)得到一段帶有 422 Unprocessable Entity(無法處理的實(shí)體)錯(cuò)誤消息的 JSON 。
{"errors": {"username": "Username required"}}
然后,我們開始執(zhí)行 SQL 事務(wù)。收到的僅僅是用戶名,但事實(shí)上,我們需要知道實(shí)際的用戶 ID 。因此,事務(wù)的第一項(xiàng)內(nèi)容是查詢另一個(gè)參與者的 ID 和頭像。如果找不到該用戶,我們將會(huì)返回 404 Not Found(未找到) 錯(cuò)誤。另外,如果找到的用戶恰好和“當(dāng)前已驗(yàn)證用戶”相同,我們應(yīng)該返回 403 Forbidden(拒絕處理)錯(cuò)誤。這是由于對(duì)話只應(yīng)當(dāng)在兩個(gè)不同的用戶之間發(fā)起,而不能是同一個(gè)。
然后,我們?cè)噲D找到這兩個(gè)用戶所共有的對(duì)話,所以需要使用 INTERSECT 語句。如果存在,只需要通過 /api/conversations/{conversationID} 重定向到該對(duì)話并將其返回。
如果未找到共有的對(duì)話,我們需要?jiǎng)?chuàng)建一個(gè)新的對(duì)話并添加指定的兩個(gè)參與者。最后,我們 COMMIT 該事務(wù)并使用新創(chuàng)建的對(duì)話進(jìn)行響應(yīng)。
端點(diǎn) /api/conversations 將獲取當(dāng)前已驗(yàn)證用戶的所有對(duì)話。
func getConversations(w http.ResponseWriter, r *http.Request) {ctx := r.Context()authUserID := ctx.Value(keyAuthUserID).(string)rows, err := db.QueryContext(ctx, `SELECTconversations.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_urlFROM conversationsINNER JOIN messages ON conversations.last_message_id = messages.idINNER JOIN participants other_participantsON other_participants.conversation_id = conversations.idAND other_participants.user_id != $1INNER JOIN users other_users ON other_participants.user_id = other_users.idINNER JOIN participants auth_userON auth_user.conversation_id = conversations.idAND auth_user.user_id = $1ORDER 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 Conversationvar lastMessage Messagevar otherParticipant Userif 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 = &lastMessageconversation.OtherParticipant = &otherParticipantconversations = 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)}
該處理程序僅對(duì)數(shù)據(jù)庫(kù)進(jìn)行查詢。它通過一些聯(lián)接來查詢對(duì)話表……首先,從消息表中獲取最后一條消息。然后依據(jù)“ID 與當(dāng)前已驗(yàn)證用戶不同”的條件,從參與者表找到對(duì)話的另一個(gè)參與者。然后聯(lián)接到用戶表以獲取該用戶的用戶名和頭像。最后,再次聯(lián)接參與者表,并以相反的條件從該表中找出參與對(duì)話的另一個(gè)用戶,其實(shí)就是當(dāng)前已驗(yàn)證用戶。我們會(huì)對(duì)比消息中的 messages_read_at 和 created_at 兩個(gè)字段,以確定對(duì)話中是否存在未讀消息。然后,我們通過 user_id 字段來判定該消息是否屬于“我”(指當(dāng)前已驗(yàn)證用戶)。
注意,此查詢過程假定對(duì)話中只有兩個(gè)用戶參與,它也僅僅適用于這種情況。另外,該設(shè)計(jì)也不很適用于需要顯示未讀消息數(shù)量的情況。如果需要顯示未讀消息的數(shù)量,我認(rèn)為可以在 participants 表上添加一個(gè)unread_messages_count INT 字段,并在每次創(chuàng)建新消息的時(shí)候遞增它,如果用戶已讀則重置該字段。
接下來需要遍歷每一條記錄,通過掃描每一個(gè)存在的對(duì)話來建立一個(gè)對(duì)話切片slice of conversations并在最后進(jìn)行響應(yīng)。
端點(diǎn) /api/conversations/{conversationID} 會(huì)根據(jù) ID 對(duì)單個(gè)對(duì)話進(jìn)行響應(yīng)。
func getConversation(w http.ResponseWriter, r *http.Request) {ctx := r.Context()authUserID := ctx.Value(keyAuthUserID).(string)conversationID := way.Param(ctx, "conversationID")var conversation Conversationvar otherParticipant Userif err := db.QueryRowContext(ctx, `SELECTIFNULL(auth_user.messages_read_at < messages.created_at, false) AS has_unread_messages,other_users.id,other_users.username,other_users.avatar_urlFROM conversationsLEFT JOIN messages ON conversations.last_message_id = messages.idINNER JOIN participants other_participantsON other_participants.conversation_id = conversations.idAND other_participants.user_id != $1INNER JOIN users other_users ON other_participants.user_id = other_users.idINNER JOIN participants auth_userON auth_user.conversation_id = conversations.idAND auth_user.user_id = $1WHERE 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 = conversationIDconversation.OtherParticipant = &otherParticipantrespond(w, conversation, http.StatusOK)}
這里的查詢與之前有點(diǎn)類似。盡管我們并不關(guān)心最后一條消息的顯示問題,并因此忽略了與之相關(guān)的一些字段,但是我們需要根據(jù)這條消息來判斷對(duì)話中是否存在未讀消息。此時(shí),我們使用 LEFT JOIN 來代替 INNER JOIN,因?yàn)?last_message_id 字段是 NULLABLE(可以為空)的;而其他情況下,我們無法得到任何記錄?;谕瑯拥睦碛桑覀?cè)?has_unread_messages 的比較中使用了 IFNULL 語句。最后,我們按 ID 進(jìn)行過濾。
如果查詢沒有返回任何記錄,我們的響應(yīng)會(huì)返回 404 Not Found 錯(cuò)誤,否則響應(yīng)將會(huì)返回 200 OK 以及找到的對(duì)話。
本篇帖子以創(chuàng)建了一些對(duì)話端點(diǎn)結(jié)束。
在下一篇帖子中,我們將會(huì)看到如何創(chuàng)建并列出消息。

我們?cè)谖⑿派?4小時(shí)期待你的聲音
解答本文疑問/技術(shù)咨詢/運(yùn)營(yíng)咨詢/技術(shù)建議/互聯(lián)網(wǎng)交流