一个有技术追求的研发团对,无论使用什么框架、什么工具、什么语言,团队里应该有人有能力把控所使用框架、工具、语言的每一个核心功能的实现细节。团队里的每个成员应该根据自身所长挑选其中一块做深入研究,并把研究成果分享给团队,力争使整个所处团队实力得到提升,达到同行业内顶尖水平。为了实现这个目标,不允许在团队中出现黑盒子,对.NET生态而言,我们需要打开MSBuild, Rosyln, CoreCLR等黑盒子。作为团队的一员,我将花一些业余时间打开.NET MySQL驱动黑盒子,使用.NET Core实现MySQL Client/Server Protocol,并把打开这个黑盒子的过程通过本站点记录下来。

这是使用.NET Core实现MySQL协议连载文章的第一篇。

基础知识

这是我的基础知识复习笔记,您可以直接跳过此章节

网络中进程之间如何通信?

在网络时代,网络中进程间通信无处不在,在本地可以通过进程PID来唯一标识一个进程,但网络中进程如何标识?他们之间又是如何通信的?其实TCP/IP协议族已经帮我们解决了这个问题,网络层的IP地址可以唯一标识网络中的主机,而传输层的协议+端口可以唯一标识主机中的应用程序(进程),因此,可以利用三元组(IP地址,协议,端口)标识网络中的进程,网络中的进程通信就可以利用这个标志与其它进程进行交互。无论应用层采用什么协议,最终都要经过传输层/网络层的TCP/IP协议,而TCP/IP编程有一套标准编程接口——Socket。

什么是Socket?

套接字(Socket)是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元,但Socket跟TCP/IP并没有必然的联系,TCP/IP只是一个协议栈,就像操作系统的运行机制一样,必须要具体实现,同时还要提供对外的操作接口,TCP/IP也必须对外提供编程接口——Berkeley sockets interface.,它是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:

  1. 连接使用的协议;
  2. 本地主机的IP地址;
  3. 本地进程的协议端口;
  4. 远地主机的IP地址;
  5. 远地进程的协议端口;

TCP三次握手建立连接

我们知道TCP建立连接要进行三次握手,大致流程如下:

  • 客户端向服务器发送一个SYN x
  • 服务器向客户端响应一个SYN y,并对SYN x进行确认ACK x+1
  • 客户端再向服务器发一个确认ACK y+1

TCP四次挥手断开连接

TCP断开连接要进行四次挥手,大致流程如下:

  • 应用进程首先调用close主动关闭连接,这时TCP发送一个FIN x
  • 另一端接收到FIN x之后,执行被动关闭,对这个FIN进行确认
  • 一段时间之后,应用进程调用close关闭它的socket,这导致它的TCP也发送一个FIN y
  • 接收到这个FIN的源发送端TCP对它进行确认

这样每个方向上都有一个FIN和ACK

Socket中TCP的三次握手/四次挥手

MySQL协议分析

MySQL客户端与服务器可以通过四种通信协议进行交互:

  • TCP/IP协议(上面的知识储备就是为此准备的,所以,本系列文章假设TCP/IP协议)
  • Unix Socket协议
  • Share Memory协议
  • Named Pipes协议

交互过程

MySQL客户端与服务器的交互主要分为两个阶段

  • 握手认证阶段;
  • 命令执行阶段;

握手认证阶段

握手认证阶段为客户端与服务器通过TCP三次握手建立连接后进行,交互过程如下

  • 服务器 -> 客户端:握手初始化消息
  • 客户端 -> 服务器:登陆认证消息
  • 服务器 -> 客户端:认证结果消息

命令执行阶段

客户端认证成功后,会进入命令执行阶段,交互过程如下

  • 客户端 -> 服务器:执行命令消息
  • 服务器 -> 客户端:命令执行结果

MySQL客户端与服务器的完整交互过程如下

MySQL客户端与服务器的完整交互过程图

MySQL协议中使用到的基本类型

友情提示: 基本类型很重要,是关键知识点之一,对理解代码有帮助。

整型值

MySQL报文中整型值分别有1、2、3、4、8字节长度,使用小字节序传输。 注意: 字节序

字符串(Null-Terminated String)

字符串长度不固定,当遇到’NULL’(0x00)字符时结束。

二进制数据(Length Coded Binary)

数据长度不固定,长度值由数据前的1-9个字节决定,其中长度值所占的字节数不定,字节数由第1个字节决定,如下表:

第一个字节值 后续字节数 长度值说明
0-250 0 第一个字节值即为数据的真实长度
251 0 空数据,数据的真实长度为零
252 2 后续额外2个字节标识了数据的真实长度
253 3 后续额外3个字节标识了数据的真实长度
254 8 后续额外8个字节标识了数据的真实长度

字符串(Length Coded String)

字符串长度不固定,无’NULL’(0x00)结束符,编码方式与上面的Length Coded Binary相同。

报文结构

报文分为消息头和消息体两部分,其中消息头占用固定的4个字节,消息体长度由消息头中的长度字段决定,报文结构如下:

报文结构

消息头

报文长度

用于标记当前请求消息的实际数据长度值,以字节为单位,占用3个字节,最大值为 0xFFFFFF,即接近 16 MB 大小(比16MB少1个字节)。

序号

在一次完整的请求/响应交互过程中,用于保证消息顺序的正确,每次客户端发起请求时,序号值都会从0开始计算。

消息体

消息体用于存放请求的内容及响应的数据,长度由消息头中的长度值决定。

代码实现

使用.NET Core实现MySQL协议,是一个C#类型与MySQL协议内容双向Map的过程:

  • 把MySQL协议报文包拆解成C#类型;
  • 把C#类型封装成MySQL协议报文包;

这篇文章,我们先部分实现把MySQL协议报文包拆解成C#类型相关代码,Socket会把MySQL报文包以buffer(byte[])的形式返回给我们,具体C#代码我们留到下一篇文章。通过把buffer(byte[])一片一片地按照MySQL协议切下来(记录已经被切掉的位置offset以及buffer的最大位置maxOffset),再把切片组装成C#类型,代码如下:

读取报文长度

数据长度不固定,长度值由数据前的1-9个字节决定,其中长度值所占的字节数不定,字节数由第1个字节决定,如下表:

第一个字节值 后续字节数 长度值说明
0-250 0 第一个字节值即为数据的真实长度
251 0 空数据,数据的真实长度为零
252 2 后续额外2个字节标识了数据的真实长度
253 3 后续额外3个字节标识了数据的真实长度
254 8 后续额外8个字节标识了数据的真实长度

从上表可以看出,报文的最大长度是8个字节,8字节 * 每字节8bit = 64bit, 结合C#数据类型,能表示8个字节的基础数据类是是Int64UInt64,因为报文长度不可能为负,所以使用UInt64, 可以使用以下代码实现Protocol::LengthEncodedInteger

public UInt64 ReadLengthEncodedInteger()
{
	// https://dev.mysql.com/doc/internals/en/integer.html
	byte encodedLength = buffer[offset++];
	switch (encodedLength)
	{
		case 0xFC:
			return ReadFixedLengthUInt32(readByteCount: 2);
		case 0xFD:
			return ReadFixedLengthUInt32(readByteCount: 3);
		case 0xFE:
			return ReadFixedLengthUInt64(readByteCount: 8);
		case 0xFF:
			throw new FormatException("Length-encoded integer cannot have 0xFF prefix byte.");
		default:
			return encodedLength;
	}
}

public uint ReadFixedLengthUInt32(int readByteCount)
{
	if (readByteCount <= 0 || readByteCount > 4)
		throw new ArgumentOutOfRangeException(nameof(readByteCount));
	uint result = 0;
	for (int i = 0; i < readByteCount; i++)
		result |= ((uint)buffer[offset + i]) << (8 * i);
	offset += readByteCount;
	return result;
}

public ulong ReadFixedLengthUInt64(int readByteCount)
{
	if (readByteCount <= 0 || readByteCount > 8)
		throw new ArgumentOutOfRangeException(nameof(readByteCount));
	ulong result = 0;
	for (int i = 0; i < readByteCount; i++)
		result |= ((ulong)buffer[offset + i]) << (8 * i);
	offset += readByteCount;
	return result;
}

读取基本类型

MySQL报文中整型值分别有1、2、3、4、8字节长度,使用小字节序传输(注意上面代码位移操作符)。3字节长度的整数值,需要使用C#的Int32、UInt32来存储,由上面public uint ReadFixedLengthUInt32(int readByteCount)实现,其它实现代码如下:

using System;

namespace MySql.Data
{
	internal sealed class ByteArrayReader
	{
		public ByteArrayReader(byte[] buffer, int offset, int length)
		{
			if (buffer == null)
				throw new ArgumentNullException(nameof(buffer));
			if (offset < 0)
				throw new ArgumentOutOfRangeException(nameof(offset));
			if (offset + length > buffer.Length)
				throw new ArgumentOutOfRangeException(nameof(length));

			this.buffer = buffer;
			maxOffset = offset + length;
			this.offset = offset;
		}

		public ByteArrayReader(ArraySegment<byte> arraySegment)
			: this(arraySegment.Array, arraySegment.Offset, arraySegment.Count)
		{
		}

		public int Offset
		{
			get { return offset; }
			set
			{
				if (value < 0 || value > maxOffset)
					throw new ArgumentOutOfRangeException(nameof(value), "value must be between 0 and {0}".FormatInvariant(maxOffset));
				offset = value;
			}
		}

		public byte ReadByte()
		{
			VerifyRead(1);
			return buffer[offset++];
		}

		public void ReadByte(byte value)
		{
			if (ReadByte() != value)
				throw new FormatException("Expected to read 0x{0:X2} but got 0x{1:X2}".FormatInvariant(value, buffer[offset - 1]));
		}

		public short ReadInt16()
		{
			VerifyRead(2);
			var result = BitConverter.ToInt16(buffer, offset);
			offset += 2;
			return result;
		}

		public ushort ReadUInt16()
		{
			VerifyRead(2);
			var result = BitConverter.ToUInt16(buffer, offset);
			offset += 2;
			return result;
		}

		public int ReadInt32()
		{
			VerifyRead(4);
			var result = BitConverter.ToInt32(buffer, offset);
			offset += 4;
			return result;
		}

		public uint ReadUInt32()
		{
			VerifyRead(4);
			var result = BitConverter.ToUInt32(buffer, offset);
			offset += 4;
			return result;
		}

		public uint ReadFixedLengthUInt32(int readByteCount)
		{
			if (readByteCount <= 0 || readByteCount > 4)
				throw new ArgumentOutOfRangeException(nameof(readByteCount));
			VerifyRead(readByteCount);
			uint result = 0;
			for (int i = 0; i < readByteCount; i++)
				result |= ((uint) buffer[offset + i]) << (8 * i);
			offset += readByteCount;
			return result;
		}

		public ulong ReadFixedLengthUInt64(int readByteCount)
		{
			if (readByteCount <= 0 || readByteCount > 8)
				throw new ArgumentOutOfRangeException(nameof(readByteCount));
			VerifyRead(readByteCount);
			ulong result = 0;
			for (int i = 0; i < readByteCount; i++)
				result |= ((ulong) buffer[offset + i]) << (8 * i);
			offset += readByteCount;
			return result;
		}

		// 读取字符串(Null-Terminated String)
		public byte[] ReadNullTerminatedByteString()
		{
			// https://dev.mysql.com/doc/internals/en/string.html
			// Protocol::NulTerminatedString: Strings that are terminated by a 0x00 byte.
			int index = offset;
			while (index < maxOffset && buffer[index] != 0x00)
				index++;
			if (index == maxOffset)
				throw new FormatException("Read past end of buffer looking for NUL.");
			byte[] substring = new byte[index - offset];
			Buffer.BlockCopy(buffer, offset, substring, 0, substring.Length);
			offset = index + 1;
			return substring;
		}

		public byte[] ReadByteString(int readByteCount)
		{
			VerifyRead(readByteCount);
			var result = new byte[readByteCount];
			Buffer.BlockCopy(buffer, offset, result, 0, result.Length);
			offset += readByteCount;
			return result;
		}

		public UInt64 ReadLengthEncodedInteger()
		{
			// https://dev.mysql.com/doc/internals/en/integer.html
			byte encodedLength = buffer[offset++];
			switch (encodedLength)
			{
			case 0xFC:
				return ReadFixedLengthUInt32(readByteCount: 2);
			case 0xFD:
				return ReadFixedLengthUInt32(readByteCount: 3);
			case 0xFE:
				return ReadFixedLengthUInt64(readByteCount: 8);
			case 0xFF:
				throw new FormatException("Length-encoded integer cannot have 0xFF prefix byte.");
			default:
				return encodedLength;
			}
		}

		// 读取字符串(Length Coded String)
		public ArraySegment<byte> ReadLengthEncodedByteString()
		{
			// https://dev.mysql.com/doc/internals/en/string.html
			// Protocol::LengthEncodedString
			var length = checked((int) ReadLengthEncodedInteger());
			var result = new ArraySegment<byte>(buffer, offset, length);
			offset += length;
			return result;
		}

		public int BytesRemaining => maxOffset - offset;

		private void VerifyRead(int length)
		{
			if (offset + length > maxOffset)
				throw new InvalidOperationException("Read past end of buffer.");
		}

		private readonly byte[] buffer;
		private readonly int maxOffset;
		private int offset;
	}
}

总结

ByteArrayReader已经实现了把报文包按照MySQL协议的基本类型切成片再组装成C#类型,接下来就可以根据MySQL各种报文类型,把MySQL协议报文包实现成C#报文类型。下一篇文章,我们将实现如何从Socket中获取报文内容并封装成C#类型,以及实现报文OK_Packet

ABOUT

I'm a developer, I use C/C++, Java, C# and Swift, but I also embrace all the new stuff.

I'm working at Tencent. My family live in Hangzhou, Zhejiang, China. Maybe the difference with other programmers is I already married, and have a lovely daughter. (^<>^)

A man like me, is unique in the world of existence, no matter where you go, there belonging to my stage. Like desert gold, light can not hide forever! Especially my messy hair, melancholy eyes, sigh of stubble, handsome face, are deeply addicted whom even I.

ELSEWHERE