基于netty的浏览器客户端打印控件实现

项目地址:https://github.com/w414034207/print-netty

业务场景

给客户开发一个web管理系统时,客户要求能够在浏览器点击打印,直接使用客户端的本地打印机打印服务端的文件,打印配置可以在网页进行设置。
客户要求静默打印,用户不需要看打印预览页面,可以打印服务端动态生成的PDF文件,可以在网页上选择打印份数,默认双面打印。

思路

网上看了各种提供打印的方式,使用js的一些打印控件都不符合要求,PDF文件中的电子签名解析不出来
还试用了两种收费的打印控件,都是需要在客户端安装启动一个可执行文件。
下图为当时试过的打印方式
当时试过的打印方式
其中一个收费的控件能一定程度满足我们的需求,直接传URL就能打印对应的PDF文件。且不会丢失PDF文件中的电子签名。(即上图中的第一个button对应的,简写是jcp,可以静默打印,也可以传各种打印参数,缺点是:1. 打印方法的回调不能确定是否打印成功;2. 如果url没有返回pdf,回调不会被调用;3. 收费)

这时我就想,如果考虑客户端安装控件的方式,好像自己写一个控件问题也不大:

  1. 实现一个http服务,可接收http请求,被请求时可用java调用打印机进行打印;
  2. 浏览器客户端请求本地一个固定端口即可访问到该服务;

技术选择

Spring Boot

现成的http服务,我直接就写了一个,验证上面思路的可行性,实验之后确实可以在浏览器端打印PDF文件;但是问题就是太大了,由于我的功能很简单,而使用了Spring Boot之后,运行时内存都200M以上,这对用户的机器性能就有可能造成较大的影响;

Java NIO

由于当时刚好在学习Java NIO的相关知识,既然Spring Boot现成的服务太大了,我或许可以自己实现http协议的编解码,使用Java NIO实现http服务,使服务更轻量,然后我就舍弃了Spring Boot,改用Java NIO实现(这个代码,在github上是Private的,如果有感兴趣的盆友可以发消息找我要);

netty

NIO版本的已经确认可以在FireFox、Chrome浏览器稳定使用,但是在使用IE进行测试时,经常会出现请求消息读取不完整的情况,我自己实现的http协议看来还需要优化。考虑到自己写的http协议还是太简陋,优化的空间很大,成本不好说。所以我决定还是使用netty提供的现成的http支持,用netty实现http服务,以兼顾轻量和稳定。

实现

netty实现http服务端
  1. 创建处理http请求的Handler(这里只贴出主要的方法,还有两个必须实现的接口方法没有贴)
@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()));
    }
}
  1. 启动一个netty服务
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();
        }
    }
}
打印功能
  1. 获取所有打印机
PrintService[] printServices = PrinterJob.lookupPrintServices();
  1. 获取默认打印机
PrintService defaultService = PrintServiceLookup.lookupDefaultPrintService();
  1. 打印功能实现,由于业务需要,可能会一次打印多个PDF文件。所以我使用多线程下载文件的方式,先把所有文件都下载到内存。都下载成功才启动打印任务。
    启动多线程下载的代码如下:
    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("打印机不可用");
        }
js提供客户端API

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)

你可能感兴趣的:(java,netty)