这次真成全栈工程师了。第三个 Go 语言项目,简单地实现了一个基于 TCP 连接的聊天室。

image-20240425124404153

实现功能也比较简单,用户输入昵称进入聊天室,进入聊天室后会向其他用户广播,用户可以看到聊天室的所有聊天记录和当前在线人数,支持@其他人,支持敏感词检测。

由于本项目也是基于书籍中的教程进行的开发,所以在此还是大概记录一下完成该项目学到的东西。

WebSocket

本项目最重要的组成之一,本项目便是基于 WebSocket 进行开发的。

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,用于在 Web 应用程序中创建实时、双向的通信通道。

传统的 HTTP 请求通常是一次请求、一次相应,而 WebSocket 则可以建立一个持久连接,允许服务器即时向客户端推送数据,同时也可以接受客户端发送的数据。 WebSocket 相比于传统的轮询或长轮询方式,能够显著减少网络流量和延迟,提高数据传输的效率和速度。它对实时 Web 应用程序和在线游戏的开发非常有用。

WebSocket 可以在浏览器和服务器之间建立一条双向通信的通道,实现服务器主动向浏览器推送消息,而无需浏览器向服务器不断发送请求。其原理是在浏览器和服务器之间建立一个 “套接字”,通过 “握手” 的方式进行数据传输。由于该协议需要浏览器和服务器都支持,因此需要在应用程序中对其进行判断和处理。

原理

WebSocket 是什么

WebSocket 是 HTML5 开始推出的基于 TCP 协议的双向通信协议,其优势在于与 HTTP 协议兼容、开销小、通信高效。WebSocket 让客户端和服务器之间建立连接,并通过这个持久连接实时地进行双向数据传输。

其实 WebSocket 最主要的特点就是建立了一个可持久化的 TCP 连接,这个连接会一直保留,直到客户端或者服务器发起中断请求为止。WebSocket 通过 HTTP/1.1 协议中的 Upgrade 头信息来告诉服务器,希望协议从 HTTP/1.1 升级到 WebSocket 协议。

WebSocket 建立在 HTTP 协议之上,所有的 WebSocket 请求都会通过普通的 HTTP 协议发送出去,然后在服务器端根据 HTTP 协议识别特定的头信息 Upgrade,服务端也会判断请求信息中 Upgrade 是否存在。 这里面 HTTP 是必不可少的,不然 WebSocket 根本无法建立。特别的,WebSocket 在握手时采用了 Sec-WebSocket-Key 加密处理,并采用 SHA-1 签名。

一旦建立了 WebSocket 连接,客户端和服务器端就可以互相发送二进制流或 Unicode 字符串。所有的数据都是经过 mask 处理过的,mask 的值是由服务器端随机生成的。在数据进行发送之前,必须先进行 mask 处理,这样可以有效防止数据被第三方恶意篡改。

最后需要说明一下的是,WebSocket 的通信协议是基于帧(数据包)的。在数据发送时,一个完整的数据包可以分为多个帧进行发送,而每一个帧都包含了数据的一部分,同时还包含了帧头信息。

区别

WebSocket 和 HTTP

HTTP 是一个无状态的协议,使客户端向服务器请求资源,并从服务器接受响应。客户端使用 HTTP 请求/响应语法,即请求发送到服务器之后,服务器向客户端返回 HTML 文件、图像和其他媒体内容。

WebSocket 通信协议尝试在较大范围内改进 Web 实时通信和插件技术,并提供全双工基于事件的通信而无需采用低效的轮询方式。开发人员可以从 Web 浏览器的 JS 端轻松地创建 WebSocket 连接并发送数据,进而实现应用程序的实时数据传输的实现。

由于 WebSocket 是面向消息的,因此它更加适用于实时通信,而 HTTP 更适用于请求和服务器-客户端通信的响应。

img

区别总结

  • 连接方式不同: HTTP 是一种单向请求-响应协议,每次请求需要重新建立连接,而 WebSocket 是一种双向通信协议,使用长连接实现数据实时推送。
  • 数据传输方式不同: HTTP 协议中的数据传输是文本格式的,而 WebSocket 可以传输文本和二进制数据。
  • 通信类型不同: HTTP 主要用于客户端和服务器之间的请求和响应,如浏览器请求网页和服务器返回网页的 HTML 文件。WebSocket 可以实现双向通信,常常用于实时通信场景。
  • 性能方面不同: 由于 HTTP 的每次请求都需要建立连接和断开连接,而 WebSocket 可以在一次连接上进行多次通信,WebSocket 在性能上比 HTTP 有优势。

WebSocket 和 TCP

WebSocket 和 HTTP 都是基于 TCP 协议的应用层协议。

  • 层次结构: WebSocket 是应用层协议,而 TCP 是传输层协议。
  • 协议特点: TCP 是一种面向连接的协议,使用三次握手建立连接,提供可靠的数据传输。而 WebSocket 是一种无状态的协议,使用 HTTP 协议建立连接,可以进行双向通信,WebSocket 的数据传输比 TCP 更加轻量级。
  • 数据格式: TCP 传输的数据需要自定义数据格式,而 WebSocket 可以支持多种数据格式,如 JSON、XML、二进制等。WebSocket 数据格式化可以更好的支持 Web 应用开发。

连接方式: TCP 连接的是物理地址和端口号,而 WebSocket 连接的是 URL 地址和端口号。

img

WebSocket 和 Socket

协议不同

Socket 是基于传输层 TCP 协议的,而 Websocket 是基于 HTTP 协议的。Socket 通信是通过 Socket 套接字来实现的,而 Websocket 通信是通过 HTTP 的握手过程实现的。

持久化连接

传统的 Socket 通信是基于短连接的,通信完成后即断开连接。而 Websocket 将 HTTP 协议升级后,实现了长连接,即建立连接后可以持续通信,避免了客户端与服务端频繁连接和断开连接的过程。

双向通信

传统的 Socket 通信只支持单向通信,即客户端向服务端发送请求,服务端进行响应。而 Websocket 可以实现双向通信,即客户端和服务端都可以发起消息,实时通信效果更佳。

效率

Socket 通信具有高效性和实时性,因为传输数据时没有 HTTP 协议的头信息,而 Websocket 除了HTTP协议头之外,还需要发送额外的数据,因此通信效率相对较低。

应用场景

Socket 适用于实时传输数据,例如在线游戏、聊天室等需要快速交换数据的场景。而 Websocket 适用于需要长时间保持连接的场景,例如在线音视频、远程控制等。

基础代码框架

在基本了解 WebSocket 之后,尝试去使用 TCP 和 WebSocket 分别来写一个简单的聊天室。具体代码就不在这里赘述了,可以去下面的参考文献中找一下。

OK,在基本学习了如何使用 WebSocket 来完成一个聊天室的设计之后,我们来正式开启项目的设计。基础框架和流程如下:

image

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
├── README.md
├── cmd
│ ├── chatroom
│ └── main.go
├── go.mod
├── go.sum
├── logic
│ ├── broadcast.go
│ ├── message.go
│ └── user.go
├── server
│ ├── handle.go
│ ├── home.go
│ └── websocket.go
└── template
└── home.html

相关目录说明如下:

  • cmd:该目录几乎是 Go 圈约定俗成的,Go 官方以及开源界推荐的方式,用于存放 main.main;
  • logic:用于存放项目核心业务逻辑代码,和 service 目录是类似的作用;
  • server:存放 server 相关代码,虽然这是 WebSocket 项目,但也可以看成是 Web 项目,因此可以理解成存放类似 controller 的代码;
  • template:存放静态模板文件;

四个类型

User:

1
2
3
4
5
6
7
8
9
10
11
12
type User struct {
UID int `json:"uid"`
NickName string `json:"nickname"`
EnterAt time.Time `json:"enter_at"`
Addr string `json:"addr"`
MessageChannel chan *Message `json:"-"`
Token string `json:"token"`

conn *websocket.Conn

isNew bool
}

broadcaster:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// broadcaster 广播器
type broadcaster struct {
// 所有聊天室用户
users map[string]*User

// 所有 channel 统一管理,可以避免外部乱用

enteringChannel chan *User
leavingChannel chan *User
messageChannel chan *Message

// 判断该昵称用户是否可进入聊天室(重复与否):true 能,false 不能
checkUserChannel chan string
checkUserCanInChannel chan bool

// 获取用户列表
requestUsersChannel chan struct{}
usersChannel chan []*User
}

Message:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 给用户发送的消息
type Message struct {
// 哪个用户发送的消息
User *User `json:"user"`
Type int `json:"type"`
Content string `json:"content"`
MsgTime time.Time `json:"msg_time"`

ClientSendTime time.Time `json:"client_send_time"`

// 消息 @ 了谁
Ats []string `json:"ats"`
}

offlineProcessor:

1
2
3
4
5
6
7
8
9
type offlineProcessor struct {
n int

// 保存所有用户最近的 n 条消息
recentRing *ring.Ring

// 保存某个用户离线消息(一样 n 条)
userRing map[string]*ring.Ring
}

核心流程

本项目的核心流程分为两个部分,一个是前端的设计,另一个是后端的 API 开发。有关前端的部分我想可能还得一段时间才会去学习,所以这一部分就只写后端的内容。

新用户来了

由于在第二个项目中已经把注册登录功能实现的很好了,所以在本聊天室中并未设置注册登录功能,为了方便识别用户,我们简单地要求用户输入昵称。

昵称在建立 WebSocket 连接时,通过 HTTP 协议传递,因此可以通过 http.Request 获取到。虽然没有注册功能,但依然要解决昵称重复的问题。这里必须引出 Broadcaster 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type broadcaster struct {
// 所有聊天室用户
users map[string]*User

// 所有 channel 统一管理,可以避免外部乱用

enteringChannel chan *User
leavingChannel chan *User
messageChannel chan *Message

// 判断该昵称用户是否可进入聊天室(重复与否):true 能,false 不能
checkUserChannel chan string
checkUserCanInChannel chan bool
}

因为 Broadcaster.Broadcast() 在一个单独的 goroutine 中运行,按照 Go 语言的原则,应该通过通信来共享内存。因此,我们定义了 5 个 channel,用于和其他 goroutine 进行通信。

  • enteringChannel:用户进入聊天室时,通过该 channel 告知 Broadcaster,即将该用户加入 Broadcaster 的 users 中;
  • leavingChannel:用户离开聊天室时,通过该 channel 告知 Broadcaster,即将该用户从 Broadcaster 的 users 中删除,同时需要关闭该用户对应的 messageChannel,避免 goroutine 泄露,后文会讲到;
  • messageChannel:用户发送的消息,通过该 channel 告知 Broadcaster,之后 Broadcaster 将它发送给 users 中的用户;
  • checkUserChannel:用来接收用户昵称,方便 Broadcaster 所在 goroutine 能够无锁判断昵称是否存在;
  • checkUserCanInChannel:用来回传该用户昵称是否已经存在;

image

两个 goroutine 通过两个 channel 进行通讯,因为 conn goroutine(代表用户连接 goroutine)可能很多,通过这种方式,避免了使用锁。

如果用户已存在,连接会断开;否则创建该用户的实例(新建 User 类型):

1
user := logic.NewUser(conn, nickname, req.RemoteAddr)

至此,用户算是进入了聊天室,新用户进入,一方面给 TA 发送欢迎的消息,另一方面需要通知聊天室的其他人,有新用户进来了(新建 Message 类型)。

接下来看看发送消息的过程,发送消息分两情况,它们的处理方式有些差异:

  • 给单个用户(当前)用户发送消息
  • 给聊天室其他用户广播消息

给当前用户发送消息的情况比较简单:conn goroutine 通过用户实例(User)的字段 MessageChannel 将 Message 发送给 write goroutine。

image

给聊天室其他用户广播消息自然需要通过 broadcaster goroutine 来实现:conn goroutine 通过 Broadcaster 的 MessageChannel 将 Message 发送出去,broadcaster goroutine 遍历自己维护的聊天室用户列表,通过 User 实例的 MessageChannel 将消息发送给 write goroutine。

image

用户走了

1
2
3
4
5
6
7
8
9
10
11
12
13
// 6. 用户离开
logic.Broadcaster.UserLeaving(user)
msg = logic.NewNoticeMessage(user.NickName + " 离开了聊天室")
logic.Broadcaster.Broadcast(msg)
log.Println("user:", nickname, "leaves chat")

// 根据读取时的错误执行不同的 Close
if err == nil {
conn.Close(websocket.StatusNormalClosure, "")
} else {
log.Println("read from client error:", err)
conn.Close(websocket.StatusTryAgainLater, "Read from client error")
}

这里主要做了三件事情:

  • 在 Broadcaster 中注销该用户;
  • 给聊天室中其他还在线的用户发送通知,告知该用户已离开;
  • 根据 err 处理不同的 Close 行为。关于 Close 的 Status 可以参考 rfc6455 的 第 7.4 节;

单例模式

Go 不是完全面向对象的语言,只支持部分面向对象的特性。面向对象中的单例模式是一个常见、简单的模式。

简介

该模式规定一个类只允许有一个实例,而且自行实例化并向整个系统提供这个实例。因此单例模式的要点有:

  1. 只有一个实例;
  2. 必须自行创建;
  3. 必须自行向整个系统提供这个实例。

单例模式主要避免一个全局使用的类频繁地创建与销毁。当你想控制实例的数量,或有时候不允许存在多实例时,单例模式就派上用场了。

image

通过该类图我们可以看出,实现一个单例模式有如下要求:

  • 私有、静态的类实例变量;
  • 构造函数私有化;
  • 静态工厂方法,返回此类的唯一实例;

根据实例化的时机,单例模式一般分成饿汉式和懒汉式。

  • 饿汉式:在定义 instance 时直接实例化,private static Singleton instance = new Singleton();
  • 懒汉式:在 getInstance 方法中进行实例化;

那两者有什么区别或优缺点?

  • 饿汉式单例类在自己被加载时就将自己实例化。即便加载器是静态的,饿汉式单例类被加载时仍会将自己实例化。单从资源利用率角度讲,这个比懒汉式单例类稍差些。从速度和反应时间角度讲,则比懒汉式单例类稍好些。
  • 然而,懒汉式单例类在实例化时,必须处理好在多个线程同时首次引用此类时的访问限制问题,特别是当单例类作为资源控制器在实例化时必须涉及资源初始化,而资源初始化很有可能耗费时间。这意味着出现多线程同时首次引用此类的几率变得较大。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 饿汉式单例模式
package singleton

type singleton struct {
count int
}

var Instance = new(singleton)

func (s *singleton) Add() int {
s.count++
return s.count
}

// 这样使用
c := singleton.Instance.Add()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 懒汉式单例模式
package singleton

import (
"sync"
)

type singleton struct {
count int
}

var (
instance *singleton
mutex sync.Mutex
)

func New() *singleton {
mutex.Lock()
if instance == nil {
instance = new(singleton)
}
mutex.Unlock()

return instance
}

func (s *singleton) Add() int {
s.count++
return s.count
}

作用

单例模式是一种创建型设计模式, 让你能够保证一个类只有一个实例, 并提供一个访问该实例的全局节点。

单例模式同时解决了两个问题, 所以违反了单一职责原则

  1. 保证一个类只有一个实例。 为什么会有人想要控制一个类所拥有的实例数量? 最常见的原因是控制某些共享资源 (例如数据库或文件) 的访问权限。

    它的运作方式是这样的: 如果你创建了一个对象, 同时过一会儿后你决定再创建一个新对象, 此时你会获得之前已创建的对象, 而不是一个新对象。

    注意, 普通构造函数无法实现上述行为, 因为构造函数的设计决定了它必须总是返回一个新对象。

一个对象的全局访问节点

  1. 为该实例提供一个全局访问节点。 还记得你 (好吧, 其实是我自己) 用过的那些存储重要对象的全局变量吗? 它们在使用上十分方便, 但同时也非常不安全, 因为任何代码都有可能覆盖掉那些变量的内容, 从而引发程序崩溃。

    和全局变量一样, 单例模式也允许在程序的任何地方访问特定对象。 但是它可以保护该实例不被其他代码覆盖。

    还有一点: 你不会希望解决同一个问题的代码分散在程序各处的。 因此更好的方式是将其放在同一个类中, 特别是当其他代码已经依赖这个类时更应该如此。

goroutine 泄露

在 Go 中,goroutine 的创建成本低廉且调度效率高。Go 运行时能很好的支持具有成千上万个 goroutine 的程序运行,数十万个也并不意外。但是,goroutine 在内存占用方面却需要谨慎,内存资源是有限的,因此你不能创建无限的 goroutine。

每当你在程序中使用 go 关键字启动 goroutine 时,你必须知道该 goroutine 将在何时何地退出。如果你不知道答案,那可能会内存泄漏。

原因

造成goroutine泄露的几个原因:

    1. 从 channel 里读,但是同时没有写入操作
    1. 向 无缓冲 channel 里写,但是同时没有读操作
    1. 向已满的 有缓冲 channel 里写,但是同时没有读操作
    1. select操作在所有case上都阻塞()
    1. goroutine进入死循环,一直结束不了

可见,很多都是因为channel使用不当造成阻塞,从而导致goroutine也一直阻塞无法退出导致的。

实际场景

生产者消费者场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func main() {
newRandStream := func() <-chan int {
randStream := make(chan int)

go func() {
defer fmt.Println("newRandStream closure exited.")
defer close(randStream)
// 死循环:不断向channel中放数据,直到阻塞
for {
randStream <- rand.Int()
}
}()

return randStream
}

randStream := newRandStream()
fmt.Println("3 random ints:")

// 只消耗3个数据,然后去做其他的事情,此时生产者阻塞,
// 若主goroutine不处理生产者goroutine,则就产生了泄露
for i := 1; i <= 3; i++ {
fmt.Printf("%d: %d\n", i, <-randStream)
}

fmt.Fprintf(os.Stderr, "%d\n", runtime.NumGoroutine())
time.Sleep(10e9)
fmt.Fprintf(os.Stderr, "%d\n", runtime.NumGoroutine())
}

生产协程进入死循环,不断产生数据。消费协程,也就是主协程只消费期中的 3 个值,然后主协程就再也不消费 channel 中的数据,去做其他事情了。此时生产协程放了一个数据到 channel 中,但已经不会有协程消费该数据,所以生产协程阻塞。此时,若没有人再消费 channel 中的数据,生产协程是被泄露的协程

解决方法:

总的来说,要解决channel引起的goroutine leak问题,主要是看在channel阻塞goroutine时,该goroutine的阻塞是正常的,还是可能导致协程永远没有机会执行。若可能导致协程永远没有机会执行,则可能会导致协程泄露。 所以,在创建协程时就要考虑到它该如何终止。

解决一般问题的办法就是,当主线程结束时,告知生产线程,生产线程得到通知后,进行清理工作:或退出,或做一些清理环境的工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func main() {
newRandStream := func(done <-chan interface{}) <-chan int {
randStream := make(chan int)

go func() {
defer fmt.Println("newRandStream closure exited.")
defer close(randStream)

for {
select {
case randStream <- rand.Int():
case <-done: // 得到通知,结束自己
return
}
}
}()

return randStream
}


done := make(chan interface{})
randStream := newRandStream(done)
fmt.Println("3 random ints:")

for i := 1; i <= 3; i++ {
fmt.Printf("%d: %d\n", i, <-randStream)
}

// 通知子协程结束自己
// done <- struct{}{}
close(done)
// Simulate ongoing work
time.Sleep(1 * time.Second)
}

上面的代码中,协程通过一个channel来得到结束的通知,这样它就可以清理现场。防止协程泄露。 通知协程结束的方式,可以是发送一个空的struct,更加简单的方式是直接close channel。如上图所示。

master work 场景

在该场景下,我们一般是把工作划分成多个子工作,把每个子工作交给每个goroutine来完成。此时若处理不当,也是有可能发生goroutine泄漏的。我们来看一下实际的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// function to add an array of numbers.
func worker_adder(s []int, c chan int) {
sum := 0
for _, v := range s {
sum += v
}
// writes the sum to the go routines.
c <- sum // send sum to c
fmt.Println("end")
}

func main() {
s := []int{7, 2, 8, -9, 4, 0}

c1 := make(chan int)
c2 := make(chan int)

// spin up a goroutine.
go worker_adder(s[:len(s)/2], c1)
// spin up a goroutine.
go worker_adder(s[len(s)/2:], c2)

//x, y := <-c1, <-c2 // receive from c1 aND C2
x, _:= <-c1
// 输出从channel获取到的值
fmt.Println(x)

fmt.Println(runtime.NumGoroutine())
time.Sleep(10e9)
fmt.Println(runtime.NumGoroutine())
}

以上代码在主协程中,把一个数组分成两个部分,分别交给两个worker协程来计算其值,这两个协程通过channel把结果传回给主协程。 但,在以上代码中,我们只接收了一个channel的数据,导致另一个协程在写channel时阻塞,再也没有执行的机会。 要是我们把这段代码放入一个常驻服务中,看的更加明显:

http server 场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 把数组s中的数字加起来
func sumInt(s []int, c chan int) {
sum := 0
for _, v := range s {
sum += v
}
c <- sum
}

// HTTP handler for /sum
func sumConcurrent2(w http.ResponseWriter, r *http.Request) {
s := []int{7, 2, 8, -9, 4, 0}

c1 := make(chan int)
c2 := make(chan int)

go sumInt(s[:len(s)/2], c1)
go sumInt(s[len(s)/2:], c2)

// 这里故意不在c2中读取数据,导致向c2写数据的协程阻塞。
x := <-c1

// write the response.
fmt.Fprintf(w, strconv.Itoa(x))
}

func main() {
StasticGroutine := func() {
for {
time.Sleep(1e9)
total := runtime.NumGoroutine()
fmt.Println(total)
}
}

go StasticGroutine()

http.HandleFunc("/sum", sumConcurrent2)
err := http.ListenAndServe(":8001", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}

如果运行以上程序,并在浏览器中输入:

1
http://127.0.0.1:8001/sum

并不断刷新浏览器,来不断发送请求,可以看到以下输出:

1
2
3
4
5
6
7
8
2
2
5
6
7
8
9
10

这个输出是我们的http server的协程数量,可以看到:每请求一次,协程数就增加一个,而且不会减少。说明已经发生了协程泄露(goroutine leak)。

解决方法:

解决的办法就是不管在任何情况下,都必须要有协程能够读写channel,让协程不会阻塞。

time.After

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func ProcessMessage(ctx context.Context, in <-chan string) {
for {
select {
case s, ok := <-in:
if !ok {
return
}
// handle `s`
case <-time.After(5 * time.Minute):
// do something
case <-ctx.Done():
return
}
}
}

在标准库 time.After 的文档中有一段说明:

等待持续时间过去,然后在返回的 channel 上发送当前时间。它等效于 NewTimer().C。在计时器触发之前,计时器不会被垃圾收集器回收。

所以,如果还没有到 5 分钟,该函数返回了,计时器就不会被 GC 回收,因此出现了内存泄露。因此大家使用 time.After 时一定要仔细,一般建议不用它,而是使用 time.NewTimer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func ProcessMessage(ctx context.Context, in <-chan string) {
idleDuration := 5 * time.Minute
idleDelay := time.NewTimer(idleDuration)
// 这句必须的
defer idleDelay.Stop()
for {
idleDelay.Reset(idleDuration)
select {
case s, ok := <-in:
if !ok {
return
}
// handle `s`
case <-idleDelay.C:
// do something
case <-ctx.Done():
return
}
}
}

敏感词处理

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

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

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

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

简单替换或正则替换

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

// 2. strings.Replacer
replacer := strings.NewReplacer("坏蛋", "**", "坏人", "**", "发票", "**", "傻子", "**", "傻大个", "**", "傻人", "**")
fmt.Println(replacer.Replace("不要发票,你就是一个傻子,只会发呆"))

// Output: 不要**,你就是一个**,只会发呆

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

DFA

DFA全称为:Deterministic Finite Automaton,即确定有穷自动机。其特征为:有一个有限状态集合和一些从一个状态通向另一个状态的边,每条边上标记有一个符号,其中一个状态是初态,某些状态是终态。但不同于不确定的有限自动机,DFA中不会有从同一状态出发的两条边标志有相同的符号。

敏感词过滤很适合用DFA算法,用户每次输入都是状态的切换,如果出现敏感词,既是终态,就可以结束判断。

我们把数组形式的敏感词整理为一个树状结构,准确的说是一个森林。

image

这样查找敏感词就变成了一个查找路径的问题,如果用户输入的内容中包含一个从根节点到叶子节点的完整路径,就说明包含敏感词。

算法实现逻辑是循环用户输入的字符串,依次查找每个字符是否出现在树的节点上,比如用户输入“你是傻大个”,从第一个字开始判断,“你”不在树的根节点上,进入下一步,“是”也不在根节点上,进入下一步,“傻”出现在了根节点上,这时状态切换,下一步的查找范围变为“傻”的子节点;“大”出现在子节点中,状态再次切换为“大”的子节点;“个”出现在子节点中,并且为叶子节点,所以包含敏感词。

基于朴素贝叶斯分类算法

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

敏感词检测步骤:

  1. 分词:对获取的评论进行分词处理,采用的是jieba分词
  2. 去除无意义词:采用的是哈工大的词表,遍历每一条评论,判断是否在无用词表(这里主要包含特殊字符,标点符号,感叹词等)中,从而达到去除无意词的效果
  3. 通过评论建立自己的词库,采用并集处理,达到词库中词的唯一性
  4. 建立向量:将去除无意词后的评论装换成稀疏矩阵,采用的是多项式模型,这里考虑到评论一般都比较短小,相对来说,几乎每一个词都会影响到最终的判断,所以采用多项式模型,而没有采用伯努利模型
  5. 划分训练集和测试集:采用random.shuffle()函数将数据随机排序,然后再通过切片处理划分数据,为了保证每条评论与其对应的标签保持一致,采用zip()函数将评论和标签绑定在一起
  6. 调用sklearn里面内置的贝叶斯算法接口

总结

到这里本项目也已经基本完成了,就行文章开始写的那样,这并不是一片详细的教程,只是用来记录一下完成这个项目所学到的东西。

总的来说,这个项目的教程写的也不是很详细,很多函数的实现还是要自己去完成,但是核心的内容作者都会很详细地解释,而且重要的内容其实都可以搜到。

现在我知道为什么这个项目是基于 TCP,而不是 UDP 的了。

参考文献

WebSocket:

单例模式:

goroutine 泄露:

敏感词检测: