2017.6.26 Demo
2017.6.27 更新
之前已经实现了向数据库写入和读取信息的功能,那么很自然地,用户的注册和登录部分就比较容易写逻辑来实现了。下面记录一些关键问题。
首先梳理一下注册过程的逻辑:(服务端操作[S]/客户端操作[C])
[S]开启服务器,连接数据库 -> [C]用户将用户名、密码、确认密码的信息写入输入区 -> [C]点击相关按钮后连接服务器 -> [C]将上述信息按照一定的协议发给服务器 -> [S]服务器按协议解析收到的消息 -> [S]服务器判断消息的正确性,并与数据库交互 -> [S]服务器将结果返回客户端 -> [C]客户端根据收到的结果展示不同的提示页面
1.开启服务器,连接数据库
这个部分前几篇文章讲过,过程大同小异。只要注意相关IP地址和名称就好。
//开启服务器,连接数据库
public void Start(string host, int port)
{
//数据库
string connStr = "Database=game;Data Source=127.0.0.1;";
connStr += "User Id=root;Password=123456;port=3306";
sqlConn = new MySqlConnection (connStr);
try
{
sqlConn.Open();
}
catch(Exception e)
{
Console.Write ("[数据库]连接失败" + e.Message);
return;
}
//连接池
conns = new Conn[maxConn];
for (int i = 0; i < maxConn; i++)
{
conns [i] = new Conn ();
}
//Socket
listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream,ProtocolType.Tcp);
//Bind
IPAddress ipAdr = IPAddress.Parse(host);
IPEndPoint ipEp = new IPEndPoint (ipAdr, port);
listenfd.Bind (ipEp);
//Listen
listenfd.Listen(maxConn);
//Accept
listenfd.BeginAccept(AcceptCb, null);
Console.WriteLine ("[服务器]启动成功");
}
2.用户将用户名、密码、确认密码的信息写入输入区
注意,密码的输入区应该将属性栏的内容类型(Content Type)改为”Password”,如下图所示:
3.点击相关按钮后连接服务器
需要连接服务器的按钮有“登录”和“注册”两个。
public void Connection()
{
//Socket
socket = new Socket(AddressFamily.InterNetwork,SocketType.Stream,ProtocolType.Tcp);
//Connect
string host = "127.0.0.1";
int port = 1234;
socket.Connect (host, port);
//Recv 倒数第二个参数为回调函数
socket.BeginReceive(readBuff, 0, BUFFER_SIZE, SocketFlags.None, ReceiveCb, null);
}
4.将上述信息按照一定的协议发给服务器
登录/注册:
public void SendLog()
{
Connection ();
string str ="LOGIN " + userId.text.ToString() + " " + passW.text.ToString();
byte[] bytes = System.Text.Encoding.Default.GetBytes (str);
socket.Send(bytes);
}
public void SendReg()
{
Connection ();
string str ="REG " + userId.text.ToString() + " " + passW.text.ToString() + " " + cfmPassW.text.ToString();
byte[] bytes = System.Text.Encoding.Default.GetBytes (str);
socket.Send(bytes);
}
- 协议
协议是服务器和客户端遵循的一套消息处理的模式,换句话说就是任一方都需要“让对方听懂自己在说什么”和“让自己听懂对方在说什么”。因为我们的系统每次只能向服务器发送一条消息,为了让消息中包含多个信息,则需要协议。这部分是用户自己给出的,为了方便理解和处理,应该尽量简单和准确。
这部分的协议我们定义为:功能关键字(LOGIN/REG)/ID/密码/确认密码(登录功能无此部分),每部分之间用空格符分开。
5.服务器按协议解析收到的消息
private void ReceiveCb(IAsyncResult ar)
{
Conn conn = (Conn)ar.AsyncState;
try
{
int count = conn.socket.EndReceive(ar);
//关闭信号
if(count <= 0)
{
Console.WriteLine("收到 [" + conn.GetAddress() + "] 断开连接");
conn.Close();
return;
}
//数据处理
string str = System.Text.Encoding.UTF8.GetString(conn.readBuff,0,count);
Console.WriteLine("收到 [" + conn.GetAddress() + "] 数据:" + str);
string[] args = str.Split(' ');
string result = "";
Console.WriteLine("args[0]=" + args[0]);
if(args[0] == "LOGIN")
{
string id = args[1];
string pw = args[2];
Console.WriteLine("用户登录: " + id);
result = CheckPassWord(id, pw);
}
if(args[0] == "REG")
{
string id = args[1];
string pw = args[2];
string cpw = args[3];
Console.WriteLine("用户注册: " + id + " " + pw);
result = Register(id, pw, cpw);
}
byte[] bytes = System.Text.Encoding.Default.GetBytes(result);
conns[index].socket.Send(bytes);
//继续接收
conn.socket.BeginReceive(conn.readBuff,conn.buffCount, conn.BuffRemain(),SocketFlags.None,ReceiveCb,conn);
}
catch
{
Console.WriteLine("收到 [" + conn.GetAddress() +"] 断开连接");
conn.Close();
}
}
上述代码中的“数据处理”部分即为服务器对协议的处理,因为这部分容易出错,所以代码中加入了不少调试的语句。依据空格拆分消息的语句如下:
string[] args = str.Split(' ');
6.服务器判断消息的正确性,并与数据库交互
这部分很关键,一些判定条件包括但不限于:
(1)防SQL注入
所谓SQL注入,就是通过输入请求,把SQL命令插入到SQL语句中,以达到欺骗服务器执行恶意SQL命令的目的。假设服务端要获取玩家的数据,可能使用如下的SQL语句:
string sql = "SELECT * FROM player WHERE id=" + id";
正常情况下,该语句能够完成读取数据的工作。但如果一名玩家注册了类似“wanjia;DELETE * FROM player;”的名字,则上面的SQL语句会变为:
SELECT * FROM player WHERE id= wanjia; DELETE * FROM player;
(这样的玩家如同毒瘤,后果不堪设想)
当然,防范这个问题也比较简单,即加入对非法字符(如 ; * 等)的判断:
//判定安全字符串
public bool IsSafeStr(string str)
{
return !Regex.IsMatch(str, @"[-|;|,|\/|\(|\)|\[|\]|\{|\}|%|@|\*|!|\']");
}
(2)判定两次密码输入是否一致
(3)通过查询数据库判定用户名是否已被注册
private bool CanRegister(string id)
{
//查询id是否存在
string cmdStr = string.Format("select * from user where id='{0}';", id);
MySqlCommand cmd = new MySqlCommand (cmdStr, sqlConn);
try
{
MySqlDataReader dataReader = cmd.ExecuteReader();
bool hasRows = dataReader.HasRows;
dataReader.Close();
return !hasRows;
}
catch(Exception e)
{
Console.WriteLine ("[DataMgr]CanRegister fail " + e.Message);
return false;
}
}
(4)登陆密码校验
public string CheckPassWord(string id, string pw)
{
//防止sql注入
if (!IsSafeStr (id) || !IsSafeStr (pw))
return "SYMBOL_INV";
//查询
string cmdStr = string.Format("select * from user where id = '{0}' and pw = '{1}';", id, pw);
MySqlCommand cmd = new MySqlCommand (cmdStr, sqlConn);
try
{
MySqlDataReader dataReader = cmd.ExecuteReader();
bool hasRows = dataReader.HasRows;
dataReader.Close();
if(hasRows)
return "LOG_SUCCESS";
else
return "INF_WRONG";
}
catch(Exception e)
{
Console.WriteLine ("[DataMgr]CheckPassWord " + e.Message);
return "UNKNOWN_ERROR";
}
}
7.服务器将结果返回客户端
第5步中已解决此问题:
conns[index].socket.Send(bytes);
8.客户端根据收到的结果展示不同的提示页面
//因为只有主线程能够修改UI组件的属性
//因此在Update里更换文本
void Update()
{
// //保证每次变化只更新一次窗口
if (resOld != resNew) {
UpdateWindow (resNew);
Debug.Log("Window updated!");
}
resOld = resNew;
// //recvText.text = recvStr;
}
void UpdateWindow (string res)
{
if (res == "INF_WRONG") {
Debug.Log("用户名/密码错误!");
infImg[0].gameObject.SetActive (true);
} else if (res == "SYMBOL_INV") {
Debug.Log("使用非法字符!");
infImg[1].gameObject.SetActive (true);
} else if (res == "LOG_SUCCESS") {
Debug.Log("登录成功!");
infImg[2].gameObject.SetActive (true);
} else if (res == "ID_HASREG") {
Debug.Log("该账户已被注册");
infImg[3].gameObject.SetActive (true);
} else if (res == "PASS_INC") {
Debug.Log("两次密码不一致!");
infImg[4].gameObject.SetActive (true);
} else if (res == "REG_SUCCESS") {
Debug.Log("注册成功!");
infImg[5].gameObject.SetActive (true);
}
}
将窗口附在Image对象上,通过改变对应对象的显示状态来控制反馈。
跳转的逻辑比较简单,不再赘述,部分页面效果如下:
- 登录主界面
- 登录异常界面
- 注册异常界面
- 注册成功界面
目前存在的问题:
- 未引入心跳机制,发生异常时不会主动断开连接,造成连接池资源浪费。
- 消息未改变时,再次点击同一按钮时不会再次弹窗。
- 其他未发现问题…
参考资料:
《Unity3D 网络游戏实战》罗培羽·著
书中源代码已获作者授权使用。
用户登录部分算是告一段落了,下一步的目标是实现玩家数据的管理和更新。