Go语言 海量用户即时通讯系统

海量用户即时通讯系统

前期准备

  • 需求分析

        1) 用户注册
        2) 用户登录
        3) 显示在线用户列表
        4) 群聊 ( 广播 )
        5) 点对点聊天
        6) 离线留言
  • 界面设计

  • 项目开发前技术准备

功能实现-显示客户端登录菜单

功能:能够正确的显示客户端的菜单

 代码实现:因为在第一个页面有个登录功能,所以我们要创建main.go和login.go两个文件,主体还是一个for循环嵌套switch结构,并定义两个变量分别接收用户的选择key和是否继续选择菜单loop

//接收用户选择
var key int
//判断是否继续显示菜单
var loop = true
for loop {
	fmt.Println("-----------欢迎登陆多人聊天系统------------")
	fmt.Println("\t\t\t 1 登陆聊天室")
	fmt.Println("\t\t\t 2 注册用户")
	fmt.Println("\t\t\t 3 退出系统")
	fmt.Println("\t\t\t 请选择(1-3): ")
	fmt.Scanf("%d\n", &key)

	switch key {
	case 1:
		fmt.Println("登陆聊天室")
		loop = false
	case 2:
		fmt.Println("注册用户")
		loop = false
	case 3:
		fmt.Println("退出系统")
		os.Exit(0)
	default:
		fmt.Println("输入有误,请重新输入")
	}
}

接下来编写二级菜单,首先定义两个全局变量接收用户id和密码,二级菜单主题结构是ifelse结构,我们不能将所有功能都写在main文件,所以我们要先把登录的函数写到另一个文件

if key == 1 {
	//说明用户要登陆
	fmt.Println("请输入用户id")
	fmt.Scanf("%d\n", &userId)
	fmt.Println("请输入密码")
	fmt.Scanf("%s\n", &userPwd)
	//登陆函数写到另一个文件,login.go
	err := login(userId, userPwd)
	if err != nil {
		fmt.Println("登陆失败")
	} else {
		fmt.Println("登陆成功")
	}
} else if key == 2 {
	fmt.Println("进行用户注册的逻辑...")
}

在登录文件中我们先写一个函数完成登录功能,在这个函数中我们应当定义协议,但为了先让程序跑起来我们后面再补充协议

func login(userId int, userPwd string) (err error) {
	fmt.Printf("userId=%d userPwd=%s\n", userId, userPwd)
	return nil
}

运行后报错:undefined: login,经查找后发现要先退到src文件目录并在终端输入go build go_code/chapter06/chatroom/client将整个文件编译生产mian.exe文件,然后再运行,因为在将来在server文件中也会有mian.exe文件,所以我们最好给client中的main.exe文件起别名为client.exe操作指令为go build -o client.exe go_code/chapter06/chatroom/client。还有一个细节:如果将Printf里面%d或%s后的\n删除会出现程序直接跑掉,获取不到密码的情况,因为程序会认为我们输入完id后回车,该输入密码时刚输入的回车就是我们要输入的密码内容

  • 实现功能-完成用户登录

需求:先完成指定用户的验证,用户 id=100, 密码 pwd=123456 可以登录,其它用户不能登录

多人聊天室的结构分析:

功能一:完成客户端可以发送消息长度,服务器端可以正常收到该长度值

思路分析:先确定消息 Message 的格式和结构,让服务器端在端口监听

我们先写共用的消息结构部分,首先声明结构体Message,然后再定义两个具体消息结构体,分别是登录消息和返回的结果消息,因为我们在传递消息和序列化的时候往往需要结构体的字段都是小写的,所以我们需要用到struct tag

type Message struct {
	Type string `json:"type"` //消息类型
	Data string `json:"data"` //消息数据
}
//定义两个消息
type LoginMes struct {
	UserId int `json:"userId"` //用户id
	UserPwd string `json:"userPwd"` //用户密码
	UserName string `json:userName` //用户名
}
type LoginResMes struct {
	Code int `json:"code"` //返回状态吗500表示用户未注册 200表示登陆成功
	Error string `json:"error"` //返回错误信息
} 

接下来我们需要用几个常量来描述Type是什么类型

const (
	LoginMesType = "LoginMes"
	LoginResMesType = "LoginResMes"
)

共用的消息结构部分写完我们就开始写发消息部分的代码,先从客户端开始写

首先是服务器在端口开始监听,一旦监听成功,就等待客户端来连接,连接成功则启动一个协程和客户端保持通讯

func main() {
	//提示信息
	fmt.Println("服务器在8899监听。。。。")
	listen, err := net.Listen("tcp", "0.0.0.0:8899")
	defer listen.Close()
	if err != nil {
		fmt.Println("net listen err=", err)
		return
	}
	//一旦监听成功,就等待客户端链接服务器
	for {
		fmt.Println("等待客户端来接服务器。。。")
		conn, err := listen.Accept()
		if err != nil {
			fmt.Println("liten Accept err=", err)
		}
		//一旦连接成功,则启动一个协程和客户端保持通讯。
		go process(conn)
	}
}

然后写处理与客户端通信的函数,并传入连接,在该函数中读取客户端发送的信息。到目前服务器端就要等待客户端的连接和客户端发送消息,于是我们在login.go中连接服务器

conn, err := net.Dial("tcp", "localhost:8899")
if err != nil {
	fmt.Println("net.Dial err", err)
	return
}

如果连接成功就可以通过conn发送消息给服务器,因为我们发送的是结构体,所以我们要声明一个结构体并确定发送的类型,因为登录消息的内容需要序列化后才能给到mes结果体的data,所以我们确定发送的类型和创建LoginMes结构体后需要将登录消息的内容即LoginMes结构体序列化后才能给到string类型的Data,需要注意的是序列化后的data是切片类型,到了这一步我们的mes机构体就既有了Type也有了Data

//2. 准备通过conn发送消息给服务
var mes message.Message
mes.Type = message.LoginMesType

//3.创建一个LoginMes 结构体
var loginMes message.LoginMes
loginMes.UserId = userId
loginMes.UserPwd = userPwd

//4. 将loginMes序列化
data, err := json.Marshal(loginMes)
if err != nil {
	fmt.Println("json.Marshal err=", err)
	return
}

//5. 把data赋给mes.Data字段
mes.Data = string(data)

然后我们就可以将mes序列化准备发送了

//6. 将mes进行序列化
data, err = json.Marshal(mes)
if err != nil {
	fmt.Println("json.Marshal err=", err)
	return
}

但根据我们分析的规则,我们要先发送消息的长度再发送消息本身,于是我们要先发送data的长度给服务器,又因为Write方法发送的内容是切片类型,因此我们需要先获取到data的长度然后将其转成一个表示长度的byte切片,我们用到binary.BigEndian.PutUint32方法

//7、此时data就是我们要发送的消息
// 7.1 先把data的长度发生给服务器
// 先获取到data的长度->转换成一个表示长度的byte切片
var pkgLen uint32
pkgLen = uint32(len(data))
var buf [4]byte
binary.BigEndian.PutUint32(buf[0:4], pkgLen)

现在就可以正式发送了消息的长度了

//发送长度
n, err := conn.Write(buf[:4])
if n != 4 || err != nil {
	fmt.Println("conn.Write(bytes) fail", err)
	return
}
fmt.Printf("客户端发送消息长度=%d 内容=%s", len(data), string(data))

然后回到服务器端准备接收,循环读取客户端发送的消息,因为Read方法只能读取切片类型数据,所以我们先定义一个buf,等待客户端通过 conn 发送信息,如果客户端没有 write[发送],那么协程就阻塞在这里

func process(conn net.Conn)  {
	defer conn.Close()
	//循环读取客户端发送的消息
	for  {
		buf := make([]byte, 8096)
		fmt.Println("读取客户端发送的数据...")
		n, err := conn.Read(buf[:4])
		if n != 4 || err != nil {
			fmt.Println("conn.Read err=", err)
			return
		}
		fmt.Println("读到的buf=", buf[:4])
	}
}

运行后服务器端得到的数据有很多的0且非常长,经思考后发现是因为我们在输出时将整个buf都输出了,而实际上我们只读到了第四个字节,所以输出读到的buf时应输出buf[:4]

  • 实现功能-服务器接收消息

需求:完成客户端可以发送消息本身,服务器端可以正常接收到消息,并根据客户端发送的消息(LoginMes), 判断用户的合法性,并返回相应的 LoginResMes

思路分析:让客户端发送消息本身,服务器端接受到消息, 然后反序列化成对应的消息结构体,服务器端根据反序列化成对应的消息, 判断是否登录用户是合法, 返回 LoginResMes,客户端解析返回的 LoginResMes,显示对应界面,这里我们需要做函数的封装

功能一:让客户端发送消息本身,服务器端接受到消息, 然后反序列化成对应的消息结构体

代码实现:通过conn.Write方法将已经序列化为data的message结构体传入

_, err = conn.Write(data)
if  err != nil {
	fmt.Println("conn.Write(data) fail", err)
	return
}

后面还需要处理服务器端返回的消息,在客户端将data传入后,服务器端的协程要读取,但因为在读取内容后我们希望返回的是一个消息结构体,而不仅仅只是读取,还要反序列化为结构体,所以我们最好将这个过程封装为一个函数,这里将读取数据包,直接封装成一个readPkg(),返回Message,err,这个函数前面的大体结构和协程里的for循环类似,都是定义buf,读取一定长度的切片类型数据,后面开始不同,根据读到的长度buf[:4] 转成一个uint32类型,来知道到底读多少字节的数据,得到pkgLens具体长度后读取响应字节长度的消息内容,到此时buf就是相应字节的切片了,我们还需要将其反序列化为message结构体,然后取出Data字段,然后再反序列化成LoginMes,需要注意的是在反序列化传入第二个参数mes时一定要带取地址符&,不然得到的mes就是空的

func readPkg(conn net.Conn) (mes message.Message, err error){
	buf := make([]byte, 8096)
	fmt.Println("读取客户端发送的数据...")
	_, err := conn.Read(buf[:4])
	if err != nil {
		err = errors.New("read pkg header error")
		return
	}
	//根据buf[:4]转成一个uint32类型
	var pkgLen uint32
	pkgLen = binary.BigEndian.Uint32(buf[0:4])

	//根据pkgLen读取消息内容
	n, err := conn.Read(buf[:pkgLen])
	if n != int(pkgLen) || err != nil {
		err = errors.New("read pkg header error")
		return
	}

	//把pkgLen 反序列化成 -> message.Message
	err = json.Unmarshal(buf[:pkgLen], &mes)
	if err != nil {
		fmt.Println("json.Unmarsha err=", err)
		return
	}
	return
}

然后再协程的for循环中调用该方法,为了知道错误出在哪里也可以返回一个自定义的错误

for  {
	mes, err := readPkg(conn)
	if err != nil {
		fmt.Println("readPkg err=", err)
	}
	fmt.Println("mes=",mes)
}

运行后一直报错:readPkg err= read pkg header error,mes= { },可根据自定义返回第错误看到header一直没有读到,客户端发送的内容应该是没有问题,经思考后发现是因为我们的服务器端一直在进行for循环读取,而conn.Read只有在conn没有被关闭的情况下,才会阻塞,如果客户端或服务器端任意一方关闭了 conn 就不会阻塞,不阻塞就会去一直读,但因为conn已经关闭,读不到东西,且在for循环中没有做任何应对出错的处理,又会去读,又回到出错的地方,又抛出错误,解决方法就是在for循环中设置如果客户端退出,服务器端也退出

if err != nil {
	if err == io.EOF {
		fmt.Println("客户端退出,服务器端也退出...")
		return
	} else {
		fmt.Println("readPkg err=", err)
    	return
	}
}

功能二:服务器端根据反序列化成对应的消息, 判断是否登录用户是合法, 返回 LoginResMes,客户端解析返回的 LoginResMes

接下来实现服务器端根据反序列化成对应的消息, 判断是否登录用户是合法, 返回 LoginResMes的功能,但在这里我们遇到一个问题,我们的服务器端现在只要求处理登录的消息,但为了后面程序的可扩展性即处理其他类型的消息,我们需要让开启的协程能够根据不同的情况调用不同的分支函数。

根据上面的分析接下来我们就要编写一个ServerProcessMes 函数 ,功能为根据客户端发送消息种类的不同,决定调用哪个函数来处理,首先需要传入参数conn和读到的mes并返回err,要想实现根据不同的情况调用不同的函数功能我们可以使用switch结构,这时我们mes结构体中定义的Type就可以用到了,根据mes.Type判断

func serverProcessMes(conn net.Conn, mes *message.Message) (err error) {
	switch mes.Type {
		case message.LoginMesType:
			//处理登陆
			err = serverProcessLogin(conn, mes)
		//case message.RegisterMesType:
			//处理注册
		default:
			fmt.Println("消息类型不存在,无法处理...")
	}
	return
}

接下来编写一个函数serverProcessLogin函数,专门处理登陆请求,同时也要传入conn连接和mes消息并返回err,现在我们得到传入的mes结构体了,但Data还没有取出来,这个Data其实就是LoginMes序列化后的结果,我们要取出LoginMes里的id取出来组装一个新的LoginResMes返回去,所以我们先取出mes.Data然后反序列化为LoginMes,然后就可以根据UserId和pwd判断,其实这里我们应该有个到数据库验证的逻辑,但为了先实现功能我们先写死条件。然后根据合不合法构建LoginResMes,我们先声明一个resMes,他的类型就是LoginResMesType,类型就确定了,但数据还没有,再声明一个 LoginResMes, 并根据message文件定义的LoginResMes结构通完成赋值,现在我们需要将LoginResMes填到resMes结构体中才能返回去,但LoginResMes目前是结构体类型,所以我们需要将其序列化为切片,后续赋值时再强转为字符串,然后再对resMes 进行序列化,准备发送。

// 编写一个函数serverProcessLogin函数,专门处理登陆请求
func serverProcessLogin(conn net.Conn, mes *message.Message) (err error){
	// 核心代码。。。
	// 1、先从mes中取出mes.Data,并直接反序列化成LoginMes
	var loginMes message.LoginMes
	err = json.Unmarshal([]byte(mes.Data), &loginMes)
	if err != nil {
		fmt.Println("json.Unmashal fail err=", err)
		return
	}
	// ① 先声明一个resMes
	var resMes message.Message 
	resMes.Type = message.LoginResMesType

	// ② 再声明一个 loginResMes,并完成赋值
	var loginResMes message.LoginResMes
	//如果用户的id为100,密码为123456,认为合法, 否则 不合法
	if loginMes.UserId == 100 && loginMes.UserPwd == "123456" {
		// 合法
		loginResMes.Code = 200
	} else {
		//不合法
		loginResMes.Code = 500 //500状态码,表示该用户不存在
		loginResMes.Error = "该用户不存在,请注册后再使用"
	}

	// ③ 将 loginResMes 序列化
	data, err := json.Marshal(loginResMes)
	if err != nil {
		fmt.Println("json.Marshal fail", err)
		return
	}

	// ④ 将data 赋值给resMes
	resMes.Data = string(data)

	// ⑤ 对resMes  进行序列化,准备发送
	data, err = json.Marshal(resMes)
	if err != nil {
		fmt.Println("json.Marshal fail", err)
		return
	}

	// ⑥ 发送data,将其封装到writePkg函数
}

发送前为了防止服务器端给客户端时丢包并返回的是一个消息结构体,为了防止我们反复的封包解包,我们可以将其封装成类似readPkg的writePkg函数并将这两个通用函数封装到一个结构体里去扩展通用性,传入conn连接和data切片并返回err,这里用切片类型是因为write方法只能读取切片类型。先发送一个长度给对方,这里和readPkg代码类似,然后发送data本身

func writePkg(conn net.Conn, data []byte) (err error){
	// 先发送一个长度给对方
	var pkgLen uint32
	pkgLen = uint32(len(data))
	var buf [4]byte
	binary.BigEndian.PutUint32(buf[0:4], pkgLen)
	// 发送长度
	n, err := conn.Write(buf[0:4])
	if n != 4 || err != nil {
		fmt.Println("conn.Write fail", err)
		return
	}
	// 发送data本身
	n, err = conn.Write(data)
	if n != int(pkgLen) || err != nil {
		fmt.Println("conn.Write fail", err)
		return
	}
	return
}

到目前为止服务器端已经将信息返回来了,现在客户端需要接收,需要处理服务器端的消息,我们只需要读取一下,我们可以将之前写的readPkg和writePkg方法复制到utils.go文件中便于调用,然后我们在客户端调用一下readPkg方法,传入conn,返回mes,再解包

运行后发现服务器端一直卡在:读取客户端发送的数据...这句话上,经过检查代码和思考后发现是因为在服务器端协程处理不同消息的时候我们没有调用serverProcessMes

结构改进

一、服务器端结构改进

程序框架图:

 步骤:①先把分析出来的文件,创建好,然后放到相应的文件夹[包]                                                            ②现在根据各个文件,完成的任务不同,将 main.go 的代码剥离到对应的文件中即可                    ③先修改 utils/utils.go

        因为我们现在的思路是分成+面向对象,所以我们要将这两个方法关联到结构体中,然后分析结构体中应有的字段,首先conn连接要有,缓冲切片buf也要有,然后将方法绑定结构体变量,然后引入相关的包,将conn和buf改成this.Conn和this.Buf

           ④修改userProcess.go                                                                                                                 同理先声明一个结构体,也要有conn字段,缓冲buf可以不用,因为buf已经在utils中绑定,我们可以通过UserProcess将连接传给utils即可,然后引包,因为我们将方法绑定了结构体变量,所以传入的conn参数就可以删除了,同时将utils包中的方法首字母大写,在发送data处因为使用分层模式(mvc),我们先创建一个Transfer实例,然后读取

          修改main/processor.go                                                                                                               同理创建结构体,绑定,引包,因为我们要调用的是userProcess,所以我们要创建一个UserProcess实例并调用

          ⑥修改 main/main.go                                                                                                                          将协程里的for循环功能移入process.go文件中并创建函数,在函数中创建Transfer实例完成读包的任务,然后再main函数中的process函数中调用总控,还是创建实例并调用

二、客户端结构改进

程序框架图:

 步骤:①先把分析出来的文件,创建好,然后放到相应的文件夹[包]                                                            ②修改 utils/utils.go

由于逻辑一样,直接将server文件中的utils.go拿过来即可

            ③修改userProcess.go 

先将login.go中关于用户登录的代码复制到userProcess.go中,声明结构体,将方法与结构通绑定,由于原先login.go中的conn连接是自己拿到的,ReadPkg和WritePkg方法使用时创建Transfer实例就可以,所以结构体中目前不需要任何字段,然后引包,创建Transfer实例并调用方法,然后再判断登录成功后调用显示server.go中的菜单,保持和服务器通讯即启动协程

if loginResMes.Code == 200 {
	// fmt.Println("用户登陆成功")
	// 这里需要在客户端启动一个协程
	// 该协程保持和服务器端的通讯,如果服务器有数据推送客户端
	// 则接收并显示在客户端的终端
	go serverProcessMes(conn)
	
	// 1、显示我们登陆成功的菜单[循环]
	for {
		ShowMenu()
	}

             ④编写server.go

server.go首先要完成显示登录成功界面,显示一个菜单,主体结构还是switch循环,这里不需要for循环是因为我们会设计在userProcess.go中一旦登录成功,就会循环显示菜单。然后启动一个协程,该协程保持和服务器端的通讯,如果服务器有数据推送客户端则接收并显示在客户端的终端,首先需要引包,然后创建一个transfer实例,不停的读取服务器发送的消息

// 显示登陆成功后的界面...
func ShowMenu() {

	fmt.Println("----------恭喜登陆成功---------")
	fmt.Println("-------1、显示在线用户列表------")
	fmt.Println("-------2、发送消息------")
	fmt.Println("-------3、信息列表------")
	fmt.Println("-------4、退出系统------")
	fmt.Println("请选择1-4")
	var key int 
	fmt.Scanf("%d\n", &key)
	switch key {
		case 1 : 
			fmt.Println("1、显示在线用户列表")
		case 2 : 
			fmt.Println("2、发送消息")
		case 3 : 
			fmt.Println("3、信息列表")
		case 4 : 
			fmt.Println("4、退出系统")
			os.Exit(0)
		default :
			fmt.Println("你输入的选项不正确")
	}
}

//和服务器保持通讯
func serverProcessMes(conn net.Conn)  {
	//创建一个transfer实例,不停的读取服务器发送的消息
	tf := &utils.Transfer{
		Conn: conn,
	}
	for  {
		fmt.Println("客户端正在等待读取服务器发送的消息")
		mes, err := tf.ReadPkg()
		if err != nil {
			fmt.Println("tf.ReadPkg err=", err)
			return
		}
		//如果读取到消息,又是下一步处理逻辑
		fmt.Println("mes=%v\n", mes)
	}
}

Redis用户操作

一、验证分析

思路分析:

 步骤:手动直接在 redis 增加一个用户信息

二、用户登录 

需求:如输入的用户名密码在 Redis 中存在则登录,否则退出系统,并给出相应的提示信息:①用户不存在,请重新注册再登录;②输入密码不正确

代码实现:首先编写model层的user.go,在文件中声明一个用户的结构体,为了序列化和反序列化成功,我们必须保证用户信息的json字符串和结构体的字段对应的tag名字必须一致

//定义一个用户的结构体
type User struct {
	//为了序列化和反序列化成功,必须保证用户信息的json字符串的
	//key和结构体的字段对应的tag名字一致
	UserId int `json:"userId"`
	UserPwd string `json:"userPwd"`
	UserName string `json:"userName"`
}

然后编写error.go,该文件是根据业务逻辑的需要自定义一些错误

var (
	ERROR_USER_NOTEXISTS =errors.New("用户不存在...")
	ERROR_USER_EXISTS = errors.New("用户已经存在...")
	ERROR_USER_PWD = errors.New("密码不正确")
)

然后编写userDao.go文件,首先定义一个UserDao结构体,完成对User结构体的各种操作,因为UserDao要操作Redis,所以我们需要一个字段拿到Redis连接池

//完成对User结构体的各种操作
type UserDao struct {
	pool *redis.Pool
}

然后编写一下方法,因为我们要做登录功能,所以我们需要根据用户id能够返回一个User实例和err,具体就是通过给定的id去redis查询这个用户

res, err := redis.String(conn.Do("HGet", "users", id))
if err != nil {
	//错误!
	if err == redis.ErrNil {//表示在users哈希中,没有找到对应id
		err = ERROR_USER_NOTEXISTS
	}
	return
}

然后我们需要将得到的res反序列化成User实例

user = &User{}
//把res反序列化成User实例
err = json.Unmarshal([]byte(res), user)
if err != nil {
	fmt.Println("json.Unmarshal err=", err)
	return
}

再编写一个登录校验功能,该功能是如果用户的id和pwd都正确,则返回一个user实例,如果用户的id或pwd有错误,则返回对应的错误信息,因为要对比数据库,所以要先从UserDao的连接池中取出一个连接,获取到用户后进行判断

func (this *UserDao) Login(userId int, userPwd string) (user *User, err error) {
	//先从UserDao的连接池中取出一个连接
	conn := this.pool.Get()
	defer conn.Close()
	user, err = this.getUserById(conn, userId)
	if err != nil {
		return
	}
	//这时证明这个用户是获取到
	if user.UserPwd != userPwd {
		err = ERROR_USER_PWD
		return
	}
	return
}

到目前为止我们的UserDao是空的,所以我们最好使用工厂模式创建一个userDao的实例并传入一个pool

//使用工厂模式,创建一个UserDao实例
func NewUserDao(pool *redis.Pool) (userDao *UserDao) {
	userDao = &UserDao{
		pool,
	}
	return
}

然后我们要初始化Redis连接池,在服务器端的main文件中编写redis.go文件,首先要定义一个全局变量pool,然后编写一个初始化函数初始化连接池。然后当服务器启动时,我们就初始化连接池

// 定义一个全局pool
var pool *redis.Pool

func initPool(address string, maxIdle, maxActive int, idleTimeout time.Duration) {
	pool = &redis.Pool{
    	MaxIdle:maxIdle,  //最大空闲连接数
    	MaxActive:maxActive, //表示和数据库最大连接数,0表示没有限制
    	IdleTimeout:idleTimeout, //最大空闲时间
    	Dial:func()(redis.Conn,error){  //初始化连接的代码
        return redis.Dial("tcp","localhost:6379")
    	},
	}
}

然后当服务器启动时,我们就在main函数中初始化连接池,并且当服务器启动后,还要初始化一个userDao实例,把它做成全局变量,在需要和redis操作时,就直接使用即可

// 在服务器启动后,就初始化一个userDao实例
// 做成全局的变量,在需要和redis操作时,直接使用即可
var (
	MyUserDao *UserDao
)

此时MyUserDao是空的,我们需要对他创建实例化对象,我们可以在main函数中编写一个函数,完成对UserDao初始化任务,需要注意的是此函数的初始化需要在initPool函数之后,不然传入的pool就是空的

// 这里编写一个函数,完成对UserDao初始化任务
func initUserDao() {
	// 这里的pool本身就是一个全局的变量
	// 需要注意一个初始化顺序问题
	// 先initPool 再 initUserDao
	model.MyUserDao = model.NewUserDao(pool)
}

到目前为止该写的代码都已经写完了,就差userProcess里的登录、注册等调用数据库相关操作了,我们只需要修改以下代码

 我们需要做的就是到Redis数据库中去完成验证,即使用model.MyUserDao到Redis验证,然后用实例化好的MyUserDao去调用Login方法

user, err := model.MyUserDao.Login(loginMes.UserId, loginMes.UserPwd)	
if err != nil {
	if err == model.ERROR_USER_NOEXISTS {
		loginResMes.Code = 500
		loginResMes.Error = err.Error()
	} else if err == model.ERROR_USER_PWD {
		loginResMes.Code = 300
		loginResMes.Error = err.Error()
	} else {
		loginResMes.Code = 403
		loginResMes.Error = "未知错误"
	}	
} else {
	loginResMes.Code = 200
	fmt.Println(user, "登陆成功")
}

运行后无论输入什么用户名和密码都显示该用户不存在,检查代码并思考后,不是自定义错误的问题、err赋值和逻辑的问题,感觉应该是和Redis连接出了问题,没有获取到手动添加的用户,无法正确对比id和密码,检查代码后发现是反序列化时多了个&符合

 运行后还是一直显示该用户不存在,再次检查代码发现逻辑没有问题,肯定是和Redis交互方面出现了问题,最后发现在get数据库信息时的key为users,而Redis数据库中的key为user,改正后运行成功

  • 实现功能-完成用户注册

需求:完成注册功能,将用户信息录入到 Redis

思路分析:先把user.go 放入到common/message 文件夹,②common/message/message.go 新增加两个消息类型,③在客户端接收用户的输入,④在client/process/userProcess.go 增加一个 Register方法,完成请求注册,⑤在sever/model/userDao.go 增加了一个方法 Register方法

一、客户端实现

代码实现:和实现登录功能逻辑一样,我们需要现在message.go文件中增加注册消息类型和注册响应消息,因为我们已经定义了user结构体,所以在注册消息类型结构体中,我们直接声明User结构体类型,并把model文件中的user.go文件直接复制到message文件中,然后就是注册响应类型结构体,该结构体和登录响应类型结构体类似

type RegisterMes struct {
	User User `json:"user"`//类型就是User结构体
}
type RegisterResMes struct {
	Code int `json:"code"` //返回状态吗400表示用户已经占有 200表示注册成功
	Error string `json:"error"` //返回错误信息
} 

接下来我们要在客户端main.go中接收用户输入,还是创建UserProcess实例即调用UserProcess,完成注册请求

fmt.Println("注册用户")
fmt.Println("请输入用户id:")
fmt.Scanf("%d\n", &userId)
fmt.Println("请输入用户密码:")
fmt.Scanf("%s\n", &userPwd)
fmt.Println("请输入用户昵称:")
fmt.Scanf("%s\n", &userName)

然后在客户端的userProcess.go文件去编写Register方法,和Login方法类似,因为服务器以及在端口监听且协程已经启动,我们现在要做的就是等待客户端的连接和客户端发送消息,于是现在Register中连接服务器

//1、连接到服务器
conn, err := net.Dial("tcp", "localhost:8899")
if err != nil {
	fmt.Println("net.Dial err", err)
	return
}
//延时关闭
defer conn.Close()

如果连接成功就可以通过conn发送消息给服务器,因为我们发送的是结构体,所以我们要声明一个结构体并确定发送的类型,因为注册消息的内容需要序列化后才能给到mes结果体的data,所以我们确定发送的类型和创建RegisterMes结构体后需要将注册消息的内容即RegisterMes结构体序列化后才能给到string类型的Data,需要注意的是序列化后的data是切片类型,到了这一步我们的mes机构体就既有了Type也有了Data,然后我们就可以将mes序列化准备发送了

// 2、准备通过conn发送消息给服务器
var mes message.Message
mes.Type = message.RegisterMesType

// 3、创建一个LoginMes
var registerMes message.RegisterMes
registerMes.User.UserId = userId
registerMes.User.UserPwd = userPwd
registerMes.User.UserName = userName

// 4、将RegisterMes序列化
data, err := json.Marshal(registerMes)
if err != nil {
	fmt.Println("json.Marshal err=", err)
	return
}
//5、将data赋给 mes.Data字段
mes.Data = string(data)
// 6、将mes进行序列化
data, err = json.Marshal(mes)
if err != nil {
	fmt.Println("json.Marshal err=", err)
	return
}

但根据我们分析的规则,我们要先发送消息的长度再发送消息本身,于是我们要先发送data的长度给服务器,又因为Write方法发送的内容是切片类型,因此我们需要先获取到data的长度然后将其转成一个表示长度的byte切片,我们用到binary.BigEndian.PutUint32方法,现在就可以正式发送了消息的长度了,这一步在上面已经封装成了WritePkg方法,所以我们只需要创建Transfer实例并调用WritePkg方法即可

// 创建Transfer实例
tf := &utils.Transfer{
	Conn : conn,
}
// 发送data到服务器端
err = tf.WritePkg(data)
if err != nil {
	fmt.Println("注册发送信息出错 err=", err)
}		

发送完后还需要处理服务器端返回的消息

mes, err = tf.ReadPkg()  //mes就是RegisterResMes
if err != nil {
	fmt.Println("readPkg err=", err)
	return
}

现在就可对返回的信息进行一个简单的判断,先将mes的Data部分反序列化成 RegisterResMes

// 将mes的Data部分反序列化成 ResterResMes
var registerResMes message.RegisterResMes
err = json.Unmarshal([]byte(mes.Data), &registerResMes)
if registerResMes.Code == 200 {
	fmt.Println("注册成功,你重新登陆一把")
	os.Exit(0)
} else {
	fmt.Println(registerResMes.Error)
	os.Exit(0)
}
return

然后就可在客户端的main.go文件中调用了,到目前为止客户端的代码就写完了,但服务器端没有将注册的用户信息入库

二、服务器端实现

因为我们之前已经让服务器在端口监听,等待客户端连接服务器,连接成功后启动协程process,在协程里又调用总控函数,总控又调用process2方法,这个方法会创建Transfer实例不断读包,将读到的结果交给serverProcessMes函数,serverProcessMes函数会根据发送消息即mes.Type的不同调用不同的方法。到现在我们就要调用UserProcess完成注册,在UserProcess中编写serverProcessRegister方法,因为之前登录我们是调用的model文件中的userDao.go里的Login方法,所以注册我们也要先编写userDao.go里的Register方法,传入User结构体,还是先从UserDao的连接池中取出一个连接,获取到用户后进行判断,完成注册前要先将User结构体序列化成字符串

func (this *UserDao) Register(user *message.User) (err error) {
	// 先从UserDao 的连接池中取出一根连接
	conn := this.pool.Get()
	defer conn.Close()
	_, err = this.getUserById(conn, user.UserId)
	if err == nil {
		err = ERROR_USER_EXISTS
		return	
	}
	// 这时,说明id在redis还没有,则可以完成注册
	data, err := json.Marshal(user)  //序列化
	if err != nil {
		return
	}
	//入库
	_, err = conn.Do("HSet", "users", user.UserId, string(data))
	if err != nil {
		fmt.Println("保存注册用户错误 err=", err)
		return
	}
	return
}

Register方法写完后我们就可以编写serverProcessRegister方法去调用Register方法了,还是传入conn连接和mes消息并返回err,现在我们得到传入的mes结构体了,但Data还没有取出来,这个Data其实就是RegisterMes序列化后的结果,我们要取出RegisterMes里的id取出来组装一个新的RegisterResMes返回去,所以我们先取出mes.Data然后反序列化为RegisterMes,然后去Redis数据库完成注册,调用userDao中的Register方法并验证,然后根据验证结果构建RegisterResMes,我们先声明一个resMes,他的类型就是RegisterResMesType,类型就确定了,但数据还没有,再声明一个RegisterResMes, 并根据message文件定义的RegisterResMes结构体完成赋值,现在我们需要将RegisterResMes填到resMes结构体中才能返回去,但RegisterResMes目前是结构体类型,所以我们需要将其序列化为切片,后续赋值时再强转为字符串,然后再对resMes 进行序列化,准备发送,之后就可以将RegisterResMes序列化,然后将data赋值给resMes,对resMes进行序列化,准备发送

func (this *UserProcess) ServerProcessRegister(mes *message.Message) (err error){
	// 1、先从mes中取出mes.Data,并直接反序列化成RegisterMes
	var registerMes message.RegisterMes
	err = json.Unmarshal([]byte(mes.Data), &registerMes)
	if err != nil {
		fmt.Println("json.Unmashal fail err=", err)
		return
	}

	// ① 先声明一个resMes
	var resMes message.Message 
	resMes.Type = message.RegisterResMesType
	// ② 再声明一个 loginResMes,并完成赋值
	var registerResMes message.RegisterResMes

	// 需要到redis数据库完成注册
	// 1、使用model.MyUserDao到Redis验证
	err = model.MyUserDao.Register(&registerMes.User)
	if err != nil {
		if err == model.ERROR_USER_EXISTS {
			registerResMes.Code = 505
			registerResMes.Error = model.ERROR_USER_EXISTS.Error()
		} else {
			registerResMes.Code = 506
			registerResMes.Error = "注册发生未知错误"
		}
	} else {
		registerResMes.Code = 200
	}

	data, err := json.Marshal(registerResMes)
	if err != nil {
		fmt.Println("json.Marshal fail", err)
		return
	}
	
	// ④ 将data 赋值给resMes
	resMes.Data = string(data)

	// ⑤ 对resMes  进行序列化,准备发送
	data, err = json.Marshal(resMes)
	if err != nil {
		fmt.Println("json.Marshal fail", err)
		return
	}

	// ⑥ 发送data,将其封装到writePkg函数
	// 因为使用了分层的模式,先创建Transfer实例,然后读取
	tf := &utils.Transfer{
		Conn : this.Conn,
	}

	err = tf.WritePkg(data)
	return
}

运行后服务器端报错:消息类型不存在,无法处理,经检查代码后发现是serverProcessMes方法里没有调用ServerProcessRegister方法

实现功能-完成登录时能返回当前在线用户

功能一:用户登录后,可以得到当前在线用户列表

思路分析:

 代码实现:首先在客户端的process文件中创建一个userMgr.go,声明一个结构体,结构体中声明一个map存储用户在线信息。又因为UserMgr实例在服务器有且只有一个且在很多地方都会使用,因此我们将其定义为全局变量,然后完成对userMgr初始化工作

// 因为UserMgr实例在服务器有且只有一个
// 因为在很多地方,都会使用,因此,我们
// 将其定义为全局变量
var (
	 userMgr *UserMgr
)

type UserMgr struct {
	onlineUsers map[int]*UserProcess
}

// 完成对userMgr初始化工作
func init() {
	userMgr = &UserMgr {
		onlineUsers : make(map[int]*UserProcess, 1024),
	}
}

接下来完成对onlineUsers的增删改查,首先完成对onlineUsers添加,传入UserProcess指针,为了方便管理,表明UserProcess中的Conn字段是属于哪个用户的连接,我们增加一个字段UserId int

// 完成对onlineUsers添加
func (this *UserMgr) AddonlineUserup(up *UserProcess){
	this.onlineUsers[up.UserId] = up
}

删除onlineUsers

// 删除
func (this *UserMgr) DelonlineUserup(userId int){
	delete(this.onlineUsers, userId)
}

返回当前所有在线的用户

//返回当前所有在线用户
func (this *UserMgr) DelonlineUserupmap() map[int]*UserProcess{
	return this.onlineUsers
}

根据ID返回UserProcess对应的值

// 根据ID返回对应的值
func (this *UserMgr) GetOnlienUserById(userId int) (up *UserProcess, err error) {
	// 如何从map取出一个值,带检测的方式
	up, ok := this.onlineUsers[userId]
	if !ok  { //说明你要查找的用户,当前不在线
		err = fmt.Errorf("用户%d 不存在", userId)
		return
	}
	return
}

现在我们要实现用户登录成功以后我们就把id和UserProcess放进userMgr中去,因为在userMgr.go文件中已经将userMgr初始化且在一个包下,所以可以直接使用,然后将登录成功用户的userId赋给this,找到用户登录成功的代码处增加如下代码

//用户登录成功以后我们就把id和UserProcess放进userMgr中去
//将登录成功用户的userId赋给this
this.UserId = loginMes.UserId
userMgr.AddonlineUserup(this)

为了返回在线用户信息,我们需要在LoginResMes中增加一个在线用户id切片字段,然后将当前在线用户的id放入到LoginResMes.UsersId中

//将当前在线用户的id放入到LoginResMes.UsersId中
//遍历userMgr.onlineUsers
for id, _ := range userMgr.onlineUsers {
	loginResMes.UsersId = append(loginResMes.UsersId, id)
}

接下来编写客户端的代码,在客户端接收

//显示当前在线用户列表,遍历loginResMes.UsersId
fmt.Println("当前在线用户列表如下:")
for _, v := range loginResMes.UsersId {
	fmt.Println("用户id:\t", v)
}
fmt.Print("\n\n")

如果我们要求不显示自己在线,可以增加如下代码

for _, v := range loginResMes.UsersId {
	//要求不显示自己在线
	if v == userId {
		continue
	}	
	fmt.Println("用户id:\t", v)
}

但运行后在第一个客户端的显示在线用户id还是一个且如果我们输入1显示在线用户列表就拿不到在线用户列表了

功能二:当一个新的用户上线后,其它已经登录的用户也能获取最新在线用户列表

代码实现:先从服务器端开始编写,因为是服务器推送消息给在线用户,当有用户登录时就调用推送机制。先在user.go中的User结构体增加一个UserStatus字段表示用户状态,然后为了配合服务器端推送用户状态变化的消息,我们在message.go中定义一个新的消息类型,然后定义一个消息类型

type NotifyUserStatusMes struct {
	UserId int `json:"userId"` //用户Id
	Status int `json:"status"` //用户状态
}

接下来就要编写两个重要的方法,在userProcess.go文件在编写通知所有在线用户的方法,并传入上线用户的id,因为userId要通知其他在线用户我上线了,首先我们要for循环遍历userMgr.go中的onlineUsers切片,然后一个一个的发送NotifyUserStatusMes,需要注意的是要过滤掉自己再通知,我们单独写一个通知的方法再在此处调用。

//遍历userMgr.go中的onlineUsers切片,然后一个一个的发送NotifyUserStatusMes
for id, up := range this.onlineUsers {
	//过滤自己
	if vid == userId {
		continue
	}
	//开始通知
	up.NotifyMeOnline(userId)
}

这个方法需要传入的id是遍历的所有在线用户的id,在NotifyMeOnline方法中开始组装我们的NotifyUserStatusMes

//组装我们的NotifyUserStatusMes
var mes message.Message
mes.Type = message.NotifyUserStatusMesType

var notifyUserStatusMes message.NotifyUserStatusMes
notifyUserStatusMes.UserId = userId
notifyUserStatusMes.Status = message.UserOnline

为了方便组装,我们还需要回到message.go中定义几个状态常量

const (
	UserOnline = iota
	UserOffline
	UserBusyStatus
)

组装完成后我们将NotifyUserStatusMes序列化,因为用户状态变化消息的内容需要序列化后才能给到mes结果体的data,所以我们确定发送的类型和创建notifyUserStatusMes结构体后需要将用户状态变化的消息的内容即notifyUserStatusMes结构体序列化后才能给到string类型的Data,需要注意的是序列化后的data是切片类型,到了这一步我们的mes机构体就既有了Type也有了Data,然后我们就可以将mes序列化准备发送了

// 4、将notifyUserStatusMes序列化
data, err := json.Marshal(notifyUserStatusMes)
if err != nil {
	fmt.Println("json.Marshal err=", err)
	return
}
//5、将data赋给 mes.Data字段
mes.Data = string(data)
// 6、将mes进行序列化
data, err = json.Marshal(mes)
if err != nil {
	fmt.Println("json.Marshal err=", err)
	return
}

但根据我们分析的规则,我们要先发送消息的长度再发送消息本身,于是我们要先发送data的长度给服务器,又因为Write方法发送的内容是切片类型,因此我们需要先获取到data的长度然后将其转成一个表示长度的byte切片,我们用到binary.BigEndian.PutUint32方法,现在就可以正式发送了消息的长度了,这一步在上面已经封装成了WritePkg方法,所以我们只需要创建Transfer实例并调用WritePkg方法即可

// 创建Transfer实例
tf := &utils.Transfer{
	Conn : this.Conn,
}
// 发送data到服务器端
err = tf.WritePkg(data)
if err != nil {
	fmt.Println("NotifyMeOnline err=", err)
}	

然后编写客户端代码,服务器端已经将结构体传过来了,现在需要接收,在server.go中的serverProcessMes函数中接收,首先需要switch判断一下传过来消息的类型,然后取出传过来的NotifyUserStatusMes,取出后我们还需要将这个人加入到我们客户端维护的map中去,所以我们现在需要编写一个专门管理这个用户信息的文件userMgr.go,首先声明客户端要维护的map,但初始化的工作我们是在userProcess.go文件中登录成功时

user := &message.User{
	UserId : v,
	UserStatus : message.UserOnline
}
onlineUsers[v] = user

然后我们要写一个保存个人信息的方法并传入notifyUserStatusMes,因为服务器端返回的是一个结构体而不是简单的user,还需要解析,然后再进行一个判断如果有该用户就只更新状态,没有就添加该用户再更新状态

// 编写一个方法,处理返回的NotifyUserStatusMes
func updateUserStatus(notifyUserStatusMes *message.NotifyUserStatusMes){	
		// 适当优化
		user, ok := onlineUsers[notifyUserStatusMes.UserId]
		if !ok { //原来没有
			user = &message.User {
				UserId : notifyUserStatusMes.UserId,
			}
		}	
	user.UserStatus = notifyUserStatusMes.Status
	onlineUsers[notifyUserStatusMes.UserId] = user
	outputOnlineUser()
}

然后再编写一个在客户端显示当前在线用户的方法,只需要遍历onlineUser即可

// 在客户端显示在线用户
func outputOnlineUser(){
	// 遍历 一把 onlineUsers
	fmt.Println("当前在线用户列表:")
	for id, _ := range onlineUsers {
		fmt.Println("用户id:\t", id)
	}
}

userMgr.go里的方法编写完成后就需要去server.go中调用了

switch mes.Type {
	case message.NotifyUserStatusMesType: //有人上线
		//1.取出NotifyUserStatusMes
		var notifyUserStatusMes message.NotifyUserStatusMes
		json.Unmarshal([]byte(mes.Data), &notifyUserStatusMes)
		//2.把这个用户的信息、状态保存到客户端map[int]User中
		updateUserStatus(&notifyUserStatusMes)
	default :
		fmt.Println("服务器端返回了未知的消息类型")
}

实现功能-完成登录用可以群聊

功能一:当一个用户上线后,可以将群聊消息发送给服务器,服务器可以接收到

思路分析:

 代码实现:首先加一个发送消息SmsMes结构体

// 增加一个SmsMes 消息发送结构体
type SmsMes struct {
	Content string `json:"content"`
	User           //匿名结构体,使用到继承的特性
}

再增加一个CurUser结构体用来维护自身信息和连接,在客户端的model文件中编写curUser.go并定义结构体

// 表示在线登录成功客户的信息,便于管理
type CurUser struct {
	Conn net.Conn
	message.User
}

然后我们需要将CurUser结构体初始化,由于CurUser在许多处都会使用到,有且只用改一个变量,故将其在客户端的userMgr.go中定义定义成一个全局变量,然后再用户登录成功后完成对CurUser结构体的初始化

// 完成客户端MyCurUser的初始化
CurUser.Conn = conn
CurUser.UserId = userId
CurUser.UserStatus = message.UserOnline

接下来完成群发功能,在SmsProcess.go文件中编写SendGroupMes函数,还是先创建一个mes,再创建一个SmsMes实例,然后序列化smsMes,并对mes再次序列化,然后将mes发送给服务器

// 发送群聊的消息
func (smsPro *SmsProcess) SendGroupMes(content string) (err error) {

	1.创建一个mes
	var mes message.Message
	mes.Type = message.SmsMesType
	// 2.创建一个SmsMes 实例
	var smsMes message.SmsMes
	smsMes.Content = content
	smsMes.UserId = CurUser.UserId
	smsMes.UserStatus = CurUser.UserStatus
	// 3.序列化smsMes
	data, err := json.Marshal(smsMes)
	if err != nil {
		fmt.Println("SendGroupMes json.Marshal err=", err.Error())
		return
	}

	mes.Data = string(data)

	// 4.对mes再次序列化
	data, err = json.Marshal(mes)
	if err != nil {
		fmt.Println("SendGroupMes json.Marshal err=", err.Error())
		return
	}
	// 5发生给服务器
	tf := &utils.Transfer{
		Conn : CurUser.Conn,
	}
	// 6.发送
	err = tf.WritePkg(data)
	if err != nil {
		fmt.Println("SendGroupMes err=", err.Error())
		return
	}
	return
}

接下来就是去server.go中调用,调用前还需要将SmsProcess实例化

功能二:服务器可以将接收到的消息,群发给所有在线用户(发送者除外)

思路分析:

 代码实现:首先在message.go中定义一个服务器消息回复结构体SmsResMes

// SmsResMes 服务器消息回复结构体
type SmsResMes struct {
	Content string `json:"content"`
	User           //匿名结构体,使用到继承的特性
}

然后再客户端的process.go中的switch机构中增加一个新的case,创建一个SmsProcess实例完成转发群聊消息,再在服务器端的smsProcess.go中编写群发方法,首先需要先取出mes的内容,所以我们需要将SmsMes反序列化

// 将mes的内容SmsMes 反序列化
var smsMes message.SmsMes
err = json.Unmarshal([]byte(mes.Data), &smsMes)
if err != nil {
	fmt.Println("smsProcess SendGroupMes json.Unmarshal() err=", err)
	return
}

然后遍历服务器端的 onlineUsers map[int]*UserProcess,将消息转发取出

for id, up := range MyUserMgr.onlineUsers {
	// 群发,排除本身
	if id == smsMes.UserId {
		continue
	}
	err = smsPro.SendMesToEachOnlineUsers(&smsMes, up.Conn)
	if err != nil {
		fmt.Printf("消息发送给id:%v失败\n,id")
	}
}

然后编写将消息转发给其他在线用户方法

func (this *SmsProcess) SendMesToEachOnlineUsers(smsMes *message.SmsMes, conn net.Conn) {
	smsResMes := &message.SmsResMes{
		Content : smsMes.Content,
		User : smsMes.User,
	}

	// 序列化 smsReMes
	data, err := json.Marshal(smsResMes)
	if err != nil {
		fmt.Println("smsPro SendMesToEachOnlineUsersjson.Marshal(smsResMes) err=", err)
		return
	}

	//创建一个mes
	mes := message.Message{
		Type: message.SmsResMesType,
		Data: string(data),
	}

	// 序列化mes
	data, err = json.Marshal(mes)
	if err != nil {
		fmt.Println("smsPro SendMesToEachOnlineUsersjson.Marshal(mes) err=", err)
		return
	}

	// 创建一个utils.Transfer 发送信息
	tf := &utils.Transfer{
		Conn: conn,
	}
	err = tf.WritePkg(data)
	if err != nil {
		fmt.Println("转发消息失败 err=", err)
		return
	}
}

接下来在process.go中调用

case message.SmsMesType:
	// fmt.Println("mes=", mes)
	// 创建一个SmsProcess实例完成转发群消息的逻辑
	smsProcess := &process.SmsProcess{}
	smsProcess.SendGroupMes(mes)

还需要在客户端去接收服务器端群发的数据,因为我们还要把接收到的内容反序列化取出来,所以我们还需要编写一个smsMgr.go,又因为在server.go中已经判断过类型,所以此处的mes.Data内容是SmsResMes类型的消息,反序列化mes.Data

func outputGroupMes(mes *message.Message) { //此处的mes.Data 内容是SmsResMes类型的消息

	// 反序列化
	smsResMes := &message.SmsResMes{}
	err := json.Unmarshal([]byte(mes.Data), smsResMes)
	if err != nil {
		fmt.Println("smsMgr.go outputGroupMes json.Unmarshal() err=", err.Error())
		return
	}

	// 显示信息
	info := fmt.Sprintf("UserId %v:%s\n", smsResMes.UserId, smsResMes.Content)
	fmt.Println(info)
	fmt.Println()
}

最后在server.go中增加case调用

case message.SmsResMesType:
	outputGroupMes(&mes)

剩下几个功能参考链接:海量用户即时通讯系统_ALEX_CYL学习记录的博客-CSDN博客

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值