文章目录
一、前言
嗨,大家好,我是新发。
有老同事问我会不会Go
语言,人生苦短,Let's Go
,我做了一个Go语言基础
的思维导图,福利给大家~
嘛,今天就做一个Go
语言服务端与Unity
通信的小案例吧,效果如下,
工程源码见文章末尾~
二、Go开发环境搭建(Windows系统)
Go
语言是一门编译型语言,代码文件以.go
为后缀,我们写的.go
代码最终要编译为可执行文件(在Windows
平台下就是.exe
文件),编译需要用到go build
命令,go build
命令哪来的呢,这就需要我们在系统中安装GO
命令行工具。
另外,写.go
代码需要一个IDE
,推荐使用VSCode
,需要而外安装Go
插件和工具链。
画个图,方便大家理解~
看起来好像有点小麻烦,不要怕,几分钟搞定,下面我就来教大家~
1、安装Go命令行工具
进入Go
官网:https://golang.google.cn/,点击Download Go
,
然后根据你的操作系统选择对应的文件,它支持Windows
、macOS
、Linux
三个平台,我以Windows
为例,点击第一个,如下,
下载完毕后直接双击执行安装即可,
安装完毕后,打开cmd
命令行(步骤: 按win + r
键,输入cmd
按回车),然后执行go version
,如果能正常输出版本号,则说明安装成功了,如下,
2、创建GoWorkspace目录
在任意磁盘中创建一个文件夹作为工程 工作空间,建议命名为GoWorkSpace
,然后再分别创建bin
、pkg
、src
三个文件夹,
三个文件夹的用途如下:
文件夹 | 用途 |
---|---|
bin | 用来存放编译后的可执行文件 |
pkg | 用于存放编译后的包文件(一些第三方包文件) |
src | 是用来存放.go 源码文件(就是自己写的.go 代码) |
3、配置GOPATH环境变量
GOPATH
是一个环境变量,用来表明你写的go
项目的存放路径,现在我们来设置一下GOPATH
环境变量。
在我的电脑
上鼠标右键,点击属性
,然后点击高级系统设置
,
再点击环境变量
,
在系统变量下点击新建
按钮,
变量名为GOPATH
,变量值为刚刚创建的GoWorkSpace
路径,然后点击确定
,
这样,我们的GOPATH
环境变量就配置完成了~
4、配置GOPROXY代理
我们在执行go
编译时,会自动去下载依赖包,GOPROXY
默认配置是:GOPROXY=https://proxy.golang.org,direct
,由于国内访问不到,编译时会报错超时,我们需要改成国内的源,打开命令行,执行下面的命令:
go env -w GOPROXY=https://goproxy.cn,direct
如下,
5、安装VSCode
接下来是IDE
的安装,建议用VSCode
,安装过程很简单,这里不赘述~
VSCode
官网:https://code.visualstudio.com/
6、VSCode安装Go插件
VSCode
安装完毕后,点击插件安装按钮,搜索go
,选择Go
插件,点击install
按钮,如下,
注:这个
Go
插件提供了go
代码的智能感知、提示、语法高亮、语法检测等功能。
7、安装Go开发工具链
进行go
开发还需要下载配套的开发工具链(比如调试器、代码风格格式化等)。
我们打开VSCode
,按Ctrl + Shift + P
,输入go:install
,选择Go: Install/Update Tools
,然后全选,最后点击OK
按钮,如下,
注:如果你没有
Go: Install/Update Tools
这个选项,请检查第6步的Go
插件是否已正常安装。
耐心等待(大约1分钟
左右),下载完毕后可以在VSCode
的日志输出中看到All tools successfully installed. You are ready to Go. :)
,如下,
三、HelloGo 工程
以上,我们的Go
开发环境就搭建好了,现在我们来写一个HelloWorld
,不,是HelloGo
测试一下吧~
1、创建go脚本: main.go
在GoWorkSpace/src
目录中新建一个HelloGo
文件夹,如下,
回到VScode
,创建一个main.go
脚本,
注:文件名不叫
main
也可以,不过一般作为程序入口脚本,建议叫main
2、main.go代码
好了,现在我们开始写代码,功能就是打印一句日志:Hello Golang
,代码如下:
// 包名,main包为入口包,main包中必须含有一个main方法
package main
import "fmt"
// 程序入口方法,必须叫main
func main() {
// 输出日志
fmt.Println("Hello Golang")
}
3、生成go.mod文件
go mod
全称go modules
,在Golang 1.11
版本之前,go
代码的包依赖没有版本控制的概念,比如你依赖了一个protobuf
库,你在go
脚本中通过import
引入包,如下
import "github.com/micro/protobuf/proto"
它只会从github
中下载最新版本的protobuf
,可想而知,这对于团队协作是很不友好的,不同人电脑上不同时期引入的第三方包坑内版本存在差异,可能导致程序无法正常工作。
于是呢,在Golang 1.11
版本开始,就引入了go mod
,由一个go.mod
文件来记录依赖包的版本信息。
现在,我们就来生成这个go.mod
文件,在VSCode
终端中,cd
进入HelloGo
目录,然后执行命令
go mod init HelloGo
注: 上面的命令的
HelloGo
是模块名
它会生成一个go.mod
文件,如下,
4、编译生成可执行程序: go build命令
go
代码最终要生成成可执行程序才能运行,现在我们在HelloGo
目录下,执行go build
命令,最终它生成了一个HelloGo.exe
,如下,
注: 如果要指定生成的
exe
名字,则可以加上-o
参数,例:go build -o MyTest.exe
,它就会生成一个MyTest.exe
啦~
5、测试运行
现在我们去执行这个HelloGo.exe
,可以看到,成功输出了Hello Golang
,如下,
如果我们想跳过go build
命令,直接测试go
脚本,可以使用go run
命令,例:
go run main.go
如下,
四、用Go做个消息广播的服务端
接下来,我们用Go
来开发一个Socket
通信的服务端,实现消息广播的功能吧~
1、思维导图
在开始写代码之前,我们先设计一下服务端的模块,画个图,如下,
2、脚本说明
main.go
为程序入口脚本;
server.go
负责socket
监听和管理;
user.go
是用户类脚本,当server.go
的socket
监听到有客户端连接时,构造一个User
对象,后续的socket
通信交由user.go
脚本代理。
模块很简单,相信大家很容易看懂。
五、开始写服务端Go代码
1、创建项目文件夹和脚本
我们在src
目录中创建一个GoSocketServer
文件夹,作为项目文件夹,
接着我们在GoSocketServer
文件夹中创建main.go
、server.go
和user.go
三个脚本,
2、server.go脚本
我们先封装一下Server
类,注意在Go
语言中,定义类用的是struct
关键字,成员变量名如果是首字母大写,则表示是public
的,如果是小写,则表示是private
的。
2.1、成员变量声明
// Server.go 脚本
package main
// import ...
type Server struct {
Ip string
Port int
// 在线用户容器
OnlineMap map[string]*User
// 用户列容器锁,对容器进行操作时进行加锁
mapLock sync.RWMutex
// 消息广播的管道
Message chan string
}
讲解:
Message
成员是一个chan
类型,即管道类型,用于goroutine
之间的消息同步,当客户端连接服务端时,服务端开启一个goroutine
来处理后续的用户消息,消息需要广播给所有在线的客户端,所以这里我们通过Message
管道来做一层消息传递。
OnlineMap
是在线用户容器(注意User
类在user.go
脚本中定义,下文会讲),OnlineMap
存储当前连接到服务端的用户。OnlineMap
的操作存在多线程并行处理的情况,所以我们需要使用一个sync.RWMutex
读写锁对它进行加锁处理,声明一个mapLock
成员。
2.2、全局方法,NewServer
我们定义一个NewServer
全局方法,构造Server
对象,提供给外部调用。
func NewServer(ip string, port int) *Server {
server := &Server{
Ip: ip,
Port: port,
OnlineMap: make(map[string]*User),
Message: make(chan string),
}
return server
}
2.3、Socket监听连接,Listen和Accept
监听Socket
,我们可以使用net
模块的Listen
方法,函数原型如下,
// net 模块
func Listen(network, address string) (Listener, error)
例:
// import "net"
listener, err := net.Listen("tcp", "127.0.0.1:8888")
if err != nil {
fmt.Println("net.Listen err:", err)
return
}
要监听客户端连接,则用到的是Listener
的Accept
接口,该方法会阻塞,当接收到socket
连接时才会继续往下执行,接口原型如下,
// Listener接口
Accept() (Conn, error)
例:
conn, err := listener.Accept()
if err != nil {
fmt.Println("listener accept err:", err)
}
我们把Socket监听连接
的逻辑封装到Server
的Start
方法中,如下,
// Server.go 脚本
// 启动服务器的接口
func (this *Server) Start() {
// socket监听
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", this.Ip, this.Port))
if err != nil {
fmt.Println("net.Listen err:", err)
return
}
// 程序退出时,关闭监听,注意defer关键字的用途
defer listener.Close()
// 注意for循环不加条件,相当于while循环
for {
// Accept,此处会阻塞,当有客户端连接时才会往后执行
conn, err := listener.Accept()
if err != nil {
fmt.Println("listener accept err:", err)
continue
}
// TODO 启动一个协程去处理
}
}
2.4、启动协程处理用户消息,Handler
上面Start
函数中,当Listener
接收到连接后,为了不阻塞for
循环,我们启动协程去处理用户行为,封装一个Handler
方法,
// server.go 脚本
func (this *Server) Handler(conn net.Conn) {
// ...
}
在上面的Start
方法中添加Handler
调用,
// server.go 脚本
func (this *Server) Start() {
// ...
for {
// Accept,此处会阻塞,当有客户端连接时才会往后执行
conn, err := listener.Accept()
if err != nil {
fmt.Println("listener accept err:", err)
continue
}
// 启动一个协程去处理
go this.Handler(conn)
}
}
Handle
方法里面主要做三件事情:
1、构建User
对象;
2、启动一个新的协程从Conn
中读取消息;
3、通过User
对象执行消息处理。
// server.go 脚本
func (this *Server) Handler(conn net.Conn) {
// 构造User对象,NewUser全局方法在user.go脚本中
user := NewUser(conn, this)
// 用户上线
user.Online()
// 启动一个协程
go func() {
buf := make([]byte, 4096)
for {
// 从Conn中读取消息
len, err := conn.Read(buf)
if 0 == len {
// 用户下线
user.Offline()
return
}
if err != nil && err != io.EOF {
fmt.Println("Conn Read err:", err)
return
}
// 用户针对msg进行消息处理
user.DoMessage(buf, len)
}
}()
}
2.5、消息广播,通过管道同步
收到用户消息时,我们要广播给所有在线的用户,首先是把要广播的消息写到Message
管道中,如下,
// server.go 脚本
func (this *Server) BroadCast(user *User, msg string) {
sendMsg := "[" + user.Addr + "]" + user.Name + ":" + msg
this.Message <- sendMsg
}
接着我们定义一个ListenMessager
方法,去监听Message
管道,当Message
管道中有消息时,把消息写到用户管道中,
// server.go 脚本
func (this *Server) ListenMessager() {
for {
// 从Message管道中读取消息
msg := <-this.Message
// 加锁
this.mapLock.Lock()
// 遍历在线用户,把广播消息同步给在线用户
for _, user := range this.OnlineMap {
// 把要广播的消息写到用户管道中
user.Channel <- msg
}
// 解锁
this.mapLock.Unlock()
}
}
我们在Start
方法中去启动一个协程来执行ListenMessager
,
// server.go 脚本
func (this *Server) Start() {
// ...
// 启动一个协程来执行ListenMessager
go this.ListenMessager()
for {
// Accept,此处会阻塞,当有客户端连接时才会往后执行
conn, err := listener.Accept()
if err != nil {
fmt.Println("listener accept err:", err)
continue
}
// 启动一个协程去处理
go this.Handler(conn)
}
}
3、user.go脚本
User
类,主要做的就是消息处理,即用户行为的代理,如果是在skynet
中,就是一个用户agent
服务。
注:关于
skynet
,我之前写过几篇文章,感兴趣的同学也可以看看。
【游戏开发实战】手把手教你从零跑一个Skynet,详细教程,含案例讲解(服务端 | Skynet | Ubuntu)
【游戏开发实战】手把手教你在Windows上通过WSL运行Skynet,不用安装虚拟机,方便快捷(WSL | Linux | Ubuntu | Skynet | VSCode)
【游戏开发实战】教你Unity通过sproto协议与Skynet框架的服务端通信,附工程源码(Unity | Sproto | 协议 | Skynet)
3.1、成员变量声明
我们先定义一些基础的成员变量,
// user.go 脚本
type User struct {
Name string // 昵称,默认与Addr相同
Addr string // 地址
Channel chan string // 消息管道
conn net.Conn // 连接
server *Server // 缓存Server的引用
}
3.2、全局方法,NewUser
我们定义一个NewUser
全局方法,构造User
对象,提供给外部调用。
// user.go 脚本
func NewUser(conn net.Conn, server *Server) *User {
userAddr := conn.RemoteAddr().String()
user := &User{
Name: userAddr,
Addr: userAddr,
Channel: make(chan string),
conn: conn,
server: server,
}
return user
}
3.3、用户上线,Online
封装一个Online
方法,用户上线时,广播一个上线消息,
// user.go 脚本
func (this *User) Online() {
// 用户上线,将用户加入到OnlineMap中,注意加锁操作
this.server.mapLock.Lock()
this.server.OnlineMap[this.Name] = this
this.server.mapLock.Unlock()
// 广播当前用户上线消息
this.server.BroadCast(this, "上线啦O(∩_∩)O")
}
3.4、用户下线,Offline
封装一个Offline
方法,用户下线时,广播一个下线消息,
// user.go 脚本
func (this *User) Offline() {
// 用户下线,将用户从OnlineMap中删除,注意加锁
this.server.mapLock.Lock()
delete(this.server.OnlineMap, this.Name)
this.server.mapLock.Unlock()
// 广播当前用户下线消息
this.server.BroadCast(this, "下线了o(╥﹏╥)o")
}
3.5、消息处理,DoMessage
消息的传输,实际项目中会使用到一些通信协议对消息进行加密和压缩,比如protobuf
、sproto
等。这里我就简单处理,直接以字符串的二进制流传输,做一个简单的消息广播。
// user.go 脚本
func (this *User) DoMessage(buf []byte, len int) {
//提取用户的消息(去除'\n')
msg := string(buf[:len-1])
// 调用Server的BroadCast方法
this.server.BroadCast(this, msg)
}
上面Server
类中的BroadCast
方法,会把消息同步回每个User
对象的Channel
管道,所以我们需要在User
中去监听Channel
管道消息,封装个ListenMessage
方法。我们先构造一个bytebuf
,在头部两个字节写入消息长度,然后再写入消息内容,如下,
func (this *User) ListenMessage() {
for {
msg := <-this.Channel
fmt.Println("Send msg to client: ", msg, ", len: ", int16(len(msg)))
bytebuf := bytes.NewBuffer([]byte{})
// 前两个字节写入消息长度
binary.Write(bytebuf, binary.BigEndian, int16(len(msg)))
// 写入消息数据
binary.Write(bytebuf, binary.BigEndian, []byte(msg))
// 发送消息给客户端
this.conn.Write(bytebuf.Bytes())
}
}
然后在NewUser
方法中添加一个协程调用,如下
func NewUser(conn net.Conn, server *Server) *User {
// ...
// 启动协程,监听Channel管道消息
go user.ListenMessage()
return user
}
4、main.go脚本
main.go
脚本是程序入口脚本,我们要定义一个main
方法作为入口函数。
我们封装一个StartServer
方法,通过NewServer
全局方法构造一个Server
对象,然后执行Start
成员方法,如下,
// main.go 脚本
func StartServer() {
server := NewServer("127.0.0.1", 8888)
server.Start()
}
然后在main
方法中启动一个协程去执行StartServer
,如下,
// main.go 脚本
func main() {
// 启动Server
go StartServer()
// TODO 你可以写其他逻辑
fmt.Println("这是一个Go服务端,实现了Socket消息广播功能")
// 防止主线程退出
for {
time.Sleep(1 * time.Second)
}
}
5、编译运行
在VSCode
的终端中,进入GoSocketServer
目录,然后执行go mod init GoSocketServer
,生成go.mod
文件,如下,
执行go build
命令,将go
脚本编译为.exe
可执行程序(Windows
平台),如下,
运行GoSocketServer.exe
,如下,可以看到,服务端启动起来了,
下面,我们用Unity
实现客户端部分的功能吧~
六、Unity客户端
1、创建工程,UnitySocketClient
创建Unity
工程,项目名称叫UnitySocketClient
吧,如下,
2、UGUI制作界面
使用UGUI
制作一个界面,如下,
节点层级结构如下,
3、C#脚本
C#
脚本只有两个,一个ClientNet.cs
,一个Main.cs
。
3.1、ClientNet.cs脚本
ClientNet.cs
脚本封装三个接口出来供外部调用,如下,
代码如下,代码比较简单,我写了注释,相信大家能看懂,
using System;
using UnityEngine;
using System.Net.Sockets;
public class ClientNet : MonoBehaviour
{
private void Awake()
{
m_socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
m_readOffset = 0;
m_recvOffset = 0;
// 16KB
m_recvBuf = new byte[0x4000];
}
private void Update()
{
if (null == m_socket) return;
if (m_connectState == ConnectState.Ing && m_connectAsync.IsCompleted)
{
// 连接服务器失败
if (!m_socket.Connected)
{
m_connectState = ConnectState.None;
if (null != m_connectCb)
m_connectCb(false);
}
}
if (m_connectState == ConnectState.Ok)
{
TryRecvMsg();
}
}
private void TryRecvMsg()
{
// 开始接收消息
m_socket.BeginReceive(m_recvBuf, m_recvOffset, m_recvBuf.Length - m_recvOffset, SocketFlags.None, (result) =>
{
// 如果有消息,会进入这个回调
// 这个len是读取到的长度,它不一定是一个完整的消息的长度,我们下面需要解析头部两个字节作为真实的消息长度
var len = m_socket.EndReceive(result);
if (len > 0)
{
m_recvOffset += len;
m_readOffset = 0;
if (m_recvOffset - m_readOffset >= 2)
{
// 头两个字节是真实消息长度,注意字节顺序是大端
int msgLen = m_recvBuf[m_readOffset + 1] | (m_recvBuf[m_readOffset] << 8);
if (m_recvOffset >= (m_readOffset + 2 + msgLen))
{
// 解析消息
string msg = System.Text.Encoding.UTF8.GetString(m_recvBuf, m_readOffset + 2, msgLen);
Debug.Log("Recv msgLen: " + msgLen + ", msg: " + msg);
if (null != m_recvMsgCb)
m_recvMsgCb(msg);
m_readOffset += 2 + msgLen;
}
}
// buf移位
if (m_readOffset > 0)
{
for (int i = m_readOffset; i < m_recvOffset; ++i)
{
m_recvBuf[i - m_readOffset] = m_recvBuf[i];
}
m_recvOffset -= m_readOffset;
}
}
}, this);
}
/// <summary>
/// 连接服务端
/// </summary>
/// <param name="host">IP地址</param>
/// <param name="port">端口</param>
/// <param name="cb">回调</param>
public void Connect(string host, int port, Action<bool> cb)
{
m_connectCb = cb;
m_connectState = ConnectState.Ing;
m_socket.SendTimeout = 100;
m_connectAsync = m_socket.BeginConnect(host, port, (IAsyncResult result) =>
{
// 连接成功会进入这里,连接失败不会进入这里
var socket = result.AsyncState as Socket;
socket.EndConnect(result);
m_connectState = ConnectState.Ok;
m_networkStream = new NetworkStream(m_socket);
Debug.Log("Connect Ok");
if (null != m_connectCb) m_connectCb(true);
}, m_socket);
Debug.Log("BeginConnect, Host: " + host + ", Port: " + port);
}
/// <summary>
/// 注册消息接收回调函数
/// </summary>
/// <param name="cb">回调函数</param>
public void RegistRecvMsgCb(Action<string> cb)
{
m_recvMsgCb = cb;
}
/// <summary>
/// 发送消息
/// </summary>
/// <param name="bytes">消息的字节流</param>
public void SendData(byte[] bytes)
{
m_networkStream.Write(bytes, 0, bytes.Length);
}
/// <summary>
/// 关闭Sockete
/// </summary>
public void CloseSocket()
{
m_socket.Shutdown(SocketShutdown.Both);
m_socket.Close();
}
/// <summary>
/// 判断Socket是否连接状态
/// </summary>
/// <returns></returns>
public bool IsConnected()
{
return m_socket.Connected;
}
private enum ConnectState
{
None,
Ing,
Ok,
}
private Action<bool> m_connectCb;
private Action<string> m_recvMsgCb;
private ConnectState m_connectState = ConnectState.None;
private IAsyncResult m_connectAsync;
private byte[] m_recvBuf;
private int m_readOffset;
private int m_recvOffset;
private Socket m_socket;
private NetworkStream m_networkStream;
private static ClientNet s_instance;
public static ClientNet instance
{
get
{
if (null == s_instance)
{
var go = new GameObject("ClientNet");
s_instance = go.AddComponent<ClientNet>();
}
return s_instance;
}
}
}
3.2、Main.cs脚本
Main.cs
脚本作为入口脚本,同时作为UI
交互的脚本。
代码如下,
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class Main : MonoBehaviour
{
public Button sendBtn;
public InputField inputField;
public Text chatText;
private Queue<string> m_msgQueue;
private void Awake()
{
m_msgQueue = new Queue<string>();
}
void Start()
{
// 注册消息回调
ClientNet.instance.RegistRecvMsgCb((msg) =>
{
// 把消息缓存到队列中,注意不要在这里直接操作UI对象
m_msgQueue.Enqueue(msg);
});
// 连接服务端
ClientNet.instance.Connect("127.0.0.1", 8888, (ok) =>
{
Debug.Log("连接服务器, ok: " + ok);
});
sendBtn.onClick.AddListener(SendMsg);
}
/// <summary>
/// 发送消息
/// </summary>
private void SendMsg()
{
if (ClientNet.instance.IsConnected())
{
// 把字符串转成字节流
byte[] data = System.Text.Encoding.UTF8.GetBytes(inputField.text + "\n");
// 发送给服务端
ClientNet.instance.SendData(data);
// 清空输入框文本
inputField.text = "";
}
else
{
Debug.LogError("你还没连接服务器");
}
}
private void Update()
{
if (m_msgQueue.Count > 0)
{
// 从消息队列中取消息,并更新到聊天文本中
chatText.text += m_msgQueue.Dequeue() + "\n";
}
// 按回车键,发送消息
if(Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter))
{
SendMsg();
}
}
private void OnDestroy() {
ClientNet.instance.CloseSocket();
}
}
把Main.cs
脚本挂到Canvas
节点上,并赋值脚本的UI
成员对象,如下,
4、打包客户端
点击菜单File / Build Settings...
,然后添加场景到Scenes in Build
列表中,选择平台为PC
平台,如下,
接着点击Player Settings
,设置Fullscreen Mode
为Windowed
模式,即窗口模式,设置一下窗口大小,如下,
最终,点击Build
,执行打包,
打包完毕,生成了exe
文件,如下,
七、运行测试
1、启动Go服务端
回到我们的Go
服务端工程,在终端执行我们上面生成的GoSocketServer.exe
,如下,
2、启动Unity客户端
启动多个客户端,测试聊天,如下,
功能正常,VeryGood
,收拾吃饭去~
八、工程源码
本文服务端+客户端工程源码已上传到CODE CHINA
,感兴趣的同学可自行下载学习。
地址:https://codechina.csdn.net/linxinfa/golangserverandunityclientdemo
注:我使用的go
版本是1.17.2
,使用的Unity
版本是2021.1.9f1c1
九、完毕
好啦,就先到这里吧~
我是林新发:https://blog.csdn.net/linxinfa
原创不易,若转载请注明出处,感谢大家~
喜欢我的可以点赞、关注、收藏,如果有什么技术上的疑问,欢迎留言或私信~