目录
前言
之前在Java中,用 springboot+websocket 实现了一个聊天室:springboot+websocket聊天室(私聊+群聊)
有这几个功能:上线、下线、消息发送到公共频道(群聊)、消息发送给指定用户(私聊)。
这两天Go学了网络编程,也学了 websocket ,所以打算也用 Go + websocket 实现一个聊天室。然后这次用Go实现,还比Java多了个 离线留言
和 上传头像
的功能。
这次用Go实现的聊天室,客户端同样用的html+js实现,就是在以前写的那个页面的基础上,改了亿点,然后还把公共频道和私聊频道分开来了。功能更加完善,可玩性更高了。
功能
- 上线
- 下线
- 上传头像
- 消息发送到公共频道(群聊)
- 消息发送给指定用户(私聊)
- 离线留言
效果(一人分饰多角.jpg😎)
废话不多说,我们直接先来看实现效果。如果没有上传头像,则默认用名称的前两个字作为头像。并且消息框分为左右两边,左边的是别人的消息框,右边的是自己的消息框。
用户上线、群聊
左边为群聊,所有在线用户都可以看。
私聊和留言
右边为私聊,指定接收人,显示在私聊频道。
并且当接收人不在线时,则私聊信息为留言,当接收人上线了,处理私信,将私信内容显示出来。
私聊对象不在线:
当私聊对象一上线,会将留言显示出来,显示出来就删掉了,下次重新登录上线就没有了,除非是有新消息。
私聊对象在线:
其他人看不到私聊信息:
下线
关闭连接
实现
思路
我们通过用户名来区分用户,一个用户一个连接,每个用户可以发送消息,然后一个消息对象需要多个字段来存发送人、接收人等这些信息。
- 所以我们要先定义两个结构体,一个是
用户结构体
,一个是消息结构体
。 - 有了这两个结构体后,我们还需要两个列表,一个存储在线用户的列表,一个存储消息的列表。
- 用户列表中,用户下线时,将用户从这个列表中删除。
- 消息列表中,是为了实现离线留言的功能:指定给某个人发送消息,而这个人不在线时,将消息存储起来;等这个人上线了,再把消息发过去。如果是公共频道或者接收人在线的消息,则不进行存储。
- 用户上线,需要判断上线的用户名是否已存在,存在,则不可以重复上线;不存在,则上线成功,将该用户添加到用户列表,并往公共频道发送一条消息。
- 同时需要在消息列表中,找到有没有接收人为这个用户的消息,有就发送给该用户的私聊频道。
- 用户上线之后,处理消息,是群聊还是私聊。
- 用户下线,关闭连接。
代码
1、定义用户和消息结构体,同时给消息结构体绑定两个方法:一个是解析客户端发来的消息;一个是将消息编码,发给客户端。
然后再定义两个列表
- 用户列表因为是用户名唯一,所以用的map,用户名作为key。
- 消息列表,本来一开始用的切片,但是删除元素不好删,百度了一下,决定使用 list 。
// 定义一个用户结构体
type User struct {
Name string // 用户名
Pic string // 头像图片地址
IsImg bool // 头像是否是图片
Conn *websocket.Conn // 用户连接
}
// 解析base64图片
func (user *User) EncodingBase64() error {
if user.IsImg {
splits := strings.Split(user.Pic, ",")
// 截取文件后缀
imgType := splits[0][strings.LastIndex(splits[0], "/")+1 : strings.LastIndex(splits[0], ";")]
imgType = strings.Replace(imgType, "e", "", -1) // jpeg 去掉 e,改成jpg格式
// 解码base64图片数据
imageBytes, err := base64.StdEncoding.DecodeString(strings.Replace(user.Pic, splits[0]+",", "", 1))
if err != nil {
fmt.Println(err)
return err
}
dirPath := "img"
// 创建目录
err = os.MkdirAll(dirPath, os.ModePerm)
if err != nil {
fmt.Println(err)
return err
}
// 拼接图片路径
//savePath := "聊天室/main/img/" + user.Name + "." + imgType
imgPath := dirPath + "/" + user.Name + "." + imgType // 相对路径
// 保存图片到服务器
err = os.WriteFile(imgPath, imageBytes, 0644)
if err != nil {
fmt.Println(err)
return err
}
user.Pic = imgPath
}
return nil
}
// 定义一个消息结构体
type Msg struct {
SendUser string // 发送人
ReceUser string // 接收人
SendTime string // 发送时间
Msg string // 消息内容
IsPublic bool // 消息类型是否是公开的 true 公开 false 私信
IsRece bool // 接收人是否接收成功 true 接收成功 false 离线还未接收(当接收人离线时,设置为false,当对方上线时,将消息发过去,改为true)
IsSend bool // 是否是发送消息,用于区分发送消息和上线下线消息(true 发送消息 false 上线/下线消息)
IsImg bool // 头像是否是图片
Pic string // 头像图片地址
}
// 解析消息的方法(将客户端返回的消息解析)
func (msg *Msg) ParseMessage(message []byte) error {
fmt.Println(string(message))
err := json.Unmarshal(message, msg)
if err != nil {
fmt.Println(err)
}
return nil
}
// 编码消息(将服务端消息发送给客户端)
func (msg *Msg) EncodeMessage() []byte {
b, _ := json.Marshal(msg) // 直接将对象返回过去
return b
}
var users = make(map[string]User) // 用户列表,用户名作为key
var msgs = list.New() // 消息列表(用于存储私信消息)
2、定义WebSocket连接
// 定义WebSocket连接的升级器。升级器是一个http.HandlerFunc,它将HTTP连接升级为WebSocket连接
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func main() {
http.HandleFunc("/web-socket", func(w http.ResponseWriter, r *http.Request) {
// 在这里处理连接
})
log.Fatal(http.ListenAndServe(":7070", nil))
}
// 在这里面处理连接
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
}
3、开始连接,拿到客户端传过来的用户名,然后校验该用户名是否已在线。
conn, err := upgrader.Upgrade(w, r, nil)
fmt.Println(conn.RemoteAddr().String())
if err != nil {
log.Println("err====>>>", err)
return
}
defer conn.Close()
user := User{}
data := r.FormValue("data") // 获取连接的数据
err := json.Unmarshal([]byte(data), &user)
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("连接发生错误"))
return
}
_, ok := users[user.Name]
if ok { // 当用户已经在线时,不允许重复连接
conn.WriteMessage(websocket.TextMessage, []byte("该用户已连接,不允许重复连接"))
return
}
err = user.EncodingBase64() // 解码用户头像
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("连接发生错误"))
return
}
4、连接没有出错,并且校验通过了,把用户添加到用户列表,然后发送一条 该用户上线 的消息给公共频道。同时处理属于该用户的私信信息。
// 用户上线
user := User{ // 添加用户到用户列表
Name: sendUser,
Conn: conn,
}
users[user.Name] = user
str := fmt.Sprintf("%s 加入聊天室,当前聊天室人数为 %d。", user.Name, len(users))
fmt.Println(str)
// 发送上线消息给其他用户
msg := Msg{
SendUser: user.Name,
SendTime: time.Now().Format("2006-01-02 15:04:05"), // 日期格式化为 yyyy-MM-dd HH:mm:ss 格式
Msg: str,
IsPublic: true,
IsRece: true,
IsSend: false,
IsImg: user.IsImg,
Pic: user.Pic,
}
publicMessage(msg) // 公共消息
// 用户上线时,遍历消息列表,看是否有当前上线用户的未处理的私信
var next *list.Element
for el := msgs.Front(); el != nil; el = next {
next = el.Next()
v := el.Value.(Msg) // 用户上线处理这个用户的私信消息
if v.ReceUser == user.Name && !v.IsRece {
err := user.Conn.WriteMessage(websocket.TextMessage, v.EncodeMessage())
if err != nil {
log.Println(err)
}
msgs.Remove(el) // 处理完成后,将这条私信从消息列表中移除
}
}
5、循环监听连接,读取客户端发过来的消息,进行处理。
// 处理消息
for {
_, message, err := conn.ReadMessage()
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("连接已关闭"))
log.Println(conn.RemoteAddr().String(), "关闭连接", err)
break
}
// 解析消息
msg := Msg{}
err = msg.ParseMessage(message)
if err != nil {
log.Println(err)
break
}
if msg.IsPublic {
// 群聊消息
publicMessage(msg)
} else {
// 私聊消息
privateMessage(msg)
}
}
6、当监听到客户端关闭了连接时,用户列表里删除下线的用户,并发送一条 该用户下线 的消息给公共频道。
// 用户下线
name := user.Name
removeUser(user) // 删除用户
str := fmt.Sprintf("%s 离开了聊天室,当前聊天室人数为 %d。", name, len(users))
fmt.Println(str)
// 发送下线消息给其他用户
msg1 := Msg{
SendUser: name,
SendTime: time.Now().Format("2006-01-02 15:04:05"),
Msg: str,
IsPublic: true,
IsRece: true,
IsSend: false,
IsImg: user.IsImg,
Pic: user.Pic,
}
publicMessage(msg1)
服务端 chat.go 完整代码
package main
import (
"container/list"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/gorilla/websocket"
"log"
"net/http"
"os"
"strings"
"time"
)
/*
聊天室:
上线:输入用户名登录
下线:离线
群聊:在公共频道发送消息,全部人可见
私聊:指定给某个人发送消息,仅那一个人可见
留言:用户下线后,其他人给这个人发送消息,是留言
*/
// 定义一个用户结构体
type User struct {
Name string // 用户名
Pic string // 头像图片地址
IsImg bool // 头像是否是图片
Conn *websocket.Conn // 用户连接
}
// 解析base64图片
func (user *User) EncodingBase64() error {
if user.IsImg {
splits := strings.Split(user.Pic, ",")
// 截取文件后缀
imgType := splits[0][strings.LastIndex(splits[0], "/")+1 : strings.LastIndex(splits[0], ";")]
imgType = strings.Replace(imgType, "e", "", -1) // jpeg 去掉 e,改成jpg格式
// 解码base64图片数据
imageBytes, err := base64.StdEncoding.DecodeString(strings.Replace(user.Pic, splits[0]+",", "", 1))
if err != nil {
fmt.Println(err)
return err
}
dirPath := "img"
// 创建目录
err = os.MkdirAll(dirPath, os.ModePerm)
if err != nil {
fmt.Println(err)
return err
}
// 拼接图片路径
//savePath := "聊天室/main/img/" + user.Name + "." + imgType
imgPath := dirPath + "/" + user.Name + "." + imgType // 相对路径
// 保存图片到服务器
err = os.WriteFile(imgPath, imageBytes, 0644)
if err != nil {
fmt.Println(err)
return err
}
user.Pic = imgPath
}
return nil
}
// 定义一个消息结构体
type Msg struct {
SendUser string // 发送人
ReceUser string // 接收人
SendTime string // 发送时间
Msg string // 消息内容
IsPublic bool // 消息类型是否是公开的 true 公开 false 私信
IsRece bool // 接收人是否接收成功 true 接收成功 false 离线还未接收(当接收人离线时,设置为false,当对方上线时,将消息发过去,改为true)
IsSend bool // 是否是发送消息,用于区分发送消息和上线下线消息(true 发送消息 false 上线/下线消息)
IsImg bool // 头像是否是图片
Pic string // 头像图片地址
}
// 解析消息的方法(将客户端返回的消息解析)
func (msg *Msg) ParseMessage(message []byte) error {
fmt.Println(string(message))
err := json.Unmarshal(message, msg)
if err != nil {
fmt.Println(err)
}
return nil
}
// 编码消息(将服务端消息发送给客户端)
func (msg *Msg) EncodeMessage() []byte {
b, _ := json.Marshal(msg) // 直接将对象返回过去
return b
}
var users = make(map[string]User) // 用户列表,用户名作为key
var msgs = list.New() // 消息列表(用于存储私信消息)
// 定义WebSocket连接的升级器。升级器是一个http.HandlerFunc,它将HTTP连接升级为WebSocket连接
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func main() {
http.HandleFunc("/web-socket", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("err====>>>", err)
return
}
go handleConnection(conn, r)
})
log.Fatal(http.ListenAndServe(":7070", nil))
}
func handleConnection(conn *websocket.Conn, r *http.Request) {
defer conn.Close()
user := User{}
data := r.FormValue("data") // 获取连接的数据
err := json.Unmarshal([]byte(data), &user)
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("连接发生错误"))
return
}
_, ok := users[user.Name]
if ok { // 当用户已经在线时,不允许重复连接
conn.WriteMessage(websocket.TextMessage, []byte("该用户已连接,不允许重复连接"))
return
}
err = user.EncodingBase64() // 解码用户头像
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("连接发生错误"))
return
}
// 用户上线
user.Conn = conn
goLive(user)
// 处理消息
for {
_, message, err := conn.ReadMessage()
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("连接已关闭"))
log.Println(conn.RemoteAddr().String(), "关闭连接", err)
break
}
// 解析消息
msg := Msg{}
err = msg.ParseMessage(message)
if err != nil {
log.Println(err)
break
}
if msg.IsPublic {
// 群聊消息
publicMessage(msg)
} else {
// 私聊消息
privateMessage(msg)
}
}
offLine(user)
}
// 用户上线
func goLive(user User) {
users[user.Name] = user
str := fmt.Sprintf("%s 加入聊天室,当前聊天室人数为 %d。", user.Name, len(users))
fmt.Println(str)
// 发送上线消息给其他用户
msg := Msg{
SendUser: user.Name,
SendTime: time.Now().Format("2006-01-02 15:04:05"), // 日期格式化为 yyyy-MM-dd HH:mm:ss 格式
Msg: str,
IsPublic: true,
IsRece: true,
IsSend: false,
IsImg: user.IsImg,
Pic: user.Pic,
}
publicMessage(msg)
privateMessageHandle(user)
}
// 用户上线,处理自己的私信消息
func privateMessageHandle(user User) {
// 用户上线时,遍历消息列表,看是否有当前上线用户的未处理的私信
var next *list.Element
for el := msgs.Front(); el != nil; el = next {
next = el.Next()
v := el.Value.(Msg) // 用户上线处理这个用户的私信消息
if v.ReceUser == user.Name && !v.IsRece {
err := user.Conn.WriteMessage(websocket.TextMessage, v.EncodeMessage())
if err != nil {
log.Println(err)
}
msgs.Remove(el) // 处理完成后,将这条私信从消息列表中移除
}
}
}
// 公共消息
func publicMessage(msg Msg) {
for _, user := range users {
// 当 msg.IsSend 为true时,说明是发送消息,则必须判断 user.Name != msg.SendUser
if user.Conn != nil && ((msg.IsSend && user.Name != msg.SendUser) || !msg.IsSend) {
err := user.Conn.WriteMessage(websocket.TextMessage, msg.EncodeMessage())
if err != nil {
log.Println(err)
}
}
}
}
// 发送私聊消息给指定用户
func privateMessage(msg Msg) {
for _, user := range users {
if user.Name == msg.ReceUser && user.Conn != nil { // 当接收人在线时
// 发送私聊消息
err := user.Conn.WriteMessage(websocket.TextMessage, msg.EncodeMessage())
if err != nil {
log.Println(err)
}
msg.IsRece = true // 将 IsRece 设置为true
break
}
}
if !msg.IsRece { // 只有接收人离线时,才将消息存到消息列表中
msgs.PushBack(msg)
}
}
// 用户下线
func offLine(user User) {
name := user.Name
removeUser(user) // 删除用户
str := fmt.Sprintf("%s 离开了聊天室,当前聊天室人数为 %d。", name, len(users))
fmt.Println(str)
// 发送下线消息给其他用户
msg1 := Msg{
SendUser: name,
SendTime: time.Now().Format("2006-01-02 15:04:05"),
Msg: str,
IsPublic: true,
IsRece: true,
IsSend: false,
IsImg: user.IsImg,
Pic: user.Pic,
}
publicMessage(msg1)
}
// 用户下线删除用户
func removeUser(user User) {
for _, v := range users {
if v.Name == user.Name {
os.Remove(user.Pic) // 删除头像文件
delete(users, v.Name)
break
}
}
}
2024.11.2服务端 chat.go 代码更新
这个版本的,主要是解决高并发可能引发的问题:
1、增加了最大连接数限制;
2、在读写和删除连接时,增加了锁机制,确保在多用户并发访问时,对WebSocket连接的安全访问;
3、消息处理使用了Channel机制,实现消息的异步发送和接收;
4、增加了一个消息队列,所有消息通过该队列进行统一处理;
5、用户和消息结构体优化;
6、私信处理优化;
7、一些bug修改;
package main
import (
"container/list"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/gorilla/websocket"
"log"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"time"
)
/*
聊天室:
上线:输入用户名登录
下线:离线
群聊:在公共频道发送消息,全部人可见
私聊:指定给某个人发送消息,仅那一个人可见
留言:用户下线后,其他人给这个人发送消息,是留言
*/
// 定义一个用户结构体
type User struct {
Name string // 用户名
Pic string // 头像图片地址
PicName string // 用户名前两位字符
}
// 解析base64图片
func (user *User) EncodingBase64() error {
runes := []rune(user.Name)
user.PicName = string(runes[:2])
if user.Pic != "" {
splits := strings.Split(user.Pic, ",")
// 截取文件后缀
imgType := splits[0][strings.LastIndex(splits[0], "/")+1 : strings.LastIndex(splits[0], ";")]
imgType = strings.Replace(imgType, "e", "", -1) // jpeg 去掉 e,改成jpg格式
// 解码base64图片数据
imageBytes, err := base64.StdEncoding.DecodeString(strings.Replace(user.Pic, splits[0]+",", "", 1))
if err != nil {
fmt.Println(err)
return err
}
dirPath := "img"
// 创建目录
err = os.MkdirAll(dirPath, os.ModePerm)
if err != nil {
fmt.Println(err)
return err
}
// 拼接图片路径
imgPath := dirPath + "/" + user.Name + "." + imgType // 相对路径
// 保存图片到服务器
err = os.WriteFile("聊天室/main/"+imgPath, imageBytes, 0644)
if err != nil {
fmt.Println(err)
return err
}
user.Pic = imgPath
}
return nil
}
// 定义一个消息结构体
type Msg struct {
SendUser string // 发送人
ReceUser string // 接收人
SendTime string // 发送时间
Msg string // 消息内容
IsPublic bool // 消息类型是否是公开的 true 公开 false 私信
IsSend bool // 是否是发送消息,用于区分发送消息和上线下线消息(true 发送消息 false 上线/下线消息)
Pic string // 头像图片地址
PicName string // 用户名前两位字符
}
// 解析消息的方法(将客户端返回的消息解析)
func (msg *Msg) ParseMessage(message []byte) error {
fmt.Println(string(message))
err := json.Unmarshal(message, msg)
if err != nil {
fmt.Println(err)
}
return nil
}
// 编码消息(将服务端消息发送给客户端)
func (msg *Msg) EncodeMessage() []byte {
b, _ := json.Marshal(msg) // 直接将对象返回过去
return b
}
type Message struct {
Conn *websocket.Conn
Msg Msg
}
//====================================================================================================================================
var users = make(map[string]User) // 用户列表,用户名作为key
var privateMsgs = list.New() // 消息列表(用于存储私信消息)
var publickMsgs = list.New() // 消息列表(用于存储公共消息)
// 定义WebSocket连接的升级器。升级器是一个http.HandlerFunc,它将HTTP连接升级为WebSocket连接
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
var (
WebScoketConnMap = make(map[string]*websocket.Conn) // 存储连接对象,key 为用户名,value 为连接对象
messageQueue = make(chan Message, 100) // 创建一个带缓冲的通道
maxConnections int32 = 100 // 设置最大连接数
)
var clientsMux sync.RWMutex
var currentConnections int32 // 当前连接数
func main() {
// 启动消息处理 goroutine
go func() {
for msg := range messageQueue {
// 发送消息
if msg.Conn != nil {
err := msg.Conn.WriteMessage(websocket.TextMessage, msg.Msg.EncodeMessage())
if err != nil {
log.Println("发送消息失败:", err)
}
}
}
}()
http.HandleFunc("/web-socket", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("err====>>>", err)
return
}
// 连接数检查
if atomic.LoadInt32(¤tConnections) >= maxConnections {
conn.WriteMessage(websocket.TextMessage, []byte("连接数已达到上限"))
conn.Close()
return
}
atomic.AddInt32(¤tConnections, 1) // 连接数+1
defer atomic.AddInt32(¤tConnections, -1) // 关闭连接,连接数-1
go handleConnection(conn, r)
})
log.Fatal(http.ListenAndServe(":7070", nil))
}
func handleConnection(conn *websocket.Conn, r *http.Request) {
defer conn.Close()
user := User{}
data := r.FormValue("data") // 获取连接的数据
err := json.Unmarshal([]byte(data), &user)
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("连接发生错误"))
return
}
// 检查用户是否已连接
if getConnection(user.Name) != nil {
conn.WriteMessage(websocket.TextMessage, []byte("该用户已连接,不允许重复连接"))
return
}
err = user.EncodingBase64() // 解码用户头像
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("连接发生错误"))
return
}
addConnectionToMap(user.Name, conn) // 将当前连接添加到连接对象中
goLive(user) // 用户上线
// 处理消息
for {
_, message, err := conn.ReadMessage()
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("连接已关闭"))
log.Println(conn.RemoteAddr().String(), "关闭连接", err)
break
}
// 解析消息
msg := Msg{}
err = msg.ParseMessage(message)
if err != nil {
log.Println(err)
break
}
if msg.IsPublic {
publicMessage(msg) // 群聊消息
} else {
privateMessage(msg) // 私聊消息
}
}
offLine(user) // 用户下线
}
// 用户上线
func goLive(user User) {
// 将当前用户信息添加到用户列表中
users[user.Name] = user
str := fmt.Sprintf("%s 加入聊天室,当前聊天室人数为 %d。", user.Name, getConnectionCount())
fmt.Println(str)
// 发送上线消息给其他用户
msg := Msg{
SendUser: user.Name,
SendTime: time.Now().Format("2006-01-02 15:04:05"), // 日期格式化为 yyyy-MM-dd HH:mm:ss 格式
Msg: str,
IsPublic: true,
IsSend: false,
Pic: user.Pic,
PicName: user.PicName,
}
privateMessageHandle(user)
publicMessage(msg)
}
// 用户上线,处理自己的私信消息
func privateMessageHandle(user User) {
// 用户上线时,遍历消息列表,看是否有当前上线用户的未处理的私信
var next *list.Element
for el := privateMsgs.Front(); el != nil; el = next {
next = el.Next()
v := el.Value.(Msg) // 用户上线处理这个用户的私信消息
if v.ReceUser == user.Name || v.SendUser == user.Name {
// 如果我是发送人
if v.SendUser == user.Name {
v.SendUser = "我"
v.Pic = user.Pic
}
// 如果我是接收人
if v.ReceUser == user.Name {
v.Pic = ""
_, ok := users[v.SendUser]
if ok {
v.Pic = users[v.SendUser].Pic // 发送人在线,则设置这条消息的头像为发送人的头像
}
privateMsgs.Remove(el) // 接收人上线后,将这条私信从消息列表中移除
}
fmt.Println(v)
messageQueue <- Message{Conn: getConnection(user.Name), Msg: v}
}
}
}
// 公共消息
func publicMessage(msg Msg) {
clientsMux.RLock()
defer clientsMux.RUnlock()
for userName, conn := range WebScoketConnMap {
if (msg.IsSend && userName != msg.SendUser) || !msg.IsSend {
messageQueue <- Message{Conn: conn, Msg: msg}
}
}
}
// 发送私聊消息给指定用户
func privateMessage(msg Msg) {
conn := getConnection(msg.ReceUser)
if conn != nil { // 当接收人在线时,直接发送消息到消息队列
messageQueue <- Message{Conn: conn, Msg: msg}
} else { // 当接收人不在线时,先将消息存储到redis中
privateMsgs.PushBack(msg)
}
}
// 用户下线
func offLine(user User) {
name := user.Name
removeUser(user) // 删除用户
str := fmt.Sprintf("%s 离开了聊天室,当前聊天室人数为 %d。", name, getConnectionCount())
fmt.Println(str)
// 发送下线消息给其他用户
msg1 := Msg{
SendUser: name,
SendTime: time.Now().Format("2006-01-02 15:04:05"),
Msg: str,
IsPublic: true,
IsSend: false,
Pic: user.Pic,
PicName: user.PicName,
}
publicMessage(msg1)
}
// 用户下线删除用户
func removeUser(user User) {
clientsMux.Lock()
defer clientsMux.Unlock()
os.Remove(user.Pic) // 删除头像文件
delete(users, user.Name)
delete(WebScoketConnMap, user.Name)
}
// 添加连接到 WebScoketConnMap
func addConnectionToMap(userName string, conn *websocket.Conn) {
clientsMux.Lock()
defer clientsMux.Unlock()
WebScoketConnMap[userName] = conn
}
// 从 WebScoketConnMap 中获取连接
func getConnection(userName string) *websocket.Conn {
clientsMux.RLock()
conn := WebScoketConnMap[userName]
clientsMux.RUnlock()
return conn
}
// 从 WebScoketConnMap 中获取连接数
func getConnectionCount() int {
clientsMux.RLock()
defer clientsMux.RUnlock()
return len(WebScoketConnMap)
}
客户端 html 完整代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>聊天室</title>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<style>
/* 设置滚动条的样式 */
::-webkit-scrollbar {
width:5px;
}
/* 滚动槽 */
::-webkit-scrollbar-track {
-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0.3);
border-radius:10px;
}
/* 滚动条滑块 */
::-webkit-scrollbar-thumb {
border-radius:10px;
background:rgba(0,0,0,0.1);
-webkit-box-shadow:inset 0 0 6px rgba(72, 192, 164,0.5);
}
::-webkit-scrollbar-thumb:window-inactive {
background:rgba(72, 192, 164,0.4);
}
::placeholder {
color: #79879a; /* 文字颜色 */
}
/*预定义样式,通过js动态生成dom时,加上指定类名*/
.dpn-message {font-family: "\5FAE\8F6F\96C5\9ED1", Helvetica, sans-serif;font-size: 18px;z-index: 99999;}
.dpn-message {box-sizing: border-box;position: fixed;top: -200px;left: 50%;transform: translateX(-50%);z-index: 99999;padding: 20px;padding-right: 32px;min-width: 25%;max-width: 50%;border-radius: 4px;transition: top .3s;}
/*info 消息*/
.dpn-message.dpn-info {background: #EDF2FC;border: 1px solid #EBEEF5;color: #909399;}
/*success消息*/
.dpn-message.dpn-success {background: #f0f9eb;border: 1px solid #54f006;color: #67C23A;}
/*error消息*/
.dpn-message.dpn-error {background: #fef0f0;border: 1px solid #fde2e2;color: #F56C6C;}
/*warning消息*/
.dpn-message.dpn-warning {background: #fdf6ec;border: 1px solid #faecd8;color: #E6A23C;}
.dpn-message .dpn-close {position: absolute;right: 8px;top: 50%;transform: translateY(-50%);width: 16px;height: 16px;line-height: 16px;text-align: center;font-style: normal;cursor: pointer;}
</style>
<style>
body{font-family: Arial, sans-serif;margin: 0;padding: 0;background-color: #282C34;background-position: center;background-repeat: no-repeat;background-attachment: fixed;background-size:100%;}
.main {display: flex;justify-content: space-between;margin: 20px;}
.public-channel,
.private-channel {flex: 1;border: 1px solid #68768a;border-radius: 5px;padding: 10px;box-sizing: border-box;margin-right: 10px;}
.public-channel{margin-right: 80px;}
h3 {font-size: 20px;color: #48c0a4;}
.top {display: flex;align-items: center;margin-bottom: 10px;}
#sendUser,
#receUser {
width: 300px;
height: 38px;
margin-right: 10px;
border: 1px solid rgba(0,0,0,0.2);
background: rgba(53,75,105,0.5);
padding: 5px 10px;
border-radius: 5px;
font-size: 16px;
color: #cfe2f3;
outline: none;
}
#头像box {
display: flex;
align-items: center;
width: 50px;
height: 50px;
cursor: pointer;
font-size: 14px;
}
#fileImg {display: none;vertical-align: middle;}
#img {max-width: 50px;}
#fileBox {
position: relative;
display: inline-block;
width: 50px;
height: 50px;
line-height: 50px;
border: 1px solid rgba(0,0,0,0.2);
background: rgba(53,75,105,0.5);
border-radius: 5px;
color: gold;
text-align: center;
cursor: pointer;
}
#file {
position: absolute;
opacity: 0;
width: 50px;
height: 50px;
cursor: pointer;
padding: 0;
margin-left: -12px;
}
button {
width: 16%;
height: 50px;
padding: 10px 20px;
color: gold;
background: rgba(53,75,105,0.5);
border-radius: 5px;
border: none;
cursor: pointer;
margin-left: 20px;
outline: none;
}
button:hover{color: darkorange;}
button:active {border: none;color: white;outline: none;background: LightSkyBlue;}
.msgBox {
border: 1px solid rgba(0,0,0,0.2);
padding: 10px;
height: 600px;
margin-bottom: 15px;
border-radius: 15px;
overflow: auto;
}
#publicMsg,
#privateMsg{display: flex;flex-direction: column;}
.msg{display: flex;margin: 15px 5px 10px 5px;}
.right {margin-left: auto;}
.msg .pic-box{float: left;width:50px;height:50px;line-height:50px;text-align: center;}
.left .info-box{margin-left: 10px;}
.right .info-box{text-align: right;margin-right: 10px;}
.msg .msg-pic{color: deepskyblue;display: block;font-weight: bold;font-size: 20px;width:50px;vertical-align: middle;}
.msg .msg-name{color: gold;font-size: 14px;margin-bottom: 1px;}
.msg .msg-name1{font-weight: bold;color: #f75c2f;}
.msg .msg-name2{font-weight: bold;}
.msg .msg-time{color: #cfe2f3;font-size: 12px;}
.msg .msg-msginfo{font-size: 15px;color: #cfe2f3;display: block;margin-top: 3px;word-break: break-all;}
textarea {
width: 97.5%;
height: 60px;
border: 1px solid rgba(0,0,0,0.2);
background: rgba(53,75,105,0.5);
padding: 10px;
outline: none;
border-radius: 15px;
font-size: 16px;
color: #cfe2f3;
}
</style>
</head>
<body>
<div class="main">
<div class="public-channel">
<h3>公共频道</h3>
<div class="top">
<input type="text" id="sendUser" placeholder="登录用户名">
<div id="头像box">
<div id="fileImg"><img src="" id="img" width="50"></div>
<div id="fileBox">
<input type="file" id="file" class="file" multiple="multiple" accept="image/jpeg,image/png,image/jpg">
<span style="cursor: pointer;">头像</span>
</div>
</div>
<button id="上线" onclick="connectWebSocket()">登录</button>
<button id="下线" onclick="closeWebSocket()">退出</button>
</div>
<div class="msgBox">
<div id="publicMsg">
<!--<div class="msg left">
<div class="pic-box">
<img src="img/符华.jpg" width="50" class="msg-pic">
</div>
<div class="info-box">
<span class="msg-name">一名路过的假面骑士</span>
<span class="msg-time">2023-10-27 10:25:08</span>
<span class="msg-msginfo">哈喽,大家好呀哈喽</span>
</div>
</div>
<div class="msg right">
<div class="info-box">
<span class="msg-time">2023-10-27 10:25:08</span>
<span class="msg-name">一名路过的假面骑士</span>
<span class="msg-msginfo">哈哈哈</span>
</div>
<div class="pic-box">
<img src="img/符华.jpg" width="50" class="msg-pic">
</div>
</div>-->
</div>
</div>
<div class="buttom">
<textarea type="text" id="msg1" placeholder="按Enter发送消息(Shift + Enter 换行)"></textarea>
</div>
</div>
<div class="private-channel">
<h3>私聊频道</h3>
<div class="top">
<input type="text" id="receUser" placeholder="私聊对象用户名"/>
</div>
<div class="msgBox">
<div id="privateMsg">
<!--<div class="msg left">
<div class="pic-box">
<span class="msg-pic">头像</span>
</div>
<div class="info-box">
<span class="msg-name msg-name2">一名路过的假面骑士</span>
<span class="msg-time">发给</span>
<span class="msg-name msg-name1">我</span>
<span class="msg-time">2023-10-27 10:26:08</span>
<span class="msg-msginfo">哈哈哈哈哈</span>
</div>
</div>
<div class="msg right">
<div class="info-box">
<span class="msg-time">2023-10-27 10:26:08</span>
<span class="msg-name msg-name2">我</span>
<span class="msg-time">发给</span>
<span class="msg-name msg-name1">一名路过的假面骑士</span>
<span class="msg-msginfo">哈哈哈哈哈</span>
</div>
<div class="pic-box">
<span class="msg-pic">头像</span>
</div>
</div>-->
</div>
</div>
<div class="buttom">
<textarea type="text" id="msg2" placeholder="按Enter发送消息(Shift + Enter 换行)"></textarea>
</div>
</div>
</div>
<script>
class MessageBox {
constructor(options) {
for(let key in options) {
if(!options.hasOwnProperty(key)) break;
this[key] = options[key];
}
this.init();
}
init() {if(this.status === "message") {this.createMessage();this.open();return;}}
createMessage() {
this.messageBox = document.createElement('div');
this.messageBox.className = `dpn-message dpn-${this.type}`;
this.messageBox.innerHTML = `${this.message}<i class="dpn-close">X</i>`;
document.body.appendChild(this.messageBox);
this.messageBox.onclick = ev => {
let target = ev.target;
if(target.className === "dpn-close") {this.close();}
};
this.oninit();
}
open() {
if(this.status === "message") {
let messageBoxs = document.querySelectorAll('.dpn-message'),len = messageBoxs.length;
this.messageBox.style.top = `${len===1 ? 20:20+(len-1)*70}px`;
this.autoTimer = setTimeout(() => {this.close();}, this.duration);
this.onopen();
return;
}
}
close() {
if(this.status === "message") {
clearTimeout(this.autoTimer);
this.messageBox.style.top = '-200px';
let anonymous = () => {document.body.removeChild(this.messageBox);this.onclose();};
this.messageBox.addEventListener('transitionend', anonymous);
return;
}
}
}
window.messageplugin = function(options = {}) {
if(typeof options === "string") {options = { message: options };}
options = Object.assign({status: 'message', message: '我是默认信息', type: 'info', duration: 1000, oninit() {}, onopen() {}, onclose() {},}, options);
return new MessageBox(options);
};
</script>
<script>
let file = document.getElementById('file'); // 选择文件
let fileBox = document.getElementById('fileBox'); // 选择文件box
let img = document.getElementById('img'); // 头像img标签
let fileImg = document.getElementById('fileImg'); // 头像img标签box
let textareaMsg1 = document.getElementById("msg1"); // 获取多行文本框元素
let textareaMsg2 = document.getElementById("msg2"); // 获取多行文本框元素
let imgBase64 = "",fileSuffix = "";
file.onchange = function (e){
if (e.target.files.length>0){
var selectedImage = e.target.files[0];
var fileSize = selectedImage.size / 1024; // 转换为KB
if (fileSize > 1024) {messageplugin({ message: "文件大小不得超过1MB", type: "error" });return;}
var name = selectedImage.name;
fileSuffix = name.slice(name.lastIndexOf(".")).replace("e",""); // 获取文件后缀
img.src = getFileURL(selectedImage);
fileImg.style.display = "inline-block";
fileBox.style.display = "none";
var fileReader = new FileReader();
fileReader.readAsDataURL(selectedImage); // 文件读取为url
fileReader.onload = function(e) {
imgBase64 = e.target.result; // 获取头像的base64
}
}
}
//获取文件地址
function getFileURL(file) {
var url = null ;
if (window.createObjectURL!=undefined) { // basic
url = window.createObjectURL(file) ;
} else if (window.URL!=undefined) { // mozilla(firefox)
url = window.URL.createObjectURL(file) ;
} else if (window.webkitURL!=undefined) { // webkit or chrome
url = window.webkitURL.createObjectURL(file) ;
}
return url;
}
// 定义两个模板字符串,使用占位符 ${} 来表示待填充的位置
// 公共频道其他用户的消息的模板(位于左边)
const publicLeftTemplate = `
<div class="msg left">
<div class="pic-box">
{{ImgHtmlSnippet}}
</div>
<div class="info-box">
<span class="msg-name">${"{{SendUser}}"}</span>
<span class="msg-time">${"{{SendTime}}"}</span>
<span class="msg-msginfo">${"{{Msg}}"}</span>
</div>
</div>
`;
// 公共频道我的消息的模板(位于右边)
const publicRightTemplate = `
<div class="msg right">
<div class="info-box">
<span class="msg-time">${"{{SendTime}}"}</span>
<span class="msg-name">${"{{SendUser}}"}</span>
<span class="msg-msginfo">${"{{Msg}}"}</span>
</div>
<div class="pic-box">
{{ImgHtmlSnippet}}
</div>
</div>
`;
// 私聊频道其他用户的消息的模板
const privateLeftTemplate = `
<div class="msg left">
<div class="pic-box">
{{ImgHtmlSnippet}}
</div>
<div class="info-box">
<span class="msg-name msg-name2">${"{{SendUser}}"}</span>
<span class="msg-time">发给</span>
<span class="msg-name msg-name1">${"{{ReceUser}}"}</span>
<span class="msg-time">${"{{SendTime}}"}</span>
<span class="msg-msginfo">${"{{Msg}}"}</span>
</div>
</div>
`;
// 私聊频道我的消息的模板
const privateRightTemplate = `
<div class="msg right">
<div class="info-box">
<span class="msg-time">${"{{SendTime}}"}</span>
<span class="msg-name msg-name2">${"{{SendUser}}"}</span>
<span class="msg-time">发给</span>
<span class="msg-name msg-name1">${"{{ReceUser}}"}</span>
<span class="msg-msginfo">${"{{Msg}}"}</span>
</div>
<div class="pic-box">
{{ImgHtmlSnippet}}
</div>
</div>
`;
// 填充模板
function filledTemplate(obj){
console.log(obj);
var sendUser = document.getElementById("sendUser").value; // 当前用户
var isPublic = obj["IsPublic"];
// 头像代码段
const imgHtmlSnippet = obj["Pic"] != "" ? `<img src='${obj["Pic"]}' width="50" class="msg-pic">` : `<span class="msg-pic">${obj["PicName"]}</span>`;
var filledTemplate;
if (isPublic){
msgList = document.getElementById("publicMsg");
var template = publicLeftTemplate;
if (obj["SendUser"] == sendUser || obj["SendUser"] == "我"){
template = publicRightTemplate;
}
// 替换模板中的占位符
filledTemplate = template.replace("{{ImgHtmlSnippet}}", imgHtmlSnippet).replace(/\{\{(\w+)\}\}/g, (match, key) => {
if (key == "SendUser" && !obj["IsSend"]){
return "";
}else if (key == "SendUser" && obj["IsSend"]){
return " "+obj[key];
}
return obj[key] || "";
});
}else {
msgList = document.getElementById("privateMsg");
var template = privateLeftTemplate;
if (obj["SendUser"] == "我" || obj["SendUser"] == sendUser){
template = privateRightTemplate;
}
// 替换模板中的占位符
filledTemplate = template.replace("{{ImgHtmlSnippet}}", imgHtmlSnippet).replace(/\{\{(\w+)\}\}/g, (match, key) => {
if (key == "ReceUser" && obj[key] == sendUser){ // 当前用户是接收人时
return "我";
}
return obj[key] || "";
});
}
// 将生成的 HTML 插入到页面中
msgList.innerHTML += filledTemplate;
}
</script>
<script type="text/javascript">
var websocket = null;
//连接WebSocket
function connectWebSocket() {
var sendUser = document.getElementById("sendUser").value;
if (sendUser === "") {messageplugin({ message: "请输入用户名", type: "error" });return;}
//判断当前浏览器是否支持websocket
if ('WebSocket' in window) {
var val = document.getElementById("sendUser").value;
// websocket = new WebSocket("ws://localhost:7070/web-socket/"+val);
// websocket = new WebSocket("ws://localhost:7070/web-socket?username="+val);
var jsonData = {Name: val, Pic: imgBase64, PicName: "",}; // 准备要发送的JSON数据
var jsonStr = JSON.stringify(jsonData); // 将JSON数据转换为字符串
websocket = new WebSocket(`ws://localhost:7070/web-socket?data=${encodeURIComponent(jsonStr)}`);
} else {messageplugin({ message: "当前浏览器不支持 websocket", type: "error" });}
//连接发生错误的回调方法
websocket.onerror = function () {messageplugin({ message: "连接发生错误", type: "error" });};
//连接成功建立的回调方法
websocket.onopen = function () {
// 连接成功后,将连接的用户输入框和上线按钮禁用
var sendUser = document.getElementById("sendUser");
var 上线 = document.getElementById("上线");
sendUser.readOnly = true;
sendUser.disabled = "disabled";
sendUser.style.backgroundColor='rgba(0,0,0,0.2)';
file.style.display = "none";
fileBox.style.backgroundColor = "rgba(0,0,0,0.2)";
// 上线.removeAttribute("onclick");
上线.disabled = "disabled";
上线.style.backgroundColor='rgba(0,0,0,0.2)';
}
//接收到消息的回调方法
websocket.onmessage = function (event) {
try {
var obj = JSON.parse(event.data); // 将服务器发过来的消息,转为json格式,捕获异常
filledTemplate(obj); // 填充模板
} catch (error) {
messageplugin({ message: event.data, type: "error" }); // 如果不是json格式则直接用 alert 提示
}
}
//连接关闭的回调方法
websocket.onclose = function () {messageplugin({ message: "连接已关闭", type: "warning" });}
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function () {closewebsocket();}
}
//关闭连接
function closeWebSocket() {websocket.close();}
// 公共频道发送消息(监听键盘enter事件)
textareaMsg1.addEventListener("keydown", function(event) {
if (event.key === "Enter" && !event.shiftKey) { // 按下enter且没有按住shift键的情况下
event.preventDefault(); // 阻止默认的换行行为
send(textareaMsg1.value,true);
textareaMsg1.value = ""; // 清空消息
}
});
// 私聊频道发送消息(监听键盘enter事件)
textareaMsg2.addEventListener("keydown", function(event) {
if (event.key === "Enter" && !event.shiftKey) { // 按下enter且没有按住shift键的情况下
event.preventDefault(); // 阻止默认的换行行为
var receUser = document.getElementById("receUser").value; //接收者
if (receUser === "") {messageplugin({ message: "私聊对象不能为空", type: "error" });return;}
send(textareaMsg2.value,false);
textareaMsg2.value = ""; // 清空消息
}
});
//发送消息
function send(msg,isPublic) {
var m = new Map(); // 空Map
var sendUser = document.getElementById("sendUser"); //发送者
if (msg === "") {messageplugin({ message: "不能发送空消息", type: "error" });return;}
var receUser = document.getElementById("receUser").value; //接收者
var currentTime = getCurrentTime();
m.set("SendUser",sendUser.value);
m.set("SendTime",currentTime);
m.set("Msg",msg);
m.set("IsSend",true);
m.set("IsPublic",isPublic);
if (!isPublic){
if (receUser === "") {messageplugin({ message: "私聊对象不能为空", type: "error" });return;}
if (receUser === sendUser.value){messageplugin({ message: "私聊对象不能是自己", type: "error" });return;}
m.set("ReceUser",receUser);
}
m.set("Pic",fileSuffix != "" ? "img/"+sendUser.value + fileSuffix : "");
m.set("PicName",sendUser.value.slice(0,2));
var json = mapToJson(m); // map转json
websocket.send(JSON.stringify(json)); // 先将json转json字符串,再发送
json["SendUser"] = "我";
filledTemplate(json);
}
// 获取当前时间
function getCurrentTime(){
//可以使用字符串操作方法来将日期时间格式化为特定格式的字符串。例如:
const date = new Date();
const year = date.getFullYear().toString().padStart(4, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hour = date.getHours().toString().padStart(2, '0');
const minute = date.getMinutes().toString().padStart(2, '0');
const second = date.getSeconds().toString().padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`; // 2023-02-16 08:25:05
}
//map转换为json
function mapToJson(map) {
var obj= Object.create(null);
for (var[k,v] of map) { obj[k] = v; }
return obj;
}
</script>
</body>
</html>
最后
ok,以上就是本篇文章的全部内容了,如果你觉得文章对你有帮助或者写得还不错的话,不要吝啬你的大拇指,给博主点个赞吧~😎😘