简介:一套以Java实现的TACACS+协议客户端与服务端完整源码,面向需要对接AAA认证体系的Java开发者和网络运维人员,用于解决网络设备访问控制中的身份验证、授权与记账问题。zip压缩包约107KB,共36个文件,其中23个Java源文件覆盖认证、授权、记账等核心模块,另有XML配置文件、Gradle构建脚本、依赖JAR包及说明文档,可辅助快速编译与运行环境搭建。目前已有84人学习下载。源码包含完整的TACACS+报文交互逻辑、服务端并发连接处理与安全防护示例,以及客户端登录验证的调用流程;配套的配置文件与部署指南可帮助搭建实际环境,单元测试用例则便于验证功能正确性。整体按模块化分层组织,留有自定义认证策略与用户数据库的扩展接口,适合作为学习TACACS+协议及Java网络编程的中高阶参考实现。
1. TACACS+ Java 客户端与服务端:从零接管设备 AAA 会话
你手头有一批交换机、路由器要做统一接入认证,不再把 enable 密码写死在设备配置里,而是丢给认证服务器判断。TACACS+ 就是干这件事的协议,Java 生态里能同时给出客户端和服务端完整实现的资源不多。这份资源把两端都打包了:一边是模拟网络设备发起认证的 Java 客户端,一边是接收设备请求并返回认证授权记账结果的服务端,在没有思科 ACS 的环境里也能把整个 AAA 流程跑通。
反直觉的是,TACACS+ 走 TCP 49 端口却不是文本协议,包体要按字节手工组,用户名密码要用共享密钥配合 MD5 伪随机流做加密。很多人卡住的不是协议逻辑,而是「配置全对,服务端解出来却是乱码」这种玄学问题。
适合的人有两类:一类是做网管平台、运维自动化系统的 Java 后端,要把设备认证接到自有用户体系里;另一类是搞模拟器、测试平台、需要造报文的开发者。新手照着服务端代码能把流程跑通,熟手可以重点看组包、拆包和加密细节。
2. 协议层先立住:TACACS+ 的包结构、加密与三种会话
写 Java 实现之前,先花二十分钟把协议骨架理顺。TACACS+ 最早是思科私有协议,后来在 RFC 8907 里标准化。它的定位和 RADIUS 不一样,很多 Java 后端第一次接触时容易用 RADIUS 的思路去套,全都套歪了。
2.1 TACACS+ 与 RADIUS 的选型差异:为什么设备侧常用它
RADIUS 用 UDP,把认证和记账混在同一个协议里,加密只覆盖口令等少数字段,剩下的属性基本明文传输,适合 ISP 拨号、Wi-Fi 接入这类场景。TACACS+ 用 TCP 49 端口,整个包体都参与加密,而且把认证(Authentication)、授权(Authorization)、记账(Accounting)彻底拆成三种报文,设备可以独立控制「谁能登录」和「登录后能敲什么命令」。
| 对比维度 | RADIUS | TACACS+ |
|---|---|---|
| 传输层 | UDP,丢包靠应用层重试 | TCP,可靠传输、有连接状态 |
| 加密范围 | 仅加密口令等部分属性 | 整个包体用共享密钥加密 |
| AAA 分离 | 认证与记账混在一起 | 认证、授权、记账三种报文独立 |
| 命令级授权 | 不支持 | 支持,可对每条命令做授权 |
| 典型场景 | 拨号接入、无线认证 | 网络设备管理、命令审计 |
如果你只是做终端接入认证,RADIUS 够用;如果要管设备 enable、命令行权限、命令审计,TACACS+ 是常规选择。这份资源选 TACACS+ 的原因也在这里:服务端不只是回一个 pass/fail,还要能回答「这条命令放不放行」。
2.2 12 字节包头部:版本、类型、序列号与标志位
所有 TACACS+ 报文固定 12 字节头部,网络字节序,Java 侧直接用DataInputStream读即可。头部字段是后续一切组包拆包的基础,我习惯先把这张表贴在代码注释里。
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | version | 1 字节 | 常见值 0xC0,主版本 0x0C、次版本 0x00 |
| 1 | type | 1 字节 | 1=认证,2=授权,3=记账 |
| 2 | seq_no | 1 字节 | 从 1 开始递增,每个包加 1 |
| 3 | flags | 1 字节 | 第 0 位是 1 表示明文,第 2 位为 1 表示单连接模式 |
| 4 | session_id | 4 字节 | 本次会话随机生成,同一会话所有包共用 |
| 8 | body_len | 4 字节 | 包体长度,解密前的密文长度 |
seq_no 是无符号字节,Java 读出来记得& 0xFF,否则序号大于 127 时变负数。flags 里最容易踩的是「0x01 表示不加密」:很多新手以为 1 代表加密,结果把明文包当成密文解,解出来全是乱码。
提示:头部没有 CRC 校验字段。能否正确解密本身就是完整性校验,如果解出来第一个字节不是预想的类型值,基本就是密钥、session_id 或字节序不匹配。
2.3 包体加密:MD5 伪随机流与共享密钥
TACACS+ 的加密不是 AES 这类标准算法,而是用共享密钥做种子,生成一段和包体等长的伪随机字节流,再和明文按位异或。伪随机流生成规则是:第一块等于MD5(session_id + key + seq_no),如果不够长,后续每一块把上一块摘要追加在 seq_no 后面继续算。
public static byte[] buildPseudoPad(byte[] sessionId, byte[] key, byte seqNo, int bodyLen) throws Exception { ByteArrayOutputStream pad = new ByteArrayOutputStream(); byte[] prev = null; while (pad.size() < bodyLen) { MessageDigest md = MessageDigest.getInstance("MD5"); md.update(sessionId); // 4 字节,网络字节序 md.update(key); // 两端配置的共享密钥,UTF-8 字节 md.update(seqNo); // 1 字节,当前包序号 if (prev != null) { md.update(prev); } prev = md.digest(); pad.write(prev); } return pad.toByteArray(); }这段代码的逻辑是:第一次循环生成MD5(session_id + key + seq_no),第二次循环在 seq_no 后面追加第一次的 16 字节摘要,形成链式扩展,直到伪随机流长度覆盖包体。加密时把 pad 截取到bodyLen,与明文逐字节异或;解密用完全相同的参数重算一遍。
参数上最容易出分歧的是 session_id 的字节序。SecureRandom.nextInteger()得到的 int 要按大端拆成 4 字节,很多实现直接用ByteBuffer.putInt默认的大端序,没问题;如果哪端手写成了小端,两端配置一致也永远解不对。密钥统一用 UTF-8 编码,不要用平台默认字符集,跨服务器部署时默认字符集不一样结果就崩了。
2.4 三种会话数据体:认证、授权、记账分别装什么
三种报文在 Java 里可以映射成三个数据类,但字段差异很大,不能共用一套解析。
| 报文 | 主要字段 | 说明 |
|---|---|---|
| AUTHEM 认证应答 | status、action、server_msg_len、data_len | status 决定 PASS/FAIL 还是继续要数据 |
| AUTHOR 授权请求 | authen_method、priv_lvl、service、user、port、arg 列表 | 用 arg 属性对表达 service=shell、cmd=show |
| ACCT 记账请求 | flags、service、user、port、arg 列表 | flags 区分 start、stop、update |
认证是交互式状态机:设备先发 START,服务端返回 GETPASS 或 GETUSER,设备再发 CONTINUE 把密码补上。授权更像一问一答:设备敲命令前来问一次,服务端回 permit 或 deny。记账基本是单向通知,设备发 start/stop,服务端回一个 ACK 就算完事,重点是把审计字段落库。
理解这三个数据体,服务端和客户端的代码就都能看懂了。接下来先写服务端,因为调试客户端时需要一个能回包的服务端当靶子。
3. Java 服务端实战:Netty 解析设备请求并返回 AAA 决策
3.1 搭建 TCP 服务:绑定 49 端口并解决拆包
TACACS+ 服务端本质上就是一个 TCP 49 端口上的字节流解析器。用 Netty 比裸ServerSocket省心,线程模型和拆包都有现成机制。监听 49 端口需要 root 权限,本地调试可以先绑 1049,设备侧对应的tacacs-server host配置一起改。
public class TacacsServer { public static void main(String[] args) throws Exception { EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(4); try { ServerBootstrap b = new ServerBootstrap(); b.group(boss, worker) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new TacacsPacketDecoder()); ch.pipeline().addLast(new AuthHandler()); ch.pipeline().addLast(new AuthzHandler()); ch.pipeline().addLast(new AcctHandler()); } }) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.TCP_NODELAY, true); ChannelFuture f = b.bind(49).sync(); f.channel().closeFuture().sync(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } } }boss线程只负责 accept 新连接,worker线程做实际的 IO 读写。SO_BACKLOG 128控制等待队列长度,网络设备突发断连重连时能顶住排队。TCP_NODELAY一定要开,TACACS+ 交互是多次往返的小包,不开的话 Nagle 算法会把确认延迟叠上去,设备侧登录明显变慢。
3.2 自定义解码器:按 body_len 读完一包再解密
TACACS+ 没有长度前缀式的帧格式,拆包要靠头部偏移 8 处的body_len。Netty 里写一个ByteToMessageDecoder,先读 12 字节头部,再根据 body_len 决定要不要继续等。
public class TacacsPacketDecoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { if (in.readableBytes() < 12) { return; } in.markReaderIndex(); byte version = in.readByte(); byte type = in.readByte(); int seqNo = in.readByte() & 0xFF; int flags = in.readByte() & 0xFF; int sessionId = in.readInt(); int bodyLen = in.readInt(); if (bodyLen <= 0 || bodyLen > MAX_BODY) { ctx.close(); return; } if (in.readableBytes() < bodyLen) { in.resetReaderIndex(); return; } byte[] body = new byte[bodyLen]; in.readBytes(body); out.add(new TacacsPacket(version, type, seqNo, flags, sessionId, body)); } }这里最关键的是半包处理:第一次可能只到了 6 个字节,头部都没收齐,直接 return;Netty 会保留 buffer 等下一次读事件。如果头部收齐但 body 没到齐,resetReaderIndex()把读指针退回 mark 位置,等 body 凑齐再处理。bodyLen加上上限校验,否则被畸形包撑爆直接 OOM。
3.3 认证处理器:从 START 到 CONTINUE 的状态机
认证报文进入 handler 时,先解密,再根据seq_no区分 START 和 CONTINUE。seq_no 为 1 是 START,2 及以上是 CONTINUE。START 包体第一个字节是动作,常见值 LOGIN=0x01;REPLY 包体第一个字节是状态,第二个字节才是后续动作,别搞反。
public class AuthHandler extends SimpleChannelInboundHandler<TacacsPacket> { private final byte[] sharedKey = "...".getBytes(StandardCharsets.UTF_8); @Override protected void channelRead0(ChannelHandlerContext ctx, TacacsPacket pkt) { if (pkt.type() != 1) { ctx.fireChannelRead(pkt); return; } byte[] plain = TacacsCrypto.decrypt(sharedKey, pkt.sessionId(), pkt.seqNo(), pkt.flags(), pkt.body()); if (pkt.seqNo() == 1) { String user = AuthStart.readUser(plain); byte status = (user == null || user.isEmpty()) ? TAC_PLUS_AUTHEN_STATUS_GETUSER : TAC_PLUS_AUTHEN_STATUS_GETPASS; byte[] reply = AuthReply.build(status, (byte) 0, "Password: "); writeEncrypted(ctx, pkt, reply); } else { String username = AuthContinue.readUserMsg(plain); String passwd = AuthContinue.readData(plain); boolean ok = userChecker.check(username, passwd); byte status = ok ? TAC_PLUS_AUTHEN_STATUS_PASS : TAC_PLUS_AUTHEN_STATUS_FAIL; writeEncrypted(ctx, pkt, AuthReply.build(status, (byte) 0, null)); } } }START 带用户名但不带密码,服务端根据用户名是否为空决定回 GETPASS 还是 GETUSER。CONTINUE 的包体前两个字节分别是user_msg_len和data_len,后面跟用户名和密码数据。userChecker在这里是一个接口,换成 LDAP、数据库或者本地配置文件,只影响这一个方法的实现,协议层不用动。
3.4 授权与记账:返回决策并落库
授权报文在设备上触发频率比认证高得多,用户每次敲命令都可能来请求一次。授权 handler 的关键是把 arg 列表解析成可读的属性对,比如service=shell、cmd=show,再拿这些属性去匹配策略。
public class AuthzHandler extends SimpleChannelInboundHandler<TacacsPacket> { @Override protected void channelRead0(ChannelHandlerContext ctx, TacacsPacket pkt) { if (pkt.type() != 2) { ctx.fireChannelRead(pkt); return; } byte[] plain = TacacsCrypto.decrypt(sharedKey, pkt.sessionId(), pkt.seqNo(), pkt.flags(), pkt.body()); Map<String, String> args = AuthzRequest.parseArgs(plain); String service = args.getOrDefault("service", "shell"); String cmd = args.getOrDefault("cmd", ""); boolean allowed = policy.evaluate(service, cmd); byte status = allowed ? TAC_PLUS_AUTHOR_STATUS_PASS_ADD : TAC_PLUS_AUTHOR_STATUS_FAIL; writeEncrypted(ctx, pkt, AuthzReply.build(status, List.of("permit"))); } }授权响应状态值常见的是PASS_ADD=0x02,表示通过并附加属性;FAIL=0x10表示拒绝。status 写错不会报异常,但设备侧行为会非常诡异,比如能登录但所有命令都被拒。记账部分相对简单,把 start/stop 报文的用户、端口、命令、时间落进审计表:
CREATE TABLE tacacs_acct ( id BIGINT AUTO_INCREMENT PRIMARY KEY, session_id INT NOT NULL, user VARCHAR(64), device_ip VARCHAR(45), service VARCHAR(32), cmd VARCHAR(255), start_time DATETIME, stop_time DATETIME );记账是单向通知,服务端回 ACK 即可,但要保证写库不能阻塞 IO 线程。常见做法是丢进一个独立线程池或者 MQ,Netty 的 handler 里只做解析和快速响应。
4. Java 客户端实战:让业务系统主动发起认证、授权与记账
4.1 客户端两条路线:开源库组包还是手写字节
客户端的作用是模拟网络设备,最常见的用途有三个:验证服务端逻辑、压测、构造畸形报文做故障演练。社区里有开源的 tacacs-plus-java 客户端库,封装了TacacsClientBuilder,发认证请求很快。但我更建议至少把组包逻辑手写一遍,因为只有手写过才能理解 session_id、seq_no 和伪随机流之间的关系,排查问题时才不会一头雾水。
这份资源里的客户端代码是面向改造写的:核心是一个TacacsPacket构造器加一个响应解析器,用户名密码怎么装、超时重试怎么配,都可以直接改。
4.2 构造认证 START 包并发送
认证 START 包体依次是动作、权限级别、认证类型、认证服务、用户名长度、端口长度、远端地址长度、数据长度,后面跟着用户名、端口、远端地址。Java 里用ByteArrayOutputStream按顺序写即可。
public class TacacsClient { private final String host; private final int port; private final byte[] key; public AuthReply authenticate(String user, String passwd) throws IOException { int sessionId = new SecureRandom().nextInt(); byte flags = 0x00; // 第 0 位为 0,表示包体加密 byte[] body = AuthStart.builder() .action(TAC_PLUS_AUTHEN_ACTION_LOGIN) // 0x01 .user(user) .port("tty0") .remAddr("192.168.1.10") .build(); byte[] encBody = TacacsCrypto.encrypt(key, sessionId, (byte) 1, flags, body); try (Socket sock = new Socket(host, port)) { sock.setSoTimeout(3000); writePacket(sock, sessionId, (byte) 1, flags, encBody); TacacsPacket reply = readPacket(sock); return AuthReply.parse(TacacsCrypto.decrypt(key, sessionId, (byte) 2, flags, reply.body())); } } }flags = 0x00表示加密,这是最容易和直觉相反的地方:0x01 位是明文标志。sessionId每次认证随机生成,同一会话的所有包都复用这个值。setSoTimeout(3000)控制服务端不响应时的等待时间,单位毫秒,设备侧全局超时通常配 5 秒,客户端这里配小一点能更快暴露问题。
4.3 处理 GETPASS 响应:多轮 CONTINUE 的细节
服务端返回的 REPLY 里,status 字段决定流程走向。0x05表示 GETPASS,也就是需要客户端把密码补上来。此时要发 CONTINUE 报文,包体前两个字节分别是user_msg_len和data_len,后面跟用户名和密码数据。
if (reply.status() == TAC_PLUS_AUTHEN_STATUS_GETPASS) { byte[] cont = AuthContinue.builder() .userMsg(user) .data(passwd) .build(); byte[] encCont = TacacsCrypto.encrypt(key, sessionId, (byte) 3, flags, cont); writePacket(sock, sessionId, (byte) 3, flags, encCont); TacacsPacket finalReply = readPacket(sock); return AuthReply.parse(TacacsCrypto.decrypt(key, sessionId, (byte) 4, flags, finalReply.body())); }CONTINUE 的 seq_no 是 3,不是重新从 1 开始;session_id 也不能变。很多第一次写客户端的同学在这里翻车,把 seq_no 重置成 1,服务端用同一套伪随机流去解密就全乱了。如果服务端返回的是 GETDATA,就把响应循环包起来,每次根据 status 再发下一个 CONTINUE,直到拿到 PASS 或 FAIL。
4.4 连接复用与超时参数:别让设备把连接打满
如果每认证一次就建一条 TCP 连接,设备量大时服务端的连接数会涨得很快。TACACS+ 的 flags 里有一个 SINGLE_CONNECT 位,常见值 0x04,表示这条连接可以连续处理多个会话。客户端首次握手时带上这个标志,后续多个认证请求走同一条 TCP 连接,以 session_id 区分不同会话。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 连接超时 | 3~5 秒 | 客户端和服务端两侧都要配 |
| 认证重试 | 2 次 | 超时后重连,超过次数直接失败 |
| SINGLE_CONNECT | 开启 | 降低连接建立开销,但服务端必须支持 |
| 单连接空闲 | 60 秒 | 超过后主动关闭,避免设备侧一直占着连接 |
开启 SINGLE_CONNECT 后,seq_no 在每个会话内部递增,而不是在连接维度递增。服务端如果按连接记录当前序号,两个会话交替发包会把序号搞混,所以必须以 session_id 作为会话维度的 key 来维护状态。
5. 避坑清单:TACACS+ Java 实现里最常见的五类故障
下面五条都是实际调 TACACS+ 时会反复遇到的故障,每一条我都标了现象、原因和解决思路,可以直接当排错手册用。
5.1 服务端解出的密码是乱码,字段长度全不对
现象:用户名能读出来,密码变成一串不可读字节,偶尔还报下标越界,但服务端进程不崩溃。
原因:CONTINUE 包的包体不是「用户名长度 + 用户名」,而是先有user_msg_len和data_len两个长度字节,后面再有对应长度的数据。很多实现把偏移少算了一位,导致密码从错误位置开始读;另外解密时没按原始长度截断,把填充字节也当成了明文数据。
解决:解密后第一步永远是读包体首个标志字节确认报文类型,然后按user_msg_len和data_len的固定偏移去截取字段,不要用「读完整段」的方式。写单测时准备几组固定报文,把解密后的十六进制打印出来比对,能快速定位偏移写错的位置。
5.2 相同密钥两端配置一致,解密还是失败
现象:服务端和客户端配置的密钥一模一样,session_id 打印出来也一致,但服务端解出来依然是乱码。
原因:MD5 伪随机流里 session_id 要用 4 字节大端序,有的实现直接用ByteBuffer默认序就对了,有的手写数组时写成了小端;密钥也可能一边用了 UTF-8、另一边用了 ISO-8859-1,遇到中文密钥时结果完全不同。
解决:先把两端输入的 session_id 和 key 分别打成十六进制日志,比对前 4 字节是否一致。然后单独写一个pseudoPad的单测,固定 session_id、key、seq_no,期望输出用 Python 或 Wireshark 算一遍,两边一致再往下一步排查。
5.3 Netty 服务端偶发半包解析崩溃
现象:压力测试时服务端偶尔抛IndexOutOfBoundsException,或某个设备反复连接失败,重启后又恢复正常。
原因:自定义解码器里没判bodyLen上限,畸形包把缓冲区撑爆;更常见的是半包场景下没有resetReaderIndex(),第二次读事件到来时读指针已经越位,后面的包全对不上。
解决:解码器里加bodyLen <= 0 || bodyLen > MAX_BODY就关闭连接;in.readableBytes() < bodyLen时先markReaderIndex()再 return。这两个动作缺一个,半包问题就会在并发上来时集中爆发。
5.4 认证通过但授权全部 deny
现象:用户能正常登录,但敲任何命令都被拒绝,设备侧提示权限不足,查看服务端日志发现授权响应一直是 fail。
原因:授权请求里的属性对没解析干净。设备发来的参数是service=shell、cmd=show这种形式,如果服务端只匹配 cmd 参数而没匹配 service,策略会把不认识 service 的请求一律拒绝;另一种情况是授权响应的 status 写成了PASS(0x01),但设备要求的是PASS_ADD(0x02)。
解决:在 AuthzHandler 里先把所有 arg 解析成 map 打日志,看设备实际发的属性名和值,再调策略。生产环境建议默认放行service=shell的常规命令,只对configure这类高危命令单独收紧。
5.5 明文模式调试后忘关,密钥形同虚设
现象:测试时为了抓包把 flags 设成了 0x01(明文模式),上线后所有包仍然明文,密钥完全没起作用。
原因:flags 写在多处代码里,或者从常量类读取时只改了一处。明文模式在抓包时确实方便,但一旦漏改,就等于把用户名密码直接暴露在网络上。
解决:加密开关收口到全局配置,flags 的值从配置读取,代码里禁止出现字面量 0x01;上线前全局搜索UNENCRYPTED_FLAG和flags = 1,确认没有漏网。明文模式只允许出现在测试环境,生产环境强制加密。
6. 进阶技巧:抓包验证、并发压测与生产加固
6.1 用 Wireshark 解密验证协议交互
Wireshark 自带的 TACACS+ 解析器支持配置共享密钥解密包体。在 Preferences 里找到 TACACS+ 协议,填上密钥,抓包就能直接看到用户名、密码和授权属性。每次改完组包逻辑,我都先抓一次包确认明文正确,再往下调。这个习惯能省大量时间,因为协议组包出问题时,最直接的现象就是解密失败或字段错位,抓包能一眼看出是哪端的问题。
6.2 用 Java 客户端脚本做 500 并发压测
服务端逻辑调通后,用客户端批量发认证请求,验证服务端在并发下的表现。下面的代码用线程池模拟 500 个设备同时认证。
ExecutorService pool = Executors.newFixedThreadPool(50); CountDownLatch latch = new CountDownLatch(500); AtomicInteger failCount = new AtomicInteger(); for (int i = 0; i < 500; i++) { pool.submit(() -> { try (TacacsClient c = new TacacsClient("127.0.0.1", 49, key)) { AuthReply r = c.authenticate("user", "pass"); if (r.status() != 0x01) { failCount.incrementAndGet(); } } catch (Exception e) { failCount.incrementAndGet(); } finally { latch.countDown(); } }); } latch.await(60, TimeUnit.SECONDS); System.out.println("fail=" + failCount.get());压测看两个指标:失败率和 P99 响应时间。50 个线程并发 500 次请求,全失败率应该为 0,P99 在 20 毫秒以内。如果出现超时,优先看 Netty worker 线程数和后端用户校验的耗时,不要在认证接口里做重数据库查询。
6.3 生产加固:日志脱敏与宕机演练
上线前还有三件事要做:记账落库并出审计报表,按用户、设备、命令维度聚合;服务端日志里共享密钥统一打****,密码字段禁止进日志;做一次「认证服务器宕机」演练,把服务端停掉,观察设备侧是否 fallback 到本地账号。
我做网管平台那会儿,上线前一天发现授权响应 status 用了PASS而不是PASS_ADD,导致全量设备的配置命令被拒,被运维追着问了一下午。从那以后,我每次动协议代码都强制走一遍「Wireshark 解密验证、500 并发压测、服务端宕机演练」三步,确认没问题才敢提交。TACACS+ 这种二进制协议,肉眼检查远不如抓包可靠。这份资源里客户端和服务端的工程都配好了,下载后先跑通服务端,再用客户端做一轮验证和压测,整个过程会比从零写快得多,希望帮到你。
本文还有配套的精品资源,点击获取