项目地址:https://github.com/w414034207/print-netty
给客户开发一个web管理系统时,客户要求能够在浏览器点击打印,直接使用客户端的本地打印机打印服务端的文件,打印配置可以在网页进行设置。
客户要求静默打印,用户不需要看打印预览页面,可以打印服务端动态生成的PDF文件,可以在网页上选择打印份数,默认双面打印。
网上看了各种提供打印的方式,使用js的一些打印控件都不符合要求,PDF文件中的电子签名解析不出来。
还试用了两种收费的打印控件,都是需要在客户端安装启动一个可执行文件。
下图为当时试过的打印方式
其中一个收费的控件能一定程度满足我们的需求,直接传URL就能打印对应的PDF文件。且不会丢失PDF文件中的电子签名。(即上图中的第一个button对应的,简写是jcp,可以静默打印,也可以传各种打印参数,缺点是:1. 打印方法的回调不能确定是否打印成功;2. 如果url没有返回pdf,回调不会被调用;3. 收费)
这时我就想,如果考虑客户端安装控件的方式,好像自己写一个控件问题也不大:
现成的http服务,我直接就写了一个,验证上面思路的可行性,实验之后确实可以在浏览器端打印PDF文件;但是问题就是太大了,由于我的功能很简单,而使用了Spring Boot之后,运行时内存都200M以上,这对用户的机器性能就有可能造成较大的影响;
由于当时刚好在学习Java NIO的相关知识,既然Spring Boot现成的服务太大了,我或许可以自己实现http协议的编解码,使用Java NIO实现http服务,使服务更轻量,然后我就舍弃了Spring Boot,改用Java NIO实现(这个代码,在github上是Private的,如果有感兴趣的盆友可以发消息找我要);
NIO版本的已经确认可以在FireFox、Chrome浏览器稳定使用,但是在使用IE进行测试时,经常会出现请求消息读取不完整的情况,我自己实现的http协议看来还需要优化。考虑到自己写的http协议还是太简陋,优化的空间很大,成本不好说。所以我决定还是使用netty提供的现成的http支持,用netty实现http服务,以兼顾轻量和稳定。
@ChannelHandler.Sharable
public class HttpHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest msg) throws Exception {
DefaultFullHttpResponse response = executeRequest(msg);
HttpHeaders heads = response.headers();
// 返回内容的MIME类型
heads.add(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.TEXT_PLAIN + "; charset=UTF-8");
// 响应体的长度
heads.add(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
// 允许跨域访问
heads.add(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
// 响应给客户端
ctx.write(response);
}
private DefaultFullHttpResponse executeRequest(FullHttpRequest msg) throws UnsupportedEncodingException {
// 200
HttpResponseStatus responseStatus = HttpResponseStatus.OK;
String returnMsg = "";
try {
// 自己实现的保存http请求信息的类
HttpRequest httpRequest = new HttpRequest(msg);
Class<PrintController> printControllerClass = PrintController.class;
Method invokeMethod = printControllerClass.getMethod(httpRequest.getMethod(), HttpRequest.class);
PrintController printController = PrintController.getInstance();
// 使用反射,调用对应的方法,获取返回值
returnMsg = (String) invokeMethod.invoke(printController, httpRequest);
} catch (NoSuchMethodException e) {
// 404
responseStatus = HttpResponseStatus.NOT_FOUND;
} catch (IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
// 500
responseStatus = HttpResponseStatus.INTERNAL_SERVER_ERROR;
}
return new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, responseStatus,
Unpooled.wrappedBuffer(returnMsg.getBytes()));
}
}
public class HttpServer {
private final static int PORT = PrintConstants.SERVER_PORT;
public static void start() {
final HttpHandler httpHandler = new HttpHandler();
// 创建EventLoopGroup
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
//指定所使用的NIO传输Channel
.channel(NioServerSocketChannel.class)
//使用指定的端口设置套接字地址
.localAddress(new InetSocketAddress(PORT))
// 添加Handler到Channle的ChannelPipeline
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) {
// 获取管道
socketChannel.pipeline()
// 解码+编码
.addLast(new HttpServerCodec())
/* aggregator,消息聚合器,处理POST请求的消息 */
.addLast(new HttpObjectAggregator(1024 * 1024))
// HttpHandler被标注为@shareable,所以我们可以总是使用同样的案例
.addLast(httpHandler);
}
});
try {
// 异步地绑定服务器;调用sync方法阻塞等待直到绑定完成
ChannelFuture f = b.bind().sync();
// 获取Channel的CloseFuture,并且阻塞当前线程直到它完成
f.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 关闭EventLoopGroup,释放所有的资源
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
PrintService[] printServices = PrinterJob.lookupPrintServices();
PrintService defaultService = PrintServiceLookup.lookupDefaultPrintService();
private LinkedBlockingQueue<byte[]> downloadAllDocument(List<String> urlList, LinkedBlockingQueue<byte[]> queue) throws InterruptedException, IOException {
ExecutorService executorService = Executors.newCachedThreadPool(r -> new Thread(r, "t_iop_download_" + r.hashCode()));
CountDownLatch count = new CountDownLatch(urlList.size());
AtomicBoolean downFlag = new AtomicBoolean(true);
// 多线程下载文件,都下载成功之后再统一打印。
for (String url : urlList) {
if (StringUtils.isEmpty(url)) {
count.countDown();
continue;
}
executorService.submit(() -> {
try {
queue.add(download(url));
} catch (Exception e) {
// 下载失败
downFlag.set(false);
} finally {
count.countDown();
}
});
}
//等待计数器计数
count.await();
// 如果下载失败,则清空队列
if (!downFlag.get()) {
throw new IOException("获取文件失败");
}
return queue;
}
然后通过下面的代码进行打印
LinkedBlockingQueue<byte[]> documentsByte = new LinkedBlockingQueue<>();
documentsByte = downloadAllDocument(urlList, documentsByte);
byte[] fileByte;
// 都下载结束,没问题,开始打印
PrinterJob printerJob = PrinterJob.getPrinterJob();
printerJob.setPrintService(printer);
while ((fileByte = documentsByte.poll()) != null) {
try (PDDocument document = PDDocument.load(fileByte)) {
printerJob.setPageable(new PDFPageable(document));
printerJob.print(attr);
}
}
同时通过查看javax.print的源码,增加了打印机不可用的判断,测试过打印机脱机和打印队列有未处理的失败的打印任务都不可用。(该判断在jre1.8.0_25下生效,在8u201、8u212下都没有生效)
// javax.print.PrintService printer
if (printer.getAttribute(PrinterIsAcceptingJobs.class) == PrinterIsAcceptingJobs.NOT_ACCEPTING_JOBS) {
throw new PrinterException("打印机不可用");
}
api代码如下
const printServerAddress = "http://127.0.0.1:31777/";
const downloadUrl = "print.zip";
function getPrinters(callbackFunc) {
$.ajax({
type: "GET",
url: printServerAddress + "getPrinters?_m=" + Math.random(),
success: function (msg) {
let dataObj = eval("(" + msg + ")");
callbackFunc(dataObj);
},
error: downloadIoPrint
});
}
function getDefaultPrinter(callbackFunc) {
$.ajax({
type: "GET",
url: printServerAddress + "getDefaultPrinter?_m=" + Math.random(),
success: callbackFunc,
error: downloadIoPrint
});
}
function printPdf(url, config) {
$.ajax({
type: "POST",
data: {
'printer': config.printer
, 'copies': config.copies
, 'duplex': config.duplex
, 'url': url
},
url: printServerAddress + "printPdf?_m=" + Math.random(),
success: config.done
});
}
function downloadIoPrint() {
if (confirm("请下载解压并运行打印服务后重试!点击【确定】下载。")) {
location.href = downloadUrl;
}
}
到这里,已经满足我们的需求。
(这里贴出来的只是主要代码,具体的项目代码可以查看github,github上面也有完整的调用demo)