浅析MysqlJs

1,253 阅读12分钟

本文将尝试解析 mysql.js源码


什么是Mysql

到底什么是mysql? 官方的介绍是 MySQL is the world's most popular open source database
如果让你用自己的语言来回答这个问题,你会怎么回答?
这是之前我在公司的一次分享会上提出的问题,看似简单的问题却没人回应,最后是leader回答了我:mysql是一种服务。我觉得我挺认可这个答案的,完善一下就是 mysql是一种基于mysql协议的网络服务。也许你会觉得这是一个非常片面的回答,因为mysql的很多功能都没有体现出来,但是我觉得这是对mysql最形象的描述。


到底要如何操作数据库

就我的经历来说,操作数据库一般就分为两种:工具(Navicatcli)或者业务代码。业务代码中不同语言一般也会采用不同的方式,例如java中可以使用jdbc、或者spring注解(实习时使用过,不太确定),node可以使用mysqljs。不论使用什么方式,最终都是为了查出数据,所以我就一直很好奇到底最原生的数据长什么样子: 例如你在cli中查出来的数据长这样

image.png

但是使用mysqljs你能得到的数据只能是对象的形式

image.png

从本质上来说:不同的工具、工具库都是通过解析mysql数据包、然后封装成某种数据结构再返回给我们。


Mysql协议

我不知道你们一般是怎么去学习一种协议,比如http协议,tcp协议。我觉得靠背他们的定义很难真正去理解,后来我发现最好的办法是用代码实现这种协议,只有实现了这种协议你才能真正理解它。
我有专门去查阅mysql协议的介绍,发现都有些无法理解。简单总结下我个人的理解:mysql协议是一种基于tcp的有状态的应用层协议
mysql底层都是socket编程,通过socket发送和接收一些二进制数据,它不像http协议是纯文本的。
我举个例子,如果你能将下面代码交互过程发送的包全部手动解析出来(利用MysqlJS执行上图中select * from demo1),那你应该就理解了mysql协议。

receive: 74,0,0,0,10,56,46,48,46,50,56,0,10,0,0,0,52,98,3,117,1,60,112,125,0,255,255,255,2,0,255,223,21,0,0,0,0,0,0,0,0,0,0,46,93,40,123,32,30,122,95,114,17,33,28,0,99,97,99,104,105,110,103,95,115,104,97,50,95,112,97,115,115,119,111,114,100,0
...send: 66,0,0,1,207,243,6,0,0,0,0,0,33,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,114,111,111,116,0,20,187,226,180,160,90,88,116,96,100,1,118,223,174,254,246,59,21,19,104,204,115,104,101,112,101,110,103,0
receive: 7,0,0,2,0,0,0,2,0,0,0
...send: 20,0,0,0,3,115,101,108,101,99,116,32,42,32,102,114,111,109,32,100,101,109,111,49
receive: 1,0,0,1,5,47,0,0,2,3,100,101,102,7,115,104,101,112,101,110,103,5,100,101,109,111,49,5,100,101,109,111,49,4,78,65,77,69,4,78,65,77,69,12,33,0,44,1,0,0,253,0,0,0,0,0,45,0,0,3,3,100,101,102,7,115,104,101,112,101,110,103,5,100,101,109,111,49,5,100,101,109,111,49,3,65,71,69,3,65,71,69,12,63,0,11,0,0,0,3,0,0,0,0,0,45,0,0,4,3,100,101,102,7,115,104,101,112,101,110,103,5,100,101,109,111,49,5,100,101,109,111,49,3,83,69,88,3,83,69,88,12,33,0,44,1,0,0,254,0,0,0,0,0,43,0,0,5,3,100,101,102,7,115,104,101,112,101,110,103,5,100,101,109,111,49,5,100,101,109,111,49,2,105,100,2,105,100,12,63,0,11,0,0,0,3,3,80,0,0,0,53,0,0,6,3,100,101,102,7,115,104,101,112,101,110,103,5,100,101,109,111,49,5,100,101,109,111,49,7,99,111,117,110,116,114,121,7,99,111,117,110,116,114,121,12,33,0,30,0,0,0,253,0,0,0,0,0,5,0,0,7,254,0,0,34,0,16,0,0,8,5,108,105,115,105,51,2,49,56,3,231,148,183,1,50,251,18,0,0,9,7,119,97,110,103,119,117,50,2,49,56,3,229,165,179,1,51,251,17,0,0,10,8,122,104,97,110,103,115,97,110,2,49,54,1,54,1,52,251,12,0,0,11,6,229,188,160,228,184,137,251,251,1,53,251,14,0,0,12,6,231,142,139,228,186,148,2,50,48,0,1,54,251,5,0,0,13,254,0,0,34,0

这是我在mysqljs中打断点截取的,是一次完整的查询过程中抓到的包。receive是接收的包,send是发送的包,数据格式都是Buffer,由于显示不下,这里都转成了字符串展示。

简单说下解析规则:
首先肯定是不能简单的采用暴力toString方法解析的,因为255这种二进制数据toString会乱码。
每个包都分为多个小包,包头固定4个长度、例如第一个包74,0,0,0就是包头的内容,前面三个就是包实体占得长度,解析方式为:

Parser.prototype.parseUnsignedNumber = function parseUnsignedNumber(bytes) {
  if (bytes === 1) {
    return this._buffer[this._offset++];
  }

  var buffer = this._buffer;
  var offset = this._offset + bytes - 1;
  var value = 0;

  if (bytes > 4) {
    var err = new Error('parseUnsignedNumber: Supports only up to 4 bytes');
    err.offset = (this._offset - this._packetOffset - 1);
    err.code = 'PARSER_UNSIGNED_TOO_LONG';
    throw err;
  }

  while (offset >= this._offset) {
    value = ((value << 8) | buffer[offset]) >>> 0;
    offset--;
  }

  this._offset += bytes;

  return value;
};

这是mysqljs的解析源码,参数bytes就是包头长度,这里固定为3,参数_offset就是该数据包当前解析的位置,参数_buffer是Buffer对象、记录当前数据包。包头的最后一个二进制数据是用来记录该包是当前会话的第几个包,从0开始记录
顺便介绍下发送时包头的解析方式:比如你要发送一个包给mysql,你的包实体有1000个二进制数据,那么你包头应该怎么写呢

PacketWriter.prototype.writeUnsignedNumber = function(bytes, value) {
  this._allocate(bytes);

  for (var i = 0; i < bytes; i++) {
    this._buffer[this._offset++] = (value >> (i * 8)) & 0xff;
  }
};

bytes这里也固定是3value就是包头的长度,我们可以先不管第一行代码,是用来保证_buffer不越界的。所以你可以试试任意一个数字加密之后是否能解密成功。 接下来就开始解析包里面的内容了。通常第一个字符是用来表示下一次读取的长度,也是用来表示当前包是什么类型的。

Protocol.prototype._determinePacket = function(sequence) {
  var firstByte = this._parser.peak();
  if (sequence.determinePacket) {
    var Packet = sequence.determinePacket(firstByte, this._parser);
    if (Packet) {
      return Packet;
    }
  }
  switch (firstByte) {
    case 0x00: return Packets.OkPacket;
    case 0xfe: return Packets.EofPacket;
    case 0xff: return Packets.ErrorPacket;
  }

  throw new Error('Could not determine packet, firstByte = ' + firstByte);
};

peak方法就是用来读取当前解析位置的二进制数据,我们先不管sequence,它表示你发送的sql对象,每个sequence都会先尝试去解析返回的包,如果没有解析成功,就交由下面的switch兜底:OkPacket表示当前会话结束了、退出当前会话,执行下一个会话。EofPacket表示当前是结束包,统计一些信息的,ErrorPacket表示当前会话报错了,常见的有会话超时。

我们就拿最后一个select的返回结果举个例子
1,0,0,1,5,47,0,0,2,3,100,101,102
先读取到5,表示查询的结果一共有5列,然后读取下一个包一共47个字符,接下来读取到3,正常数据包(非错误包),表示下一次读取三个字符:100, 101, 102

Parser.prototype.parseString = function (length) {
  var offset = this._offset;
  var end = offset + length;
  var value = this._buffer.toString(this._encoding, offset, end);

  this._offset = end;
  return value;
};

直接使用parseString方法解析出字符串def
这里我只是举个例子,mysql底层的各种数据结构还是很多,比如日期,对应的解析方式也不同,我也没有全部研究,但是我觉得最终都是要解析成文本。

MysqlJs源码解读

mysql的解析规则大概是这样,但是具体的细节还有很多,本文只介绍部分数据包的解析方式。

var mysql = require('mysql');

var connection = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: 'root',
  database: 'shepeng'
});
// connection.connect();
var query = 'zhangsan';

connection.query('select * from demo1 where name = ?', query, function (error, results, fields) {
  if (error) throw error;
  console.log(results);
  // console.log('The fields is: ', fields);
});

最终只介绍这几行代码背后发生的事情,其他的情况请大家自行研究。由于源码过于冗长,本文只介绍重要的源码部分。

引入了MysqlJs后、createConnection方法创建了一个链接,实际上这时候并没有连接数据库,而是在下面执行的connect方法才开始连接数据库。代码中进行了注释,是因为query方法中会先判断是否连接再进行查询。

Connection.prototype.connect = function connect(options, callback) {
  if (!callback && typeof options === 'function') {
    callback = options;
    options = {};
  }
  if (!this._connectCalled) {
    this._connectCalled = true;
    // Connect either via a UNIX domain socket or a TCP socket.
    this._socket = (this.config.socketPath)
      ? Net.createConnection(this.config.socketPath)
      : Net.createConnection(this.config.port, this.config.host);
    // Connect socket to connection domain
    if (Events.usingDomains) {
      this._socket.domain = this.domain;
    }
    var connection = this;
    this._protocol.on('data', function(data) {
      console.log('...send:', data.join(','));
      connection._socket.write(data);
    });
    this._socket.on('data', wrapToDomain(connection, function (data) {
      console.log('receive:', data.join(','));
      connection._protocol.write(data);
    }));
    this._protocol.on('end', function() {
      connection._socket.end();
    });
    this._socket.on('end', wrapToDomain(connection, function () {
      connection._protocol.end();
    }));
    this._socket.on('error', this._handleNetworkError.bind(this));
    this._socket.on('connect', this._handleProtocolConnect.bind(this));
    this._protocol.on('handshake', this._handleProtocolHandshake.bind(this));
    this._protocol.on('initialize', this._handleProtocolInitialize.bind(this));
    this._protocol.on('unhandledError', this._handleProtocolError.bind(this));
    this._protocol.on('drain', this._handleProtocolDrain.bind(this));
    this._protocol.on('end', this._handleProtocolEnd.bind(this));
    this._protocol.on('enqueue', this._handleProtocolEnqueue.bind(this));
    if (this.config.connectTimeout) {
      var handleConnectTimeout = this._handleConnectTimeout.bind(this);
      this._socket.setTimeout(this.config.connectTimeout, handleConnectTimeout);
      this._socket.once('connect', function() {
        this.setTimeout(0, handleConnectTimeout);
      });
    }
  }
  this._protocol.handshake(options, wrapCallbackInDomain(this, callback));
};

可以发现,Mysql.js的本质就是Socket连接,上图中抓到的包就是这在这里打印出来的。

然后我们看看query方法

Protocol.prototype.query = function query(options, callback) {
  return this._enqueue(new Sequences.Query(options, callback));
};

很简单的一行代码。本质上就是内部维护了一个队列,用来收集你要执行的操作,那么如何区分你要执行的操作呢?这里就引入了Sequences对象。我们打开_enqueue方法你会发现只是往你要执行的操作对象上注入一些回调事件、并没有触发_socket.write。这是因为连接还没有建立,所有的操作必须先认证,建立连接后,再依次从队列中取出执行。

那么连接到底什么时候建立的呢,代码中并没有任何其他触发建立连接的操作了。我们仔细观察上图中收发到的包,会发现第一个包是先收到的,也就是说我们执行Net.createConnection创建socket后,是mysql先联系我们的,而不需要我们主动发送连接请求。

实际上在你执行任何操作之前,Mysql.js都会先注册一个Handshake类型的Sequences,目的就是用来处理mysql发送过来的第一个包,包内容每次都不一样,里面包含了很多关于mysql的基本信息,主要还有个用来加密密码的秘钥,第一个接收包的主要字段如下:

HandshakeInitializationPacket.prototype.parse = function(parser) {
  this.protocolVersion     = parser.parseUnsignedNumber(1);
  this.serverVersion       = parser.parseNullTerminatedString();
  this.threadId            = parser.parseUnsignedNumber(4);
  this.scrambleBuff1       = parser.parseBuffer(8);
  this.filler1             = parser.parseFiller(1);
  this.serverCapabilities1 = parser.parseUnsignedNumber(2);
  this.serverLanguage      = parser.parseUnsignedNumber(1);
  this.serverStatus        = parser.parseUnsignedNumber(2);

  this.protocol41          = (this.serverCapabilities1 & (1 << 9)) > 0;

  if (this.protocol41) {
    this.serverCapabilities2 = parser.parseUnsignedNumber(2);
    this.scrambleLength      = parser.parseUnsignedNumber(1);
    this.filler2             = parser.parseFiller(10);
    // scrambleBuff2 should be 0x00 terminated, but sphinx does not do this
    // so we assume scrambleBuff2 to be 12 byte and treat the next byte as a
    // filler byte.
    this.scrambleBuff2       = parser.parseBuffer(12);
    this.filler3             = parser.parseFiller(1);
  } else {
    this.filler2             = parser.parseFiller(13);
  }

  if (parser.reachedPacketEnd()) {
    return;
  }

  // According to the docs this should be 0x00 terminated, but MariaDB does
  // not do this, so we assume this string to be packet terminated.
  this.pluginData = parser.parsePacketTerminatedString();

  // However, if there is a trailing '\0', strip it
  var lastChar = this.pluginData.length - 1;
  if (this.pluginData[lastChar] === '\0') {
    this.pluginData = this.pluginData.substr(0, lastChar);
  }
};

其中scrambleBuff1就是用来加密密码的秘钥, 加密过程不在本文讨论范围。
解析完成后将一些列数据(包括database等信息)再返回给mysqlmysql会给我们返回个OkPacket,这个时候就完成了握手,所有的会话就可以在断开之前畅通无阻了。

Protocol.prototype._emitPacket = function(packet) {
  var packetWriter = new PacketWriter();
  packet.write(packetWriter);
  this.emit('data', packetWriter.toBuffer(this._parser));

  if (this._config.debug) {
    this._debugPacket(false, packet);
  }
};

发送包的代码也就简短几行,本质上也很简单,把你要发的数据按顺序塞到一个Buffer中,然后通过Socket发送出去就完事了。但是需要注意的是一个大包里面可能有多个小包,每个小包要按包头(length+number)+包body的形式来拼接,就和你收到的包一样的格式。

下面看看如何处理接收到的包,主要是通过模块Parser.js来进行解析

Parser.prototype.write = function write(chunk) {
  this._nextBuffers.push(chunk);
  while (!this._paused) {
    var packetHeader = this._tryReadPacketHeader();
    if (!packetHeader) {
      break;
    }
    if (!this._combineNextBuffers(packetHeader.length)) {
      break;
    }
    this._parsePacket(packetHeader);
  }
};

while循环不停的解析包头、然后通过包头来解析包内容,一直到整个包解析完成。
具体的解析过程这里就不赘述了,由于包的类型太多,大家可以自行研究。

最后

非常建议大家打个断点调试一下,对网络协议这一块会有一个比较深的认识。

也希望大家思考下如何借鉴mysql协议实现http协议。

如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。