简介:这份资源面向Java后端开发者、企业信息化实施人员及需要对接票据打印场景的技术人员,提供一套基于Java Socket与爱普生打印机9100端口通信的Web打印服务方案,解决网络环境下打印任务提交、指令控制与队列调度的实际问题。压缩包共4个文件,包含1个Java源码、1个Markdown说明、1个txt文档和1个docx资料,整体约37KB,体量轻便,便于快速阅读与二次开发。系统以ESC/POS指令集为核心,覆盖打印头控制、字符与图形输出、条码打印,并集成自动切纸、钱箱开启、打印队列管理与优先级调整等高级功能,同时设计了用户认证授权与网络异常下的任务重提机制。目前已有37人学习下载,适合希望理解Socket打印通信原理、掌握ESC/POS指令封装思路或搭建Web打印服务的读者参考,可从中获取完整的通信流程设计、队列调度逻辑与安全控制实现要点。
1. 从一张小票到一套服务:Java Socket 直连爱普生 9100 端口到底能干什么
很多做零售、餐饮、仓储系统的同行都遇到过这种需求:Web 后台点一下「打印」,前台那台爱普生热敏小票机就得出纸,还要顺带切纸、弹钱箱。用厂商驱动吧,部署到 Linux 服务器上各种水土不服;用 CUPS 吧,切纸和钱箱指令又不好塞进去。真正稳的做法,是绕开驱动,用 Java 的 Socket 直接连打印机的 9100 端口,把 ESC/POS 指令裸发过去。这份资源就是围绕这套思路做的一个 Web 打印服务系统,把打印控制、切纸、钱箱开启和网络打印队列管理打包在一起。它适合谁?适合手里有爱普生网络小票机、后端是 Java、又不想被驱动和操作系统绑死的团队。下面我按「这东西怎么跑起来、参数怎么设、坑在哪」拆开讲。
2. 9100 端口与 ESC/POS:先搞懂数据是怎么到打印机的
2.1 为什么是 9100 端口而不是共享打印机
爱普生这类网络小票机,出厂基本都带一个 RAW 打印端口,默认就是 9100。它的本质是一个极简的 TCP 服务:你连上去,把字节流写进去,打印机就照着字节流干活,中间没有驱动、没有假脱机、没有操作系统那一层。这跟 Windows 共享打印机(那个经常报 0x0000011b、0x000000040 的玩意儿)完全是两条路。共享打印走的是 SMB 协议栈,依赖主机在线、依赖驱动匹配,跨平台一塌糊涂;而 9100 是纯 TCP,Java 一个Socket就能搞定,Linux、Windows、容器里行为一致。
代价是:9100 端口只认字节,不认「文档」。你发过去的是 PDF,它就打出一堆乱码;你发过去的是 ESC/POS 指令,它才正常出票。所以这套方案的核心不是「打印文件」,而是「拼指令」。常见做法是后端把订单数据渲染成 ESC/POS 字节数组,再通过 Socket 推给打印机。这也是为什么这份资源值得看——它把「拼指令」和「发指令」这两件事工程化了。
2.2 ESC/POS 指令的最小可用集合
ESC/POS 是爱普生的一套控制指令集,指令以ESC(0x1B)、GS(0x1D)等控制字符开头。你不需要背全,先掌握四类就够跑通业务:
| 功能 | 指令(十六进制) | 说明 |
|---|---|---|
| 初始化 | 1B 40 | 每次打印前发一次,清状态 |
| 对齐 | 1B 61 n | n=0 左、1 中、2 右 |
| 切纸 | 1D 56 m | m=0 全切、1 半切 |
| 开钱箱 | 1B 70 m t | m=0 对应钱箱口 2,t 为脉冲时间 |
| 中文 | 1C 26等 | 需配合 GBK 编码,见后文 |
这里有个反直觉的点:切纸和开钱箱不是「打印内容」,而是独立指令,可以单独发。也就是说,你完全可以只连一次 Socket,先发开钱箱指令,再发小票内容,最后发切纸指令,一气呵成。很多新手会分三次连接去发,结果钱箱弹了票没出,或者票出了钱箱没弹——问题就出在连接时序上。
2.3 用 Java 建立第一条打印连接
先写一个最小可跑的发送类,验证网络和指令通路:
import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.Socket; public class RawPrinterClient { // 打印机 IP 与 9100 端口 private static final String PRINTER_IP = "192.168.1.50"; private static final int PRINTER_PORT = 9100; public static void send(byte[] payload) throws Exception { try (Socket socket = new Socket()) { // 连接超时 3 秒,写超时 5 秒,避免线程被挂死 socket.connect(new InetSocketAddress(PRINTER_IP, PRINTER_PORT), 3000); socket.setSoTimeout(5000); OutputStream out = socket.getOutputStream(); out.write(payload); out.flush(); } } public static void main(String[] args) throws Exception { byte[] init = {0x1B, 0x40}; // 初始化 byte[] text = "测试小票\n".getBytes("GBK"); // 中文用 GBK byte[] cut = {0x1D, 0x56, 0x00}; // 全切 byte[] all = new byte[init.length + text.length + cut.length]; System.arraycopy(init, 0, all, 0, init.length); System.arraycopy(text, 0, all, init.length, text.length); System.arraycopy(cut, 0, all, init.length + text.length, cut.length); send(all); } }逻辑说明:connect带超时是必须的,否则打印机断电时线程会一直阻塞。setSoTimeout控制写阻塞的上限。指令拼接用System.arraycopy顺序拼成一个大字节数组,一次性写出,避免多次write之间被打印机缓冲打断。参数说明:PRINTER_IP换成你打印机的实际地址;中文务必用GBK,用UTF-8会打出乱码,这是血泪经验。跑通这一步,说明网络和指令通路没问题,再往上叠业务逻辑。
3. 把裸指令封装成 Web 打印服务:队列、切纸与钱箱
3.1 打印队列为什么不能省
单机测试时,你直接new Socket发就完事了。但上了生产,多个收银台、多个订单并发过来,如果每个请求都自己开 Socket,会出现两个问题:一是打印机同一时刻只处理一条连接,并发写会互相插队,打出来的票内容错乱;二是打印机短暂离线时请求直接失败,订单丢了。所以必须有一个队列层,把「要打印的任务」排好队,串行地喂给打印机。
常见做法是用BlockingQueue做内存队列,配一个常驻消费线程。任务对象里存字节数组和目标打印机标识。消费线程从队列取任务,取不到就阻塞等待,取到了就发。这样并发请求进来只是入队,不会直接碰 Socket。要注意队列要有上限,防止打印机长时间离线导致内存被任务撑爆——我一般设 1000 条,满了就拒绝并记录,而不是无限堆积。
3.2 切纸与钱箱的指令时序
切纸和开钱箱最容易翻车的地方是时序。正确顺序是:初始化 → 打印内容 → 走纸(可选)→ 切纸 → 开钱箱。钱箱指令放在切纸之后,是因为很多机型在切纸动作完成前不响应钱箱脉冲。如果你把钱箱指令放在最前面,可能票还没打完钱箱就弹了,或者干脆不弹。
public class EscPosBuilder { private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); public EscPosBuilder init() { buffer.write(0x1B); buffer.write(0x40); return this; } public EscPosBuilder text(String s) throws Exception { buffer.write(s.getBytes("GBK")); return this; } public EscPosBuilder feed(int lines) { for (int i = 0; i < lines; i++) buffer.write(0x0A); return this; } public EscPosBuilder cut() { buffer.write(0x1D); buffer.write(0x56); buffer.write(0x00); return this; } public EscPosBuilder openCashDrawer() { // m=0 钱箱口2,t=50 脉冲时间 buffer.write(0x1B); buffer.write(0x70); buffer.write(0x00); buffer.write(0x32); return this; } public byte[] build() { return buffer.toByteArray(); } }逻辑说明:用ByteArrayOutputStream做链式拼装,比手动arraycopy可读性好得多。feed走纸几行再切,能避免切到最后一行的字。参数说明:openCashDrawer里的0x32是脉冲时间 50ms,太短钱箱弹不开,太长可能损伤线圈,50 是常见值。切纸指令0x00是全切,如果打印机是半切机型,改成0x01。
3.3 队列消费线程与失败重试
消费线程的核心是「取任务、发任务、失败处理」三步。失败处理不能简单丢弃,也不能无限重试。我的习惯是:发送失败后把任务重新入队,但给任务加一个重试计数,超过 3 次就落到一个失败表里,人工介入。这样既不会因为打印机临时抖动丢单,也不会因为打印机彻底坏了把队列堵死。
public class PrintWorker implements Runnable { private final BlockingQueue<PrintTask> queue; private final RawPrinterClient client = new RawPrinterClient(); public PrintWorker(BlockingQueue<PrintTask> queue) { this.queue = queue; } @Override public void run() { while (!Thread.currentThread().isInterrupted()) { try { PrintTask task = queue.take(); // 阻塞取任务 try { client.send(task.getPayload()); } catch (Exception e) { task.increaseRetry(); if (task.getRetry() <= 3) { queue.offer(task); // 重新入队 } else { // 落失败表,记录 task.getId() } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } }逻辑说明:queue.take()在没有任务时阻塞,不消耗 CPU。重试用offer而不是put,避免队列满时再次阻塞。参数说明:重试上限 3 次是经验值,打印机重启一般几秒内完成,3 次足够覆盖。失败表建议存任务 ID 和原始字节,方便补打。
4. 避坑与排查:那些让打印服务半夜挂掉的细节
4.1 中文乱码:编码不是 UTF-8 而是 GBK
现象:小票上中文全是问号或方块。原因:ESC/POS 打印机内置字库多为 GBK,你发 UTF-8 字节它按 GBK 解析,自然乱码。解决:所有中文内容统一getBytes("GBK"),包括商品名、备注。如果确实要打生僻字,得用位图指令,那是另一个话题。
4.2 端口占用报错:每个套接字地址只允许使用一次
现象:日志里出现「通常每个套接字地址(协议/网络地址/端口)只允许使用一次」。原因:上一个 Socket 没关闭,或者你用了固定本地端口去连。解决:客户端 Socket 不要绑定本地端口,让系统自动分配;确保try-with-resources关闭连接。这个报错在 Windows 上尤其常见,Linux 上表现为连接超时。
4.3 打印机离线导致线程挂死
现象:打印机断电后,消费线程卡住,后续任务全部积压。原因:connect或write没有超时,TCP 在等对端响应。解决:connect设 3 秒超时,setSoTimeout设 5 秒,超时抛异常走重试逻辑。注意setSoTimeout对write不一定生效,必要时用独立线程加Future.get(timeout)包一层。
4.4 切纸切到一半或切不断
现象:小票最后一行被切掉,或者纸没完全断。原因:切纸前没有走纸,或者切纸指令的 m 值跟机型不匹配。解决:切纸前feed(3)走三行;确认机型支持全切还是半切,全切用0x00,半切用0x01。部分机型还需要先发GS V 66 n带进纸的切纸指令。
4.5 钱箱不弹:指令对了但没反应
现象:指令发了,钱箱纹丝不动。原因:钱箱接口选错(钱箱口 1 还是口 2),或者脉冲时间太短。解决:确认钱箱插在打印机的哪个口,1B 70 00 32对应口 2,口 1 是1B 70 01 32;脉冲时间从 50ms 往上试到 100ms。另外,有些机型在切纸动作完成前不响应钱箱,务必把钱箱指令放在切纸之后。
5. 进阶:把打印服务做成可观测、可扩展的组件
5.1 用状态探测代替盲目重试
上面那套重试逻辑有个盲区:打印机到底是「临时抖动」还是「彻底离线」,队列并不知道。我后来加了一个轻量探测:消费线程在连续失败 3 次后,不再无脑重试,而是先尝试连一次 9100 端口,连不上就进入「离线等待」状态,每 10 秒探一次,恢复后再继续消费队列。这样避免了打印机拔线后队列空转重试把日志刷爆。
private boolean isPrinterOnline() { try (Socket s = new Socket()) { s.connect(new InetSocketAddress(PRINTER_IP, PRINTER_PORT), 2000); return true; } catch (Exception e) { return false; } }逻辑说明:只做连接探测,不发数据,对打印机无副作用。参数说明:探测超时 2 秒,比业务发送的 3 秒短,避免探测本身拖慢恢复判断。
5.2 多打印机路由与队列隔离
当系统里有前台小票机、后厨打印机、标签机多台设备时,不能共用一个队列。做法是按打印机标识建多个队列,每个队列配一个消费线程,互不干扰。任务入队时根据业务类型路由到对应队列。这样后厨打印机坏了,不会影响前台出票。路由表可以放配置文件,改打印机 IP 不用改代码。
| 队列标识 | 打印机角色 | 典型指令差异 |
|---|---|---|
| front | 前台小票机 | 含切纸、钱箱 |
| kitchen | 后厨打印机 | 只打印,不切纸 |
| label | 标签机 | 用 TSPL 而非 ESC/POS |
5.3 验证方法:别等上线才发现打不出来
我现在的习惯是,任何打印相关改动,上线前必须走一遍「三连测」:第一,用main方法直接发一条测试指令,确认硬件通路;第二,灌 50 条并发任务进队列,看是否串行、是否错乱;第三,拔掉打印机网线,发 10 条任务,再插回网线,看是否自动恢复并补打。这三步能覆盖 90% 的翻车场景。从那以后我每次改打印逻辑,都强制走一遍这个流程,再也没在半夜被叫起来过。希望帮到你。
本文还有配套的精品资源,点击获取