一个人从 2014 到 2018 断断续续写过八个聊天器,中间有一段他自己在归档时从没打开过。这次翻出来的就是那段里的一对:一个客户端、一个服务端,日期都是 2017-10-05。方法还是那套——易语言不用装 IDE 也能把 .e 读成文本,怎么读、读出来哪些是工具塞进去的噪音,另一篇讲容器的已经写了。这里只谈读出来的东西本身:一个很小的 IM 协议,是怎么在这个版本里成形,又跟前后差在哪。
协议的全部,就几行
客户端连上服务器,服务器第一件事是把这条连接的客户标识用一行 IP=<标识> 发回去。这个标识是服务器眼里这条连接本身(就是连接地址),不是什么令牌。从这一刻起,客户端往后发的每一行都以这个标识开头,格式就是空格隔开的 <标识> <命令> <参数…>。服务端拿空格一切,第二段是要干什么:
/reg 用户名 密码注册/login 用户名 密码登录/w 对象 内容发消息;对象写all就群发,否则私聊/gxIP 用户名把新地址重新绑回这个用户名之前那次登录
服务器往下只有三种东西:IP= 那种标识下发、登录成功 <用户名>、以及 /List 后面跟一串在线用户名。登录成功 回给客户端的同时,服务器把这对用户丢进两个平行数组,一个存标识、一个存显示名;客户端把收到的用户名当成显示名存下来,界面里发消息就从这个数组挑。注意这一版里显示名是被账号绑死的:登录回来是什么用户名,马甲就是什么,没有登录时临时选一个的余地。
私聊那步有个细节值得学:服务器把消息既发给目标、也回显给发送者本人,用的是同一串 马甲 + "说:" + 内容。所以发送方看到的聊天记录和自己的发言是服务器喂回来的,不是本地自己写的。这在一个客户端里能省掉“我这句话到底送出去没有”的状态——只要回显来了,就说明服务器收到了。
文本协议里,按字节数切片是个坑
这套东西全是拿空格切、拿固定偏移截的,而偏移是字节不是字。易语言里“取文本长度”返回的是字节数,一个汉字在 GBK 里占两字节。服务端登录成功那句发的是“登录成功 ”加用户名,“登录成功”四个汉字八个字节再加一个空格正好九字节,于是客户端就把收到的串右边数、砍掉前面九字节拿剩下的当显示名。发到私聊正文那一步更绕:整行是“标识 空格 /w 空格 目标 空格 正文”,服务端要把正文剥出来,就用总长减去标识和目标各自的长度、再减去那五个固定分隔字节。这种写法语义都在,但脆——命令名多一个字符、分隔符少一个空格,切片当场错位,而且错得不响,只是把半截用户名当正文广播出去。读别人的老协议时,凡是看到按数字偏移切字符串,第一反应就该是“这个数字是按字节还是按字算的”,这一步不想清楚,后面全歪。
它是怎么长出来的
跟更晚的那一对比,差别很实在。晚的版本给消息打了频道标签,用 /xxk、/jrwb 这类前缀区分走哪条路,把显示名也松绑成登录时自选的马甲,还引入了用文件当票的凭证;这一版一件都没有,就一个房间、名字即身份、消息不带任何频道。换句话说,从这一版到那一版,多出来的不是新语法,是“把一条连接上的多种流量分开、把身份从账号里解耦”这两件事——协议要长大,几乎都是从“一个会话里只装得下一件事”变成“一个会话里要能并存好几件事”开始的。至于 2016 那版,我没有它的代码在手,不去猜它长什么样,只说能验证的这一版:它比任何一个把消息直接拼成一大坨的初学者实现,多了“先下发身份、再按前缀派命令”这个骨架,这就是它能一路加功能的原因。
客户端自己的分工也说明协议在往“能连任意机器”长。它分两个窗体:一个登录窗,一个聊天窗,各自一份程序集。连服务器不写死——先试本机 127.0.0.1:12914 自测,不行再连那个穿透域名,还不行就弹一框问你要不要手填一个“地址:端口”,勾上那个选项就能把整行地址喂进去现连。断线了不报错退出,而是直接重开一条线程再走一遍连接流程。这套“默认目标 + 备用目标 + 手动兜底 + 断线重连”的四段式,是从“我给自己电脑上写个小玩具”迈向“这东西要能给陌生人连”时才长出来的壳子。
地址会漂,所以要背一个稳定句柄
这一对是奔着公网去用的,作者没有公网 IP,靠一个蒲公英那类内网穿透。走这种中转,麻烦在于客户端每次重连,服务器看到的连接地址都可能变——上一秒的标识下一秒就对不上了。/gxIP 就是为这个设计的:服务器那边把在线用户的显示名当作不变的锚,重连后客户端拿着“我还是刚才那个用户名”的话回去,服务器在数组里按名字找到那一格,把标识换成新地址,顺手把在线名单重新算一份、用 /List 推给所有人。这套思路能带走:当底层地址不可靠时,别把身份绑在地址上,带一个自己的稳定句柄,让服务方靠句柄重新认领你。在线状态也是这么维持的——没有心跳轮询,全靠在有人进、有人走、有人重绑时,服务器把整份名单重新群发一遍来推。
/gxIP 之所以不是可选项而是必需项,还藏在客户端的一个动作里:登录成功那一刻,它不是接着这条连接往下聊,而是先 断开连接,再另开一个聊天窗、重新连一次。也就是说验证身份走的是第一条 TCP 连接,真正聊天走的是第二条全新的连接,而第二条连接在服务器眼里又是个陌生的新地址、刚被重新发了一个 IP=。若不把这第二条连接的标识认领回刚才登录成功的那个用户名,服务器根本不知道这条新线是谁,/w 发出去只会撞回那句“您的 ID 没有在本系统上注册”。所以聊天窗一收到 IP=,头一件事就是补一句 /gxIP 用户名 把自己焊回去。把登录和会话拆成两条连接,本意大概是图界面清爽,但它无意中造出了一个“身份必须跨连接迁移”的问题,而 /gxIP 就是给这个问题打的补丁——很多协议里那些看着多余的“重新握手/重新认领”步骤,根子上都是这种“换了条线,身份没跟着换”的裂缝。
那个穿透域名还捎带一个教训。蒲公英给免费穿透分配的形如 <名字>.e2.luyouxia.net,这里的 <名字> 用的就是他一个旧邮箱登录名(只是中间的下划线没了)。也就是说,同一个用户名被复用在了两个毫不相干的地方:一个是他收发邮件的账号,一个是聊天客户端里硬编码的服务器子域。脱敏的时候,只挡住邮箱是不够的——那个串换了个形态又出现在代码常量里,还嵌在主机名的最左一段里。这也是为什么这里坚持等长替换:那个登录名十一字符换成十一字母的占位、子域那十个字母换成十个占位,长度一个字都不能变,因为长度本身带着信息,改了长度就等于顺手动了那条字符串常量的字节数,连带把上面那套按字节切片的偏移一起改坏。
在线名单,和那个“一万格”的上限
/List 是这一版里唯一带点结构味的下行:服务器把当前所有在线用户的显示名用空格串成一串,前缀一个 /List(重绑那条路径上还多塞一个 all 当占位),推给每个人;客户端把前缀和那个占位砍掉,再按空格切成一个列表,灌进发消息时挑对象的那个下拉框。谁进、谁走、谁重连,服务器都现拼一份整表重新广播,所以在线名单始终是全量覆盖、不做增量。客户端切片时那句“按空格分成最多一万个”,和服务端那两个各开一万格的平行数组,其实是同一个魔法常数:这台服务器能记的在线人数、一次名单里能装的条目,全被这个写死的 10000 卡着。读老代码时这种硬编码上限特别值得记一笔——它往往就是这个设计真正的天花板,比任何注释都老实;数组满了往后加人就静默丢掉,不是报错,是压根登不上,因为它找不到空槽登记你。
服务器的“数据库”就是几个文件
账号系统朴素到近乎没有:每注册一个用户,就在服务器落一个以用户名命名的 .ini,文件里直接写密码,明文,不哈希、不加盐。登录时把用户输入和这个文件比对,一致就放行。封禁是另一个文件 ban.ini,按行记被管理员拉黑的 ID。有意思的是这两处检查用了两种不同写法:注册那条是把 ban.ini 按行拆开逐条等值比对,登录那条却是直接在这整个文件里搜这个名字——后者是子串匹配,只要某个被禁的 ID 里包含了你用的名字,你也会被当成被禁,反过来也一样会误伤。同一个人写的两段逻辑对同一件事采取两种判法,这类不一致在老代码里很常见,也是读的时候该留个记号的地方。而且这份文件是每来一条消息都重读一遍,所以管理员改完封禁列表不用重启服务,代价是每个包都多一次磁盘读。除此之外,在线名单和标识绑定全活在那个一万格数组里,进程一停就归零,重启得靠重连的客户端各自 /gxIP 认领回来。
管理员那一侧就是个实时控制台。服务器每收到一条上行,原样追加到界面上一个编辑框里滚着看;下面留了两个能力,一个把一段话群发给所有在线的人,一个从在线名单里挑一个名字单发。没有权限分层,谁开着服务端谁就是上帝——这也符合它“自己电脑上开着给朋友连”的定位。
得说清楚这里没有什么
没有鉴权会话——标识就是连接地址,不轮换、不失效;连“你是谁”都靠你报的那个用户名和一个明文密码文件撑著。没有加密——所有命令和聊天正文都是明文行,中间人一眼看全。没有历史留存——服务器不写聊天记录,也不做离线消息,人一走,那一格就清空。还有一个更隐蔽的设计后果:既然身份绑的是连接地址,而所有客户端都从同一个内网穿透隧道过来,两个套在同一出口后面的客户端,理论上有拿到相同标识、彼此顶替的风险,这一版没有任何机制区分它们。这不是批评,一个 2017 年的业余小工具本就不需要处理这些;但把它当协议标本读的时候,“没有”和“有”一样重要,免得日后误以为它做过的其实没做。
读法与工具痕迹
仓库里那份从 .e 容器机器抽出来的源码文本,不是 IDE 里看到的样子。抽取工具会在某些字符串常量上留下它自己的痕迹,一类是路径那类常量里的 #e2txt_text#(这套服务端文本里那几条 .ini 路径上就压着它),另一类是核心支持库(系统核心支持库的库名是 krnln)内建调用前的 krnln:: 前缀;这两个 token 在 IDE 视图里都不会出现,读源码时按噪音忽略。这一对的抽取只落了前者,后者要换另一类抽取器才会浮现。怎么不装 IDE 把 .e 读成文本,见另一篇 https://t1mmoc.github.io/posts/2026-10-17-epl-e-container-reading/。
仓库 https://cnb.cool/t1mmoc/tspace/eyuyan/epl-chat-2017 里同时放了脱敏后的 客户端.e 与 服务端.e 两个工程文件,以及机器抽出的可读源码文本;文本里带着上面说的 #e2txt_text#、krnln:: 这类抽取工具塞进去的痕迹,这些在易语言 IDE 的源码视图里并不存在。