ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java Socket 直连爱普生 9100 端口:ESC/POS 小票打印与队列管理实战

Java Socket 直连爱普生 9100 端口:ESC/POS 小票打印与队列管理实战 简介这是一套面向Java后端开发者与商业打印场景的Web打印服务系统源码核心解决爱普生打印机9100端口的ESC/POS指令通信问题。系统通过Java Socket与打印机建立稳定连接支持文本、图形、条形码打印并集成自动切纸控制、钱箱开启、网络打印队列管理及用户认证授权等高级特性适合零售收银、餐饮后厨、仓储物流等需要网络化打印调度的场景。资源包共4个文件包含1个Java源码、1个Markdown说明、1个txt文档和1个docx附赠资料整体约37KB结构精简便于快速理解Socket通信与指令集封装思路。目前已有37人学习下载。读者可从中获取ESC/POS指令控制打印头、打印密度与条码绘制的实现参考以及打印队列排队调度、异常重提交与权限校验的设计思路适合作为网络打印服务二次开发或课程设计的实践素材。1. 从一张小票说起Java Socket 直连爱普生 9100 端口到底解决了什么很多人第一次接触小票打印是接手一个门店收银系统前台点完单后台要出小票、要切纸、要弹钱箱还得支持多台打印机排队。用厂商驱动吧Windows 上装一堆 DLLLinux 服务器上根本跑不起来用云打印吧网络一断小票就卡住老板站在收银台前盯着你。这时候 Java Socket 直连爱普生 9100 端口 ESC/POS 指令这套方案就显出价值了——它不依赖任何驱动只要打印机和服务器在同一网段一个Socket就能把格式化好的字节流怼过去切纸、开钱箱、查状态全在指令里。这套方案适合谁做餐饮、零售、仓储面单的 Java 后端尤其是部署在 Linux 服务器上、打印机是爱普生 TM 系列TM-T82、TM-T88、TM-m30 等的团队。核心就三件事把 ESC/POS 指令拼成字节数组、通过 9100 端口发出去、用一个队列把并发打印请求管起来。下面按「协议怎么立住 → 最小可跑通 → 队列与切纸钱箱 → 避坑 → 进阶验证」的顺序拆开讲每一步都给能直接抄的代码和参数。2. ESC/POS 与 9100 端口先把协议这层黑匣子拆开2.1 为什么是 9100 而不是 USB 或共享打印爱普生网络打印机的 9100 端口是 RAW 打印端口走的是 TCP 直连不经过任何打印协议协商。你把字节流写进这个 Socket打印机就当它是 ESC/POS 指令直接执行。对比几种常见接法接法依赖跨平台适合场景USB 厂商驱动驱动 DLL差单机 Windows 收银Windows 共享打印SMB 驱动差局域网小规模9100 RAW Socket无好Linux 服务端、多机并发IPP/CUPSCUPS 服务中需要统一队列管理9100 的最大好处是「无状态」每次打印就是一次 TCP 短连接发完就关服务端不需要维护打印机句柄。坏处也在这——没有回执确认你得自己处理「发出去了但打印机没纸」这种情况后面避坑章节会讲。2.2 ESC/POS 指令的字节结构ESC/POS 指令本质是「控制字符 参数」的字节序列。最常用的几条初始化ESC →0x1B 0x40对齐ESC a n→0x1B 0x61 nn0 左、1 中、2 右加粗ESC E n→0x1B 0x45 n切纸GS V m→0x1D 0x56 mm0 全切、1 半切开钱箱ESC p m t1 t2→0x1B 0x70 0x00 0x19 0xFA中文打印要注意编码。爱普生国行机型一般用 GBK日版/国际版可能用 GB18030 或 UTF-8这个必须实测。下面这段代码把常用指令封装成一个工具类public class EscPosBuilder { private final ByteArrayOutputStream buf new ByteArrayOutputStream(); // 初始化打印机清空缓冲区 public EscPosBuilder init() { buf.write(new byte[]{0x1B, 0x40}, 0, 2); return this; } // 对齐0左 1中 2右 public EscPosBuilder align(int n) { buf.write(new byte[]{0x1B, 0x61, (byte) n}, 0, 3); return this; } // 打印文本默认 GBK 编码 public EscPosBuilder text(String s) { try { buf.write(s.getBytes(GBK)); } catch (IOException e) { throw new RuntimeException(编码失败, e); } return this; } // 换行 public EscPosBuilder newline() { buf.write(0x0A); return this; } // 切纸0全切 1半切 public EscPosBuilder cut(int mode) { buf.write(new byte[]{0x1D, 0x56, (byte) mode}, 0, 3); return this; } // 开钱箱 public EscPosBuilder openCashBox() { buf.write(new byte[]{0x1B, 0x70, 0x00, 0x19, (byte) 0xFA}, 0, 5); return this; } public byte[] build() { return buf.toByteArray(); } }逻辑说明ByteArrayOutputStream做字节累积避免频繁拼接数组。每个方法返回this支持链式调用。参数说明align的 n 只能是 0/1/2传其他值打印机行为未定义cut的 mode 在部分机型上 0 是全切、1 是半切但 TM-T82 某些固件版本反过来必须实测openCashBox的0x19 0xFA是脉冲时间参数钱箱不弹就调这两个值。2.3 9100 端口发送的最小实现拿到字节数组后通过 Socket 发出去public class RawPrintClient { public static void send(String host, int port, byte[] data, int timeoutMs) throws IOException { try (Socket socket new Socket()) { socket.connect(new InetSocketAddress(host, port), timeoutMs); socket.setSoTimeout(timeoutMs); OutputStream out socket.getOutputStream(); out.write(data); out.flush(); // 不读回执直接关闭部分机型需要 sleep 后再关 Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }逻辑说明用 try-with-resources 保证 Socket 关闭。connect超时和setSoTimeout分开设前者管建连、后者管读写。参数说明timeoutMs建议 3000太小在网络抖动时误判失败太大阻塞队列。Thread.sleep(200)是血泪经验——某些爱普生固件在 TCP 关闭太快时会丢最后一段缓冲加 200ms 再关能显著降低「小票打一半」的概率。3. 从零跑通第一张带切纸和钱箱的小票3.1 环境准备与依赖不需要任何第三方打印库JDK 8 以上即可。如果你用 Maven只需要一个日志依赖方便排查dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version1.7.36/version /dependency打印机侧要确认三件事打印机已联网且 IP 固定建议在路由器里做 DHCP 静态绑定、9100 端口开放、纸张和碳带正常。用telnet 192.168.1.100 9100能连上就说明端口通。3.2 拼一张完整小票把工具类串起来打一张带标题、明细、切纸、开钱箱的小票public class ReceiptDemo { public static void main(String[] args) throws Exception { byte[] payload new EscPosBuilder() .init() .align(1) .text(测试门店).newline() .text(------------------------).newline() .align(0) .text(可乐 x2 6.00).newline() .text(薯条 x1 8.00).newline() .text(------------------------).newline() .align(2) .text(合计: 14.00).newline() .newline().newline() .cut(0) // 全切 .openCashBox() // 弹钱箱 .build(); RawPrintClient.send(192.168.1.100, 9100, payload, 3000); System.out.println(发送完成字节数 payload.length); } }逻辑说明链式调用按「初始化 → 对齐 → 文本 → 切纸 → 开钱箱」顺序拼装顺序不能乱init必须第一个cut和openCashBox放最后。参数说明cut(0)全切适合取走小票的场景半切cut(1)适合留着票根openCashBox放在cut之后是因为部分机型切纸时会短暂忙先切后弹更稳。运行后如果小票出来但没切先确认机型是否支持自动切纸TM-T82 部分型号是手动撕纸。3.3 中文乱码的三种排查路径中文打出来是问号或方块按这个顺序查第一确认text()里用的编码和打印机一致国行爱普生默认 GBK改成GB18030试试第二确认打印机没设成「字符集 A」以外的模式用ESC t n切字符页第三确认字节没在传输中被截断打印payload.length和实际发送字节数对比。常见做法是先用英文跑通再加中文把编码问题隔离出来。4. 网络打印队列管理并发下怎么不丢单不串单4.1 为什么必须上队列门店高峰期可能同时有前台、后厨、外卖三个入口往同一台打印机发数据。如果每个请求都直接开 Socket会出现两个问题一是打印机 TCP 连接数有限并发高了直接拒绝二是两个请求的字节流可能交错打出来的小票内容串在一起。队列的作用就是串行化——同一台打印机的任务排队执行一台打印机一个队列。4.2 用 BlockingQueue 实现单机队列public class PrinterQueue { private final BlockingQueuePrintTask queue new LinkedBlockingQueue(500); private final String host; private final int port; private volatile boolean running true; public PrinterQueue(String host, int port) { this.host host; this.port port; startWorker(); } public boolean submit(PrintTask task) { return queue.offer(task); // 队列满返回 false由调用方决定重试或降级 } private void startWorker() { Thread worker new Thread(() - { while (running) { try { PrintTask task queue.take(); // 阻塞等待 RawPrintClient.send(host, port, task.getData(), 3000); task.markSuccess(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (Exception e) { // 单次失败不退出循环记录后继续 System.err.println(打印失败: e.getMessage()); } } }, printer-worker- host); worker.setDaemon(true); worker.start(); } public void shutdown() { running false; } }逻辑说明LinkedBlockingQueue容量设 500防止内存无限增长。单个 worker 线程串行消费保证同一台打印机不会并发写。take()阻塞等待队列空时线程挂起不占 CPU。参数说明容量 500 是经验值按门店峰值 QPS 和单张打印耗时估算一般 2001000 之间offer而非put队列满时立即返回 false避免调用线程被阻塞。失败任务只记录不重试因为打印重试可能导致重复出票重试策略要放到业务层判断。4.3 多打印机路由与任务优先级多台打印机时用MapString, PrinterQueue按打印机 ID 路由。优先级用两个队列实现普通队列和加急队列worker 每次先poll加急队列空了再take普通队列。加急队列用于「重打」「补打」这类场景。注意加急队列也要设容量上限否则加急任务堆积一样会拖垮打印机。4.4 队列监控与失败告警队列长度、失败次数、最后成功时间这三个指标必须暴露出来。简单做法是 worker 每次循环后更新AtomicLong再通过一个 HTTP 接口或日志定时输出。队列长度持续增长说明打印机跟不上或卡纸了失败次数突增说明网络或打印机离线。常见做法是队列长度超过容量的 80% 就告警让运维去现场看打印机。5. 避坑与排查那些让你半夜爬起来重启服务的坑5.1 现象小票打一半就断了后半段丢失原因TCP 关闭太快打印机缓冲区还没处理完就收到 FIN。解决发送后Thread.sleep(200)再关闭或者用socket.shutdownOutput()半关闭等打印机处理完再关。部分机型需要读到 EOF 才确认处理完但 9100 端口一般不回数据所以 sleep 是最实用的办法。5.2 现象并发打印时小票内容串在一起原因多个线程同时往同一台打印机写 Socket。解决必须用队列串行化一台打印机一个 worker 线程。不要图省事用synchronized包住发送逻辑那样只是把并发变成了阻塞队列还能做削峰和监控。5.3 现象切纸指令无效小票不切原因三种可能——机型不支持自动切纸、切纸模式参数不对、切纸指令前没有换行到切纸位置。解决先确认机型规格再试cut(0)和cut(1)互换最后在cut前加 34 个newline()让纸张走到切刀位置。爱普生部分机型要求切纸前至少走纸 3 行。5.4 现象钱箱不弹或一直弹原因脉冲参数0x19 0xFA不匹配钱箱规格或者指令被切纸指令干扰。解决调整脉冲时间常见组合是0x00 0x19 0xFA、0x00 0x3C 0xFA逐个试。如果一直弹说明脉冲太长或钱箱驱动电路问题换短脉冲。注意开钱箱指令要放在切纸之后避免切纸时的电流波动影响。5.5 现象Linux 服务器上报「通常每个套接字地址只允许使用一次」原因频繁短连接导致 TIME_WAIT 状态堆积端口耗尽。解决设置socket.setReuseAddress(true)或者改用长连接 心跳保活。短连接场景下还可以调小net.ipv4.tcp_tw_reuse但生产环境慎改内核参数优先在应用层做连接复用。6. 进阶用状态查询和回读确认打印真的成功了6.1 为什么需要回读9100 端口默认是单向的但爱普生支持通过GS r n查询打印机状态返回一个字节。常用查询GS r 1查打印机状态GS r 2查离线状态GS r 4查错误状态。发完查询指令后读一个字节就能知道打印机是否缺纸、是否开盖、是否出错。public class StatusChecker { public static int queryStatus(String host, int port) throws IOException { try (Socket socket new Socket()) { socket.connect(new InetSocketAddress(host, port), 3000); socket.setSoTimeout(3000); OutputStream out socket.getOutputStream(); InputStream in socket.getInputStream(); // GS r 1 out.write(new byte[]{0x1D, 0x72, 0x01}); out.flush(); int status in.read(); // 阻塞读一个字节 return status; } } }逻辑说明发查询指令后读一个字节返回值按位解析。参数说明bit 0 是切纸器错误bit 3 是离线bit 5 是缺纸bit 6 是错误。返回 -1 表示连接被关闭或超时。注意不是所有爱普生机型都支持状态回读TM-T82 支持部分老机型不支持实测为准。6.2 把状态查询接进队列在 worker 每次打印前先查一次状态状态异常就跳过本次打印并告警避免往坏打印机里灌数据。打印后再查一次确认没有出错。这样队列里就不会堆积注定失败的任务。6.3 一个我踩过的坑我最早做这套系统时图省事没加状态查询结果打印机缺纸后队列里堆了 300 多单等换好纸全部打出来小票日期全是错的。后来加了状态预检和队列长度告警再没出过这种事。这套方案值不值得做如果你有 Linux 服务端 爱普生网络打印机的组合它比装驱动、比云打印都省心代码量不到 500 行维护成本极低。但一定要把状态查询和队列监控做进去否则就是给自己埋雷。希望帮到你。本文还有配套的精品资源点击获取
返回列表