4.7 非核心功能

在日常的互联网项目开发中,一般先快速开发出一个最小可运行版本(MVP),投入市场验证。之后快速迭代,并进行其他非核心功能的开发。本文介绍聊天室的一些非核心功能如何实现。

说明:这里涉及到的功能,对一个聊天室来说,并不一定就是非核心功能。只是针对本书来说,它是非核心功能,因为没有它们,聊天室也可以正常运作。当然,核心还是非核心,并没有严格的界定。

4.7.1 @ 提醒功能

现在各种聊天工具或社区类网站,基本会支持 @ 提醒的功能。我们的聊天室如何实现它呢?

可以有两种做法:

  1. @ 当做私聊,这条消息只会发给被 @ 的人,这么做的比较少,不过我们可以看如何实现;
  2. 所有人都能收到,但被 @ 的人有不一样的显示提醒;

私信

先看第一种,只关注服务端的实现,但要告知对方这是一条私信。

在广播器中给所有用户广播消息时,做了一个过滤:消息不发给自己。

  1. for _, user := range b.users {
  2. if user.UID == msg.User.UID {
  3. continue
  4. }
  5. user.MessageChannel <- msg
  6. }

私信因为是发给一个人,因此没必要遍历所有人。根据我们的设计,可以直接取出目标用户,进行消息发送。

为了方便服务端和客户端知晓这是一条私信消息,同时服务端发送前知道这是发给谁,在 Message 结构中增加一个字段 To:

  1. type Message struct {
  2. // 哪个用户发送的消息
  3. User *User `json:"user"`
  4. Type int `json:"type"`
  5. Content string `json:"content"`
  6. MsgTime time.Time `json:"msg_time"`
  7. // 消息发送给谁,表明这是一条私信
  8. To string `json:"to"`
  9. Users map[string]*User `json:"users"`
  10. }

接着在接收用户发送消息的地方,对接收到的用户消息进行解析,为 Message.To 字段赋值。

  1. // logic/user.go 中的 ReceiveMessage 方法
  2. // 内容发送到聊天室
  3. sendMsg := NewMessage(u, receiveMsg["content"])
  4. // 解析 content,看是否是一条私信消息
  5. sendMsg.Content = strings.TrimSpace(sendMsg.Content)
  6. if strings.HasPrefix(sendMsg.Content, "@") {
  7. sendMsg.To = strings.SplitN(sendMsg.Content, " ", 2)[0][1:]
  8. }

这句代码别感到奇怪:strings.SplitN(sendMsg.Content, " ", 2)[0][1:] ,Go 中,函数/方法返回的 slice 可以直接取值、reslice。

注意:这个实现要求必须是 @ 开始,消息中间的 @ 没有进行处理。

在广播器中需要对接收到的消息进行处理,由原来的代码改为(else 部分):

  1. if msg.To == "" {
  2. // 给所有在线用户发送消息
  3. for _, user := range b.users {
  4. if user.UID == msg.User.UID {
  5. continue
  6. }
  7. user.MessageChannel <- msg
  8. }
  9. } else {
  10. if user, ok := b.users[msg.To]; ok {
  11. user.MessageChannel <- msg
  12. } else {
  13. // 对方不在线或用户不存在,直接忽略消息
  14. log.Println("user:", msg.To, "not exists!")
  15. }
  16. }

这里如果用户不存在或不在线,选择了直接忽略。当然可以有其他处理方法,比如当做普通广播消息发给所有人或提示发送者,对方目前的状态。

被 @ 的人收到提醒

这种方式是普遍采用的方式,聊天室中所有人都能收到消息,但被 @ 的人有提醒。

首先,我们依然需要在 Message 结构中增加一个 Ats 字段,表示能够一次 @ 多个人。

  1. type Message struct {
  2. // 哪个用户发送的消息
  3. User *User `json:"user"`
  4. Type int `json:"type"`
  5. Content string `json:"content"`
  6. MsgTime time.Time `json:"msg_time"`
  7. // 消息 @ 了谁
  8. Ats []string `json:"ats"`
  9. Users map[string]*User `json:"users"`
  10. }

其次,在 User 接收消息时(ReceiveMessage),同样需要解析出 @ 谁了。这次我们解析出所有被 @ 的人,而且不区分是不是以 @ 开始。

  1. // logic/user.go 中的 ReceiveMessage 方法
  2. // 内容发送到聊天室
  3. sendMsg := NewMessage(u, receiveMsg["content"])
  4. // 解析 content,看看 @ 谁了
  5. reg := regexp.MustCompile(`@[^\s@]{2,20}`)
  6. sendMsg.Ats = reg.FindAllString(sendMsg.Content, -1)

这里要求昵称必须 2-20 个字符,跟前面的昵称校验保持一致。(昵称没有做特殊字符处理)

以上就是服务端要做的事情。

下面看看前端。因为前端不是重点,我们只会简单的提示有人 @ 你,在将消息 push 到 msgList 之前做提示,5 秒后消失。

  1. if (data.ats != null) {
  2. data.ats.forEach(function(nickname) {
  3. if (nickname == '@'+that.nickname) {
  4. that.usertip = '有人 @ 你了';
  5. }
  6. })
  7. }

效果图如下:

image

注意,以上做法,方法 1 代码在仓库中没有保留,方法 2 保留了。

4.7.2 敏感词处理

任何由用户产生内容的公开软件,都必须做好敏感词的处理。作为一个聊天室,当然要处理敏感词。

其实敏感词(包括广告)检测一直以来都是让人头疼的话题,很多大厂,比如微信、微博、头条等,每天产生大量内容,它们在处理敏感词这块,会投入很多资源。所以,这不是一个简单的问题,本书不可能深入探讨,但尽可能多涉及一些相关内容。

一般来说,目前敏感词处理有如下方法:

  • 简单替换或正则替换
  • DFA(Deterministic Finite Automaton,确定性有穷自动机算法)
  • 基于朴素贝叶斯分类算法

1)简单替换或正则替换

  1. // 1. strings.Replace
  2. keywords := []string{"坏蛋", "坏人", "发票", "傻子", "傻大个", "傻人"}
  3. content := "不要发票,你就是一个傻子,只会发呆"
  4. for _, keyword := range keywords {
  5. content = strings.ReplaceAll(content, keyword, "**")
  6. }
  7. fmt.Println(content)
  8. // 2. strings.Replacer
  9. replacer := strings.NewReplacer("坏蛋", "**", "坏人", "**", "发票", "**", "傻子", "**", "傻大个", "**", "傻人", "**")
  10. fmt.Println(replacer.Replace("不要发票,你就是一个傻子,只会发呆"))
  11. // Output: 不要**,你就是一个**,只会发呆

类似于上面的代码(两种代码类似),我们会使用一个敏感词列表(坏蛋、发票、傻子、傻大个、傻人),来对目标字符串进行检测与替换。比较适合于敏感词列表和待检测目标字符串都比较小的场景,否则性能会有较大影响。(正则替换和这个是类似的)

2)DFA

DFA 基本思想是基于状态转移来检索敏感词,只需要扫描一次待检测文本,就能对所有敏感词进行检测,所以效率比方案 1 高不少。

假设我们有以下 6 个敏感词需要检测:坏蛋、发票、傻子、傻大个、傻人。那么我们可以先把敏感词中有相同前缀的词组合成一个树形结构,不同前缀的词分属不同树形分支,以上述 6 个敏感词为例,可以初始化成如下 3 棵树:

image

把敏感词组成树形结构有什么好处呢?最大的好处就是可以减少检索次数,我们只需要遍历一次待检测文本,然后在敏感词库中检索出有没有该字符对应的子树就行了,如果没有相应的子树,说明当前检测的字符不在敏感词库中,则直接跳过继续检测下一个字符;如果有相应的子树,则接着检查下一个字符是不是前一个字符对应的子树的子节点,这样迭代下去,就能找出待检测文本中是否包含敏感词了。

我们以文本“不要发票,你就是一个傻子,只会发呆”为例,我们依次检测每个字符,因为前 2 个字符都不在敏感词库里,找不到相应的子树,所以直接跳过。当检测到“发”字时,发现敏感词库中有相应的子树,我们把它记为 tree-1,接着再搜索下一个字符“票”是不是子树 tree-1 的子节点,发现恰好是,接下来再判断“票”这个字符是不是叶子节点,如果是,则说明匹配到了一个敏感词了,在这里“票”这个字符刚好是 tree-1 的叶子节点,所以成功检索到了敏感词:“发票”。接着检测,“你就是一个”这几个字符都没有找到相应的子树,跳过。检测到“傻”字时,处理过程和前面的“发”是一样的,“傻子”的检测过程略过。

接着往后检测,“只会”也跳过。当检测到“发”字时,发现敏感词库中有相应的子树,我们把它记为 tree-3,接着再搜索下一个字符“呆”是不是子树 tree-3 的子节点,发现不是,因此这不是一个敏感词。

大家发现了没有,在我们的搜索过程中,我们只需要扫描一次被检测文本就行了,而且对于被检测文本中不存在的敏感词,如这个例子中的“坏蛋”、“傻大个”和“傻人”,我们完全不会扫描到,因此相比方案一效率大大提升了。

Go 中有一个库实现了该算法:github.com/antlinker/go-dirtyfilter。

3)基于朴素贝叶斯分类算法

贝叶斯分类是一类分类算法的总称,这类算法均以贝叶斯定理为基础,故统称为贝叶斯分类。而朴素朴素贝叶斯分类是贝叶斯分类中最简单,也是常见的一种分类方法。这是一种“半学习”形式的方法,它的准确性依赖于先验概率的准确性。

Go 中有一个库实现了该算法:github.com/jbrukh/bayesian。

小结

对于聊天室来说,每次的内容比较少,简单替换就可以满足大部分需求。实际中会涉及比较多的变种,比如敏感词中间加一些其他字符,有一个简单的方法是初始化一个无效字符库,比如:空格、*、#、@等字符,然后在检测文本前,先将待检测文本中的无效字符去除,这样的话被检测字符中就不存在这些无效字符了。

聊天室加上敏感词处理

聊天室一般发送的内容比较短,因此可以采用简单替换的方法。为了方便随时对敏感词列表进行修改,将敏感词存入配置文件中,通过 viper 库来处理配置文件。

由于不确定哪些地方可能需要用到配置文件中的内容,因此要求配置文件解析尽可能早的进行,同时方便其他地方进行引用或读取。因此进行代码重构,新创建一个包:global,用来存放配置文件和项目根目录等一些全局用的代码。

  1. // global/init.go
  2. func init() {
  3. Init()
  4. }
  5. var RootDir string
  6. var once = new(sync.Once)
  7. func Init() {
  8. once.Do(func() {
  9. inferRootDir()
  10. initConfig()
  11. })
  12. }
  13. // inferRootDir 推断出项目根目录
  14. func inferRootDir() {
  15. cwd, err := os.Getwd()
  16. if err != nil {
  17. panic(err)
  18. }
  19. var infer func(d string) string
  20. infer = func(d string) string {
  21. // 这里要确保项目根目录下存在 template 目录
  22. if exists(d + "/template") {
  23. return d
  24. }
  25. return infer(filepath.Dir(d))
  26. }
  27. RootDir = infer(cwd)
  28. }
  29. func exists(filename string) bool {
  30. _, err := os.Stat(filename)
  31. return err == nil || os.IsExist(err)
  32. }

以上代码核心要讲解的是 sync.Once。该类型的 Do 方法中的代码保证只会执行一次。这正好符合根目录推断和配置文件读取和解析。根据 Go 语言包的执行顺序,我们将相关初始化方法放在了单独的 Init 函数中,然后在 main.go 的 init 方法中调用它:

  1. func init() {
  2. global.Init()
  3. }

为了支持敏感词的动态修改,及时生效,在 global 包中的 config.go 文件做相关处理:

  1. // global/config.go
  2. var (
  3. SensitiveWords []string
  4. )
  5. func initConfig() {
  6. viper.SetConfigName("chatroom")
  7. viper.AddConfigPath(RootDir + "/config")
  8. if err := viper.ReadInConfig(); err != nil {
  9. panic(err)
  10. }
  11. SensitiveWords = viper.GetStringSlice("sensitive")
  12. viper.WatchConfig()
  13. viper.OnConfigChange(func(e fsnotify.Event) {
  14. viper.ReadInConfig()
  15. SensitiveWords = viper.GetStringSlice("sensitive")
  16. })
  17. }

其他配置项,如果不希望每次都通过 viper 调用获取,可以定义为 global 的包级变量,供其他地方使用。

配置文件放在项目根目录的 config/chatroom.yaml 中:

  1. sensitive:
  2. - 坏蛋
  3. - 坏人
  4. - 发票
  5. - 傻子
  6. - 傻大个
  7. - 傻人

在接收到用户发送的消息后,对敏感词进行处理。在 logic/user.go 的 ReceiveMessage 方法中增加对以下函数的调用:sendMsg.Content = FilterSensitive(sendMsg.Content)

  1. // logic/sensitive.go
  2. func FilterSensitive(content string) string {
  3. for _, word := range global.SensitiveWords {
  4. content = strings.ReplaceAll(content, word, "**")
  5. }
  6. return content
  7. }

当用户发送:不要发票,你就是一个傻子,只会发呆。最终效果:

image

4.7.3 离线消息处理(更确切说是最近的消息)

当用户不在线时,这期间发送的消息,是否需要存储,等下次上线时发送给 TA,这就是离线消息处理。

一般来说,聊天室不需要处理离线消息,而且我们的聊天室没有实现注册功能,同一个昵称不同时间可能被不同人使用,因此离线消息存储的意义不大。但有两种情况可以保存离线消息。

  • 对某个用户的 @ 消息
  • 最近发送的 10 条消息

我们聊天室要做到离线消息存储,需要解决一个问题:用户退出再登录,确保是同一个人,而不是另外一个人用了相同的昵称。但因为我们没有实现注册功能,于是这里需要对用户登录后进行一些处理。

1、正确识别同一个用户

目前聊天室虽然通过前端的 localStorage 存储了用户信息,方便记住和让同一个用户自动进入聊天室,但只要用户退出再登录,用户的 UID 就会变。为了正确识别同一个用户,我们需要保证同一个用户的 UID 和昵称都不变。

因为我们的聊天室不要求登录,为了更好的识别同一用户,同时避免恶意用户直接修改 localStorage 的数据,在用户进入聊天室时,为其生成一个 token,用来标识该用户,token 和用户昵称一起,存入 localStorage 中。

因为之前 localStorage 只是存储了用户昵称,所以需要进行修改。

  • 之前的 nickname 改为 curUser,包含 nickname、uid 和 token 等用户信息;
  • localStorage 中存入 curUser,通过 json 进行系列化后存入:localStorage.setItem(‘user’, JSON.stringify(data.user))
  • 建立 WebSocket 连接时,除了之前的 nickname,额外传递 token:new WebSocket(“ws://“+host+”/ws?nickname=”+this.curUser.nickname+”&token=”+this.curUser.token);

为此,服务端要需要进行相关的修改。首先 User 结构增加两个字段:isNew bool 和 token string ,isNew 用来判断进来的用户是不是第一次加入聊天室。相应的,NewUser 方法修改为:

  1. func NewUser(conn *websocket.Conn, token, nickname, addr string) *User {
  2. user := &User{
  3. NickName: nickname,
  4. Addr: addr,
  5. EnterAt: time.Now(),
  6. MessageChannel: make(chan *Message, 8),
  7. Token: token,
  8. conn: conn,
  9. }
  10. if user.Token != "" {
  11. uid, err := parseTokenAndValidate(token, nickname)
  12. if err == nil {
  13. user.UID = uid
  14. }
  15. }
  16. if user.UID == 0 {
  17. user.UID = int(atomic.AddUint32(&globalUID, 1))
  18. user.Token = genToken(user.UID, user.NickName)
  19. user.isNew = true
  20. }
  21. return user
  22. }

当没有传递 token 时,当做新用户处理,为用户生成一个 token:

  1. // logic/user.go
  2. func genToken(uid int, nickname string) string {
  3. secret := viper.GetString("token-secret")
  4. message := fmt.Sprintf("%s%s%d", nickname, secret, uid)
  5. messageMAC := macSha256([]byte(message), []byte(secret))
  6. return fmt.Sprintf("%suid%d", base64.StdEncoding.EncodeToString(messageMAC), uid)
  7. }
  8. func macSha256(message, secret []byte) []byte {
  9. mac := hmac.New(sha256.New, secret)
  10. mac.Write(message)
  11. return mac.Sum(nil)
  12. }

token 的生成算法:

  • 基于 HMAC-SHA256;
  • nickname+secret+uid 构成待 hash 的字符串,记为:message
  • 将 message 使用 HMAC-SHA256 计算 hash,记为:messageMAC
  • 将 messageMAC 使用 base64 进行处理,记为:messageMACStr
  • messageMACStr+“uid”+uid 就是 token

接着看看 token 的解析和校验,解析是为了得到 uid:

  1. // logic/user.go
  2. func parseTokenAndValidate(token, nickname string) (int, error) {
  3. pos := strings.LastIndex(token, "uid")
  4. messageMAC, err := base64.StdEncoding.DecodeString(token[:pos])
  5. if err != nil {
  6. return 0, err
  7. }
  8. uid := cast.ToInt(token[pos+3:])
  9. secret := viper.GetString("token-secret")
  10. message := fmt.Sprintf("%s%s%d", nickname, secret, uid)
  11. ok := validateMAC([]byte(message), messageMAC, []byte(secret))
  12. if ok {
  13. return uid, nil
  14. }
  15. return 0, errors.New("token is illegal")
  16. }
  17. func validateMAC(message, messageMAC, secret []byte) bool {
  18. mac := hmac.New(sha256.New, secret)
  19. mac.Write(message)
  20. expectedMAC := mac.Sum(nil)
  21. return hmac.Equal(messageMAC, expectedMAC)
  22. }

总体的思路就是按照生成 token 的方式,再得到一次 token,然后跟用户传递的 token 进行比较。因为 HMAC-SHA256 得到的结果是二进制的,因此相等比较使用了 hmac 包的 Equal 函数。这里大家可以借鉴下 uid 放入 token 中的技巧。

2、离线消息的实现

能够正确识别用户后,就可以来实现离线消息了。

在 logic 包中创建一个 offline.go 文件,创建 offlineProcessor 结构体对外提供一个单实例:OfflineProcessor。

  1. type offlineProcessor struct {
  2. n int
  3. // 保存所有用户最近的 n 条消息
  4. recentRing *ring.Ring
  5. // 保存某个用户离线消息(一样 n 条)
  6. userRing map[string]*ring.Ring
  7. }
  8. var OfflineProcessor = newOfflineProcessor()
  9. func newOfflineProcessor() *offlineProcessor {
  10. n := viper.GetInt("offline-num")
  11. return &offlineProcessor{
  12. n: n,
  13. recentRing: ring.New(n),
  14. userRing: make(map[string]*ring.Ring),
  15. }
  16. }

由于资源的限制,而且我们是直接将离线消息存在进程的内存中,因此不可能保留所有消息,而是保存最近的 n 条消息,其中 n 可以通过配置文件进行配置。这样的需求,标准库 container/ring 刚好满足。

container/ring 详解

这个包代码量很少,有效代码行数:87,包含注释和空格也就 141 行。因此,我们可以详细学习下它的实现。

从名字知晓,ring 实现了一个环形的链表,因此它没有起点或终点,指向环中任何元素的指针都可用作整个环的引用。空环表示为 nil 环指针。环的零值是一个包含一个元素,元素值是 nil 的环,如:

  1. var r ring.Ring
  2. fmt.Println(r.Len()) // Output: 1
  3. fmt.Println(r.Value) // Output: nil

但实际使用时,应该通过 New 函数来获得一个 Ring 的实例指针。

看看 Ring 结构体:

  1. type Ring struct {
  2. next, prev *Ring
  3. Value interface{} // for use by client; untouched by this library
  4. }

该结构体同时包含了 next 和 prev 字段,方便进行正反两个方向进行移动。我们可以通过 ring.New(n int) 函数得到一个 Ring 的实例指针,n 表示环的元素个数。

  1. func New(n int) *Ring {
  2. if n <= 0 {
  3. return nil
  4. }
  5. r := new(Ring)
  6. p := r
  7. for i := 1; i < n; i++ {
  8. p.next = &Ring{prev: p}
  9. p = p.next
  10. }
  11. p.next = r
  12. r.prev = p
  13. return r
  14. }

New 函数一共创建了 n 个 Ring 实例指针,在 for 循环中,将这 n 个 Ring 实例指针链接起来。

为了更好的理解包中其他方法,我们使用一个图来表示。先构造一个 5 个元素的环,同时将每个元素的值分别设置为 1-5:

  1. r := ring.New(5)
  2. n := r.Len()
  3. for i := 1; i <= n; i++ {
  4. r.Value = i
  5. r = r.Next()
  6. }

其中,Len 获得当前环的元素个数,时间复杂度是 O(n)。如图:

image

当前 r 的值是 1(图中黑色箭头所指,这是为了表示方便,虚拟的)。分别看看 Ring 结构的方法。注意,移动相关的方法,都应该用返回值赋值给原 r,比如:r = r.Next()。

1)r.Next() 和 r.Prev()

这两个方法很简单。当前 r 代表值是 1 的元素,r.Next() 返回的 r 就代表值是 2 的元素;而 r.Prev() 返回的 r 则代表值是 5 的元素。

2)r.Move()

Next 和 Prev 一次只能移动一步(注意,可以理解为移动的是上图中黑色的箭头),而 Move 可以通过指定 n 来告知移动多少步,负数表示向后移动,正数表示向前移动。实际上,内部还是依赖于 Next 或 Prev 进行移动的。

这里要特别提醒一下,因为是环,所以参数 n 应该在 n % r.Len() 这个范围,否则做的是无用功。因为环的长度需要额外 O(n) 的时间计算,因此对 n 并没有做 n % r.Len() 的处理,传递的是多少就进行多少步移动,虽然最后结果跟 n % r.Len() 是一样的。

  1. func (r *Ring) Move(n int) *Ring {
  2. if r.next == nil {
  3. return r.init()
  4. }
  5. switch {
  6. case n < 0:
  7. for ; n < 0; n++ {
  8. r = r.prev
  9. }
  10. case n > 0:
  11. for ; n > 0; n-- {
  12. r = r.next
  13. }
  14. }
  15. return r
  16. }

比如 r.Move(-2) 则把上图中的箭头移到了元素 4 处。

3)r.Do()

这是一个方便的遍历环的方法。该方法接收一个回调函数,函数的参数是当前环元素的 Value。该遍历是按照向前的方向进行的。因此,我们可以这样输出我们初始化的环:

  1. r.Do(func(value interface{}){
  2. fmt.Print(value.(int), " ")
  3. })

输出:

  1. 1 2 3 4 5

4)r.Link() 和 r.Unlink()

这两个函数的作用相反,但接收参数不同。我们先看 r.Link(),向环中增加一个元素 6:

  1. nr := &ring.Ring{Value: 6}
  2. or := r.Link(nr)

加上以上代码后,结果如图:

image

类似的,r.Unlink 则是删除元素,参数 n 表示从下个元素起删除 n%r.Len() 个元素。

  1. dr := r.Unlink(3)

image

从图中可以看出,环形链表被分成了两个,原来那个即 r, 从 1 开始,依次是 4、5,而被 unlink 掉的,即 dr,从 6 开始,依次是 2、3。

讲完 container/ring,我们回到离线消息上来。

离线消息实现的两个核心方法:存和取

先看离线消息如何存。

  1. func (o *offlineProcessor) Save(msg *Message) {
  2. if msg.Type != MsgTypeNormal {
  3. return
  4. }
  5. o.recentRing.Value = msg
  6. o.recentRing = o.recentRing.Next()
  7. for _, nickname := range msg.Ats {
  8. nickname = nickname[1:]
  9. var (
  10. r *ring.Ring
  11. ok bool
  12. )
  13. if r, ok = o.userRing[nickname]; !ok {
  14. r = ring.New(o.n)
  15. }
  16. r.Value = msg
  17. o.userRing[nickname] = r.Next()
  18. }
  19. }
  • 根据 Ring 的使用方式,将用户消息直接存入 recentRing 中,并后移一个位置;
  • 判断消息中是否有 @ 谁,需要单独为它保存一个消息列表;

这个方法在广播完消息后调用。

  1. case msg := <-b.messageChannel:
  2. // 给所有在线用户发送消息
  3. for _, user := range b.users {
  4. if user.UID == msg.User.UID {
  5. continue
  6. }
  7. user.MessageChannel <- msg
  8. }
  9. OfflineProcessor.Save(msg)

接着看用户离线后,再次进入聊天室取消息的实现。

  1. func (o *offlineProcessor) Send(user *User) {
  2. o.recentRing.Do(func(value interface{}) {
  3. if value != nil {
  4. user.MessageChannel <- value.(*Message)
  5. }
  6. })
  7. if user.isNew {
  8. return
  9. }
  10. if r, ok := o.userRing[user.NickName]; ok {
  11. r.Do(func(value interface{}) {
  12. if value != nil {
  13. user.MessageChannel <- value.(*Message)
  14. }
  15. })
  16. delete(o.userRing, user.NickName)
  17. }
  18. }

首先遍历最近消息,发送给该用户。之后,如果不是新用户,查询是否有 @ 该用户的消息,有则发送给它,之后将这些消息删除。因为最近的消息是所有用户共享的,不能删除;@ 用户的消息是用户独有的,可以删除。

很显然,这个方法在用户进入聊天室后调用:

  1. case user := <-b.enteringChannel:
  2. // 新用户进入
  3. b.users[user.NickName] = user
  4. b.sendUserList()
  5. OfflineProcessor.Send(user)

细心的读者会发现以上处理方式,用户可能会收到重复的消息。的确如此。关于消息排重我们不做讲解了,大体思路是会为消息生成 ID,消息按时间排序,去重。实际业务中,去重更多会由客户端来做。

4.7.4 小结

一个产品,非核心功能是很多的,需要不断迭代。对于聊天室,肯定还有其他更多的功能可以开发,这就留给有兴趣的读者自己去探索、实现了。

在实现功能的过程中,把需要用到的库能够系统的学习一遍,你会掌握的很牢固,比如本节中的 container/ring,希望在以后的学习工作中,你能够做到。

本图书由 煎鱼©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。

4.7 非核心功能 - 图7