简介:这份资源是面向网络协议学习者与打印服务开发者的 IPP(Internet Printing Protocol)协议源码包,聚焦跨平台远程打印的标准化实现,适合希望理解 HTTP/1.1 之上打印作业提交、状态查询与作业控制流程的开发者研读。压缩包共 32 个文件,以 8 个 c 源文件与 5 个 h 头文件为主体,另有 svn-base 版本文件、Makefile、readme 及 format、entries 等辅助文件,整体约 35KB,体量轻便。源码围绕报文解析、状态机、数据编码、错误处理与 HTTPS 安全等环节展开,可对照理解请求响应结构、打印机属性查询与扩展机制。已有 3874 人学习下载,适合作为网络编程与自定义打印解决方案的入门实例。
1. IPP 协议:为什么你的打印代码总在“最后一公里”翻车
很多人第一次接触网络打印,都是从一行socket.send()开始的:把 RAW 数据往 9100 端口一丢,打印机就出纸了,看起来简单得不像话。可一旦换到共享打印机、跨网段、需要查询墨量、需要双面打印、需要指定纸盒,9100 那套“裸奔”方案立刻原形毕露——要么没反应,要么打出一堆乱码,要么状态永远查不到。IPP(Internet Printing Protocol)就是为解决这些“最后一公里”问题而生的:它把打印任务抽象成对象,用 HTTP 承载请求,用 XML 描述属性,让“提交任务、查询状态、取消任务、获取能力”变成一套可编程的接口。这份资源围绕 IPP 协议展开,适合需要把打印能力集成进业务系统的后端开发者、做自助终端的外设工程师,以及想搞懂“打印机到底在说什么”的运维人员。它不教你点鼠标装驱动,而是把协议报文拆开给你看。
2. IPP 报文结构:从二进制头到属性组的逐字节拆解
IPP 的报文本质上是“HTTP 头 + 二进制体”。HTTP 部分负责寻址和传输,真正干活的是 body 里那段有严格字节序的结构。理解这段结构,是后面所有代码的前提,否则你连一个合法的打印请求都拼不出来。
2.1 请求体的三段式布局
一个 IPP 请求体由三部分组成:版本号与操作码、请求 ID、属性组序列。前 8 个字节是固定头,之后是可变长的属性组,最后才是文档数据(仅打印任务有)。固定头的布局如下:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 1 | major version | 主版本号,常见 0x02 |
| 1 | 1 | minor version | 次版本号,常见 0x00 |
| 2 | 2 | operation-id | 操作码,如 0x0002 表示 Print-Job |
| 4 | 4 | request-id | 请求标识,响应会原样带回 |
| 8 | … | attribute groups | 属性组序列 |
操作码决定了这次请求要干什么,常用的几个:0x0002Print-Job、0x0003Print-URI、0x0004Validate-Job、0x0008Get-Job-Attributes、0x0009Get-Jobs、0x000BCancel-Job、0x000CGet-Printer-Attributes。记不住没关系,写代码时用常量表映射即可,但一定要知道每个操作码对应的属性组要求不同——比如 Print-Job 必须带 operation attributes 和 job attributes,而 Get-Printer-Attributes 只需要 operation attributes。
属性组本身是“组标签 + 若干属性”的重复结构。组标签用单字节表示,0x01是 operation attributes,0x02是 job attributes,0x03是 end-of-attributes(结束标记),0x04是 printer attributes(仅响应中出现),0x05是 unsupported attributes。每个属性又是“值标签 + 名称长度 + 名称 + 值长度 + 值”的嵌套结构,值标签决定后面怎么解析——0x42是 nameWithoutLanguage,0x44是 keyword,0x21是 integer,0x41是 textWithoutLanguage。这套编码看着繁琐,但胜在自描述,解析器可以逐字节推进,不需要预先知道字段顺序。
2.2 用 Python 拼一个最小 Print-Job 请求
下面这段代码不依赖任何 IPP 库,纯手工拼字节,目的是让你看清每个字段落在哪。实际项目里我一般会封装成 builder,但第一次接触时手拼一遍,后面调 bug 会快很多。
import struct import http.client def encode_attr(tag, name, value_bytes): """编码单个属性:值标签 + 名称长度 + 名称 + 值长度 + 值""" name_b = name.encode("utf-8") return (struct.pack(">B", tag) + struct.pack(">H", len(name_b)) + name_b + struct.pack(">H", len(value_bytes)) + value_bytes) def build_print_job(printer_uri, request_id=1): # 固定头:版本 2.0,操作码 0x0002 (Print-Job),请求 ID body = struct.pack(">BBHI", 0x02, 0x00, 0x0002, request_id) # operation attributes 组 body += struct.pack(">B", 0x01) body += encode_attr(0x47, "attributes-charset", b"utf-8") # charset body += encode_attr(0x48, "attributes-natural-language", b"en") # natural language body += encode_attr(0x45, "printer-uri", printer_uri.encode()) # URI body += encode_attr(0x42, "requesting-user-name", b"tester") # 用户名 # job attributes 组 body += struct.pack(">B", 0x02) body += encode_attr(0x42, "job-name", b"demo-job") body += encode_attr(0x44, "document-format", b"application/octet-stream") # 结束标记 body += struct.pack(">B", 0x03) return body def send_print_job(host, port, printer_uri, document_bytes): body = build_print_job(printer_uri) + document_bytes conn = http.client.HTTPConnection(host, port, timeout=10) headers = { "Content-Type": "application/ipp", "Content-Length": str(len(body)), } conn.request("POST", "/printers/demo", body=body, headers=headers) resp = conn.getresponse() return resp.status, resp.read()这段代码有几个关键点值得说清楚。encode_attr里的值标签不是随便选的:0x47对应 charset,0x48对应 natural-language,0x45对应 uri,0x42对应 nameWithoutLanguage,0x44对应 keyword。标签选错,打印机可能直接返回client-error-bad-request,而且错误信息往往很含糊,这是新手最容易卡住的地方。printer-uri必须和 HTTP 请求路径里的打印机对象一致,很多实现会校验两者是否匹配,不一致就拒绝。document-format写application/octet-stream表示让打印机自己猜,如果明确知道是 PDF,写application/pdf更稳妥,部分设备对格式声明很敏感。
request-id在单次请求里随便给,但如果你要做并发或重试,建议用递增计数器,响应里会带回同一个 ID,方便对账。HTTP 方法必须是 POST,路径是打印机对象的 URI 路径部分,Content-Type 固定为application/ipp。返回的响应体同样是 IPP 结构,前 8 字节里第 2、3 字节是状态码,0x0000表示成功,其余值需要查状态码表。
2.3 响应解析:别只看 HTTP 200
IPP 的一个坑是:HTTP 层返回 200 不代表打印成功。真正的结果在响应体的状态码里。响应体结构是“固定头 + 属性组”,固定头的 operation-id 位置变成了 status-code。解析时先读 8 字节,取struct.unpack(">BBHI", head)的第 3 个字段(索引 2)作为状态码,然后逐组读属性,直到遇到0x03结束标记。状态码0x0400到0x04FF是客户端错误,0x0500到0x05FF是服务端错误,0x0406是“文档格式不支持”,0x040A是“属性值不支持”,这两个在调格式和参数时最常撞上。把状态码映射成可读文本,排错效率会高一个量级。
3. 打印机能力协商:Get-Printer-Attributes 怎么问、怎么读
硬编码打印参数是另一个高频翻车点。你在 A 打印机上写死“双面、A4、彩色”,换到 B 打印机上可能直接报属性不支持。正确做法是先问打印机“你会什么”,再决定传什么。这就是 Get-Printer-Attributes 的用途,也是 IPP 相比 9100 裸流最实用的地方。
3.1 请求构造与 requested-attributes 的取舍
Get-Printer-Attributes 的操作码是0x000C,请求体只需要 operation attributes 组,核心属性是printer-uri和可选的requested-attributes。requested-attributes是一个 keyword 类型的多值属性,用来告诉打印机“我只关心这几项”,不传则返回全部(可能几百项,解析很累)。常见做法是传一个精简列表,比如printer-name、printer-state、printer-state-reasons、document-format-supported、sides-supported、media-supported、color-supported、printer-make-and-model。多值属性的编码方式是:同一个属性名重复出现多次,每次带一个值,解析时按名称聚合。
def build_get_attrs(printer_uri, attrs, request_id=2): body = struct.pack(">BBHI", 0x02, 0x00, 0x000C, request_id) body += struct.pack(">B", 0x01) body += encode_attr(0x47, "attributes-charset", b"utf-8") body += encode_attr(0x48, "attributes-natural-language", b"en") body += encode_attr(0x45, "printer-uri", printer_uri.encode()) for a in attrs: # 多值:同名属性重复编码 body += encode_attr(0x44, "requested-attributes", a.encode()) body += struct.pack(">B", 0x03) return body这里requested-attributes用的是0x44(keyword),不是0x42。虽然不少设备对标签宽容,但严格实现会校验,用错标签可能拿到空结果。另一个细节是属性名大小写:IPP 属性名统一小写加连字符,写成Printer-URI这种驼峰,部分实现会静默忽略,表现为“请求成功但没数据”,排查起来很费劲。
3.2 解析多值属性与嵌套集合
响应里的 printer attributes 组标签是0x04,属性值可能是单值,也可能是 1setOf 多值,甚至嵌套集合(比如media-col-database里套着一堆子属性)。解析器要能处理“同一属性名连续出现”的情况,把它们收进列表。下面是一个够用的解析骨架:
def parse_ipp_response(data): major, minor, status, req_id = struct.unpack(">BBHI", data[:8]) pos, attrs, cur_group = 8, {}, None while pos < len(data): tag = data[pos]; pos += 1 if tag == 0x03: # end-of-attributes break if tag in (0x01, 0x02, 0x04, 0x05): # 组标签 cur_group = tag continue # 属性:值标签 + 名称长度 + 名称 + 值长度 + 值 name_len = struct.unpack(">H", data[pos:pos+2])[0]; pos += 2 name = data[pos:pos+name_len].decode("utf-8", "replace"); pos += name_len val_len = struct.unpack(">H", data[pos:pos+2])[0]; pos += 2 raw = data[pos:pos+val_len]; pos += val_len value = decode_value(tag, raw) attrs.setdefault(name, []).append(value) return status, attrs def decode_value(tag, raw): if tag in (0x21, 0x23): # integer / enum return struct.unpack(">i", raw)[0] if tag == 0x22: # boolean return raw[0] != 0 if tag in (0x41, 0x42, 0x44, 0x45, 0x47, 0x48): return raw.decode("utf-8", "replace") return raw # 其余类型先保留原始字节decode_value只覆盖了常见类型,遇到0x34(rangeOfInteger)、0x49(dateTime)这类需要额外处理。实际项目里我一般会把未知类型原样保留并打日志,而不是抛异常——打印机厂商的扩展属性五花八门,硬解析容易把整个流程搞崩。拿到sides-supported后,再决定 Print-Job 里传one-sided还是two-sided-long-edge;拿到media-supported后,再映射成iso_a4_210x297mm这种标准值。这套“先问后传”的流程,是把打印集成做稳的关键。
4. 任务生命周期管理:提交、轮询、取消的完整闭环
打印不是一锤子买卖。提交之后任务可能排队、可能缺纸、可能被取消,业务系统需要知道最终结果。IPP 的 job 对象提供了完整的状态机,配合 Get-Job-Attributes 和 Cancel-Job 就能做出可观测的打印流程。
4.1 job-uri 与 job-id 的获取
Print-Job 成功时,响应里会带job-uri和job-id两个属性。job-uri是后续查询和取消的寻址依据,job-id是打印机内部的整数编号。注意job-uri的路径部分通常形如/jobs/123,但不同厂商可能带前缀,不要自己拼,直接用响应里给的值。拿到之后,轮询用 Get-Job-Attributes(操作码0x0008),请求里传job-uri和requested-attributes(常用job-state、job-state-reasons、job-impressions-completed)。job-state是枚举:3 表示 pending,4 表示 pending-held,5 表示 processing,6 表示 processing-stopped,7 表示 canceled,8 表示 aborted,9 表示 completed。业务上一般把 7、8、9 视为终态。
4.2 轮询节奏与取消的边界
轮询间隔别设太密,1 到 2 秒一次足够,打印机状态更新本身有延迟,问太快只是浪费请求。更稳的做法是“指数退避 + 上限”,前几次 500ms,之后逐步拉长到 3 秒,总超时按文档大小估。取消任务用 Cancel-Job(操作码0x000B),传job-uri和requesting-user-name。这里有个血泪经验:取消只对 pending 和 processing 早期有效,一旦任务进入“正在出纸”阶段,取消请求可能返回成功但纸照样出来,别指望它能拦住已经进定影器的页。所以业务侧要有“取消后仍可能产生输出”的心理预期,计费或审计逻辑不能假设取消等于零输出。
def poll_job(host, port, job_uri, max_wait=120): import time deadline = time.time() + max_wait delay = 0.5 while time.time() < deadline: body = build_get_job_attrs(job_uri) # 结构同 Get-Printer-Attributes,操作码 0x0008 conn = http.client.HTTPConnection(host, port, timeout=10) conn.request("POST", "/", body=body, headers={"Content-Type": "application/ipp"}) status, attrs = parse_ipp_response(conn.getresponse().read()) state = attrs.get("job-state", [None])[0] if state in (7, 8, 9): return state, attrs time.sleep(delay) delay = min(delay * 1.5, 3.0) return None, {} # 超时build_get_job_attrs和前面的构造逻辑一致,只是操作码换成0x0008、属性换成job-uri。轮询超时后不要直接判失败,先再查一次打印机整体状态(printer-state-reasons),有时候是设备离线而不是任务丢了。这套闭环看起来朴素,但比“提交完就不管”可靠得多,尤其是自助打印场景,用户站在机器前等结果,状态查询就是体验本身。
5. 避坑与排查:五条来自真实调试现场的记录
5.1 请求返回 200 但打印机没反应
现象:HTTP 状态 200,响应体状态码却是0x0406或0x040A,纸一张没出。原因:只看了 HTTP 层,没解析 IPP 状态码;或者document-format声明与实际数据不符。解决:把响应体状态码映射成文本打日志,格式不确定时先用application/octet-stream,确认设备支持 PDF 后再改精确值。
5.2 属性明明传了却被忽略
现象:传了sides=two-sided-long-edge,打出来还是单面,且无报错。原因:属性名拼写或值不在sides-supported列表里,部分设备对不支持的值静默降级。解决:先 Get-Printer-Attributes 拿支持列表,做值映射;属性名严格小写连字符,别用驼峰。
5.3 中文 job-name 变乱码或请求被拒
现象:任务名带中文,打印机显示问号,或直接返回 bad-request。原因:attributes-charset声明与实际编码不一致,或值标签用了不支持语言的类型。解决:统一 UTF-8,attributes-charset写utf-8,文本类属性用0x42/0x41,避免混用0x35(textWithLanguage)这类需要额外语言字段的标签。
5.4 大文件提交超时或连接被重置
现象:小文件正常,几十 MB 的 PDF 提交到一半断开。原因:一次性把整个文档读进内存再发,或 HTTP 超时设太短。解决:用流式发送(分块写 body),超时按文件大小放宽;同时确认打印机侧有足够存储,部分低端设备对单任务大小有限制,超限会直接断连。
5.5 跨网段能 ping 通却提交失败
现象:网络可达,端口也通,但 Print-Job 返回服务端错误。原因:printer-uri里的主机名与 HTTP Host 不一致,或走了中间层导致路径被改写。解决:printer-uri用实际可达的地址,HTTP 请求路径与 URI 路径保持一致;经过中间层时确认它没有剥离Content-Type: application/ipp。
6. 进阶技巧:把 IPP 封装成可复用的打印客户端
协议细节摸清之后,真正决定项目质量的是封装方式。我一般会把 IPP 客户端拆成三层:编解码层只负责字节与属性字典互转,传输层只管 HTTP 连接与重试,业务层负责“提交—轮询—回调”的流程编排。这样换打印机、换网络环境时,改动被限制在传输层,编解码和业务逻辑不动。验证封装是否靠谱,有个简单办法:拿同一份 PDF,分别对两台不同厂商的设备跑一遍“查能力—提交—轮询到终态”,对比属性解析结果和状态流转是否一致,不一致的地方就是厂商差异,记进适配表。
| 封装层 | 职责 | 常见改动点 |
|---|---|---|
| 编解码层 | 属性字典与字节流互转 | 新增值标签类型、扩展属性 |
| 传输层 | HTTP 连接、超时、重试 | 超时阈值、连接复用 |
| 业务层 | 提交流程、状态轮询、回调 | 轮询策略、终态判定 |
还有一个容易被忽略的点:request-id在重试时不要复用。有些实现会用 request-id 做幂等判断,复用可能导致第二次请求被当成重复而丢弃。我习惯每次请求自增,重试也换新 ID,靠业务层去重而不是靠协议层。另外,调试阶段建议把原始请求和响应字节 dump 到文件,用十六进制查看器对着结构表逐段核对,比盯着日志猜快得多。这套资源里的协议说明和报文示例,配合上面的代码骨架,足够你把一个能跑通、能排错、能扩展的 IPP 客户端搭起来。从那以后我每次接新的打印设备,都强制先跑一遍能力查询和最小任务闭环,确认状态码和属性解析都对得上,再往上叠业务逻辑——这个习惯帮我省掉了大量“看起来成功其实没打”的返工。希望帮到你。
本文还有配套的精品资源,点击获取