在单元测试或者本地开发环境中,直接依赖一个真实的Memcached实例往往不是好主意:它需要额外安装、占用端口,而且测试之间容易因为残留数据互相干扰。其实Memcached的文本协议非常简单,用Node.js的net模块实现一个兼容协议的模拟服务并不困难,这就是所谓的Memcached Mock思路。本文会带你一步步实现一个支持set、get、delete、incr等命令的轻量级模拟服务,并说明如何在测试中方便地使用它。

一、Memcached文本协议的基本原理
Memcached支持两套协议:二进制协议和文本协议。绝大多数客户端默认使用文本协议,也就是客户端通过TCP发送ASCII命令,服务端逐行返回结果。以最常用的set命令为例,客户端发送的格式是:
set mykey 0 300 5 hello
第一行包含四个部分:命令名key、flags标志位、过期时间(秒)和即将发送的字节数。紧跟一行是值内容,字节数必须和声明的完全一致。服务端处理完成后返回STORED表示成功。get命令则更简单,客户端发送get mykey,服务端返回:
VALUE mykey 0 5 hello END
理解了这套一问一答的格式,实现模拟服务就只剩下三件事:解析命令行、读取定长的数据体、维护一个内存中的Map存储。文本协议设计得很宽松,命令以\r\n结尾,字段之间用空格分隔,这让我们可以用简单的字符串切分完成解析。
二、用net模块实现核心服务
Node.js内置的net模块可以直接创建TCP服务器,不需要任何第三方依赖。核心思路是用一个Map存储数据,值为对象形式,记录flags、过期时间和内容。收到数据后先按行切分,遇到set、add、replace这类携带数据体的命令时,记一个状态标记,等下一行数据到达再完成写入。
const net = require('net');
class MemcachedMock {
constructor() {
this.store = new Map();
this.server = net.createServer(socket => this.handleConnection(socket));
}
handleConnection(socket) {
let buffer = '';
socket.on('data', chunk => {
buffer += chunk.toString();
// 按行解析,可能一次到达多条命令
let index;
while ((index = buffer.indexOf('\r\n')) !== -1) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const resp = this.execute(line, socket);
if (resp === 'WAIT_DATA') return; // 等待数据体
if (resp) socket.write(resp + '\r\n');
}
});
}
execute(line, socket) {
const parts = line.split(' ');
const cmd = parts[0];
if (cmd === 'set' || cmd === 'add' || cmd === 'replace') {
const [key, flags, exptime, bytes] = [parts[1], parts[2], parts[3], parts[4]];
// 简化处理:直接从缓冲区再读一行作为值
this.pending = { cmd, key, flags: Number(flags), exptime: Number(exptime) };
return 'WAIT_DATA';
}
return this.runCommand(cmd, parts.slice(1));
}
runCommand(cmd, args) {
const key = args[0];
switch (cmd) {
case 'get': {
const item = this.getItem(key);
if (!item) return 'END';
return `VALUE ${key} ${item.flags} ${item.value.length}\r\n${item.value}\r\nEND`;
}
case 'delete':
return this.store.delete(key) ? 'DELETED' : 'NOT_FOUND';
default:
return 'ERROR';
}
}
getItem(key) {
const item = this.store.get(key);
if (!item) return null;
if (item.expires > 0 && Date.now() > item.expires) {
this.store.delete(key); // 惰性过期
return null;
}
return item;
}
}
module.exports = MemcachedMock;
上面的代码为了篇幅做了简化,把数据体也当作一行处理。严格来说应该按声明的字节数精确读取,如果值中包含\r\n字符,按行切分会出错。更健壮的做法是维护一个字节数计数器,用Buffer操作而非字符串拼接,这一点在处理二进制值时尤其重要。
三、TTL过期与incr、decr命令的实现
真实Memcached的过期机制是惰性删除加后台轮询,模拟服务里只需要实现惰性删除就够了,也就是get时检查时间戳,过期即删。这里有一个容易被忽略的细节:exptime小于30天的秒数按相对时间算,大于等于30天时会被当作绝对时间戳处理。模拟时建议照搬这个规则,否则测试环境里某些客户端行为会与线上不一致。
const THIRTY_DAYS = 60 * 60 * 24 * 30;
function parseExptime(exptime) {
if (exptime === 0) return 0; // 永不过期
if (exptime > THIRTY_DAYS) return exptime * 1000; // 绝对时间戳
return Date.now() + exptime * 1000; // 相对秒数
}
incr和decr命令要求值为64位以内的数字字符串。实现时先取值、转换成整数、加减后再转回字符串,同时注意返回的新值不带换行符、直接作为一行响应。如果key不存在返回NOT_FOUND,如果值不是数字则返回CLIENT_ERROR,这两个错误路径在测试中经常被遗漏,建议专门写用例覆盖。
四、在测试中集成与现成方案的取舍
有了模拟服务,在测试框架里启动就非常简单。以Mocha为例,在before钩子里监听一个随机端口,after钩子里关闭服务器并清空Map,这样每个测试文件都能获得完全隔离的缓存环境:
const MemcachedMock = require('./memcached-mock');
describe('cache test', function () {
let mock;
before(function (done) {
mock = new MemcachedMock();
mock.server.listen(0, () => { // 0表示随机端口
const port = mock.server.address().port;
process.env.CACHE_PORT = port;
done();
});
});
after(function (done) {
mock.store.clear();
mock.server.close(done);
});
it('should store and get value', async function () {
// 用任意memcached客户端连接 127.0.0.1:CACHE_PORT
});
});
如果不想自己维护代码,npm上也有现成的选择,比如memcached-mock这类直接替换客户端库的方案,它们重写了客户端API,不经过网络层。这类方案集成更快,但缺点是绕过了真实协议解析,无法测出序列化、flags传递这类与协议相关的问题。自建模拟服务走真实TCP链路,可以配合任何语言、任何客户端使用,保真度更高。
总的来说,用Node.js实现Memcached模拟服务是一个投入产出比很高的小项目:核心代码两三百行,却能显著降低测试环境依赖,让缓存相关的单元测试跑得又快又稳。如果你的项目对协议保真度要求不高,可以直接使用社区mock库;如果需要测试协议细节或者跨语言客户端,自建模拟服务是更可靠的选择。