在我们的即时消息应用中,消息表现为两个参与者对话的堆叠。-- Nicolás Parada

本文是该系列的第三篇。

◈ 第一篇:模式◈ 第二篇: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中国 荣誉推出

消息已读未读的模型设计_构建一个即时消息应用(三):对话 | Linux 中国相关推荐

  1. linux access函数_构建一个即时消息应用(九):Conversation 页面 | Linux 中国

    导读:在这篇文章中,我们将对对话页面进行编码. 本文字数:5504,阅读时长大约:6分钟https://linux.cn/article-12723-1.html作者:Nicolás Parada译者 ...

  2. 消息已读未读的模型设计_阿里云技术专家分享:现代 IM 系统中消息推送和存储架构的实现...

    前言 IM 全称是"Instant Messaging",中文名是即时通讯.在这个高度信息化的移动互联网时代,生活中 IM 类产品已经成为必备品,比较有名的如钉钉.微信.QQ 等以 ...

  3. 面试官:群聊消息的已读未读功能,你来设计一个?

    欢迎关注方志朋的博客,回复"666"获面试宝典 一朋友和我讨论他前段时间面试某大公司的一题目 : 企业IM比如企业微信.钉钉里面的群消息的有个已读未读的功能,发送者刚发出消息时,当 ...

  4. 群聊消息“已读”/“未读” 功能解决方案!

    一朋友和我讨论他前段时间面试某大公司的一题目: 企业IM比如企业微信.钉钉里面的群消息的有个已读未读的功能,发送者刚发出消息时,当前群里其他群成员都是未读状态,陆陆续续有人看了这个消息,这时候消息的详 ...

  5. mysql消息已读未读_Redis实现信息已读未读状态提示

    本文为大家分享了Redis实现信息已读未读状态提示的关键代码,希望可以给大家一些启发,具体内容如下 前提: 假如现在有2个模块需要提示消息:只要存在用户在上个时间点之后没有看过的信息就提示用户有新的信 ...

  6. IM群聊消息的已读未读功能在存储空间方面的实现思路探讨

    1.引言 IM系统中,特别是在企业应用场景下,消息的已读未读状态是一个强需求. 以阿里的钉钉为例,钉钉的产品定位是用于商务交流,其"强制已读回执"功能,让职场人无法再"假 ...

  7. 面试题:群聊消息的已读未读设计

    点击上方"Java之间",选择"置顶或者星标" 你关注的就是我关心的! 作者:小猿学习笔记 一朋友和我讨论他前段时间面试某大公司的一题目 : 企业IM比如企业微 ...

  8. vue+websocket+nodejs实现聊天室 - 消息已读未读

    前言 上一篇讲了聊天室增加一对一单聊,这次讲如何新增已读未读状态. 大概思路: 服务器返回消息列表,增加参数status为1,单聊和群聊通过过滤状态区分对应未读数量,当前聊天,直接在currentMe ...

  9. Android已读未读功能,android – 将SMS消息标记为已读/未读或删除在KitKat中不起作用的消息...

    我一直在研究短信应用程序.直到昨天,当我将Nexus 4更新为 Android 4.4,KitKat时,一切顺利.诸如将SMS标记为已读/未读以及删除线程中的所有消息等功能已停止工作.为什么会这样?它 ...

最新文章

  1. HTTP和HTTPS协议的区别
  2. linux学习一天一个命令(5)[rm命令]
  3. RTB撕开黑盒子 Part 1: Datacratic's RTB Algorithms
  4. Python中用dict统计列表中元素出现的次数
  5. matlab oqpsk,如何用MATLab画OQPSK星座?
  6. es6 Map、Set和Array.from()
  7. DataUml Design 教程6-DataUML Design 1.1版本号正式公布(支持PD数据模型)
  8. go语言代码连接mysql_【mysql】golang连接mysql操作示例增删改查
  9. 算法 | 一段C语言和汇编的对应分析,揭示函数调用的本质
  10. C++ 运算符重载规则
  11. fmea软件_新版FMEA易错点梳理(一):范围界定和过程流程图-SGS
  12. PAT (Basic Level) Practice1022 D进制的A+B
  13. c语言二级考试程序设计题的做题步骤,计算机二级C语言上机考试操作步骤与流程.doc...
  14. GitHub中的神奇开源,汇聚天涯神贴讨论房价涨跌,买房好帮手!
  15. OpenCV-绘制圆角矩形
  16. Python 在线电子零售公司销售数据(Online Retail | Kaggle)关联规则分析(Apriori算法)
  17. 亚洲上海linux_亚洲企鹅中学Linux俱乐部启发社区
  18. sqlite自动转mysql,Sqlite数据库转换为mysql工具SqliteToMysql使用教程
  19. Linux下菜鸟用XMMS(转)
  20. MySQLdb ImportError: libmysqlclient.so.18 No Such File or Directory

热门文章

  1. C#中要使ListBox使用AddRange()时,能够触发SelectedValueChanged事件
  2. linux系统之编译内核实现iptables应用层过滤
  3. iframe 父级元素查找
  4. gnome-mplayer 挂载 srt字幕 乱码
  5. linux单块网卡绑定多个ip及网卡聚合绑定多个ip方法
  6. 华为机试HJ29: 字符串加解密
  7. dubbo几种协议_Dubbo面试(简)
  8. php如何查看openssl扩展安装成功,php如何安装openssl扩展
  9. php java memcached_php-memcached详解
  10. 强烈安利 uTools 我的生产力工具