Netty实现静态服务器之坑

概述

最近在重构一个H5的项目,需要把关系型数据库干掉以及集成静态页面,尽可能减少部署成本。

原本这个项目是独立的spring boot工程前端后分离的,页面是通过nginx跑起来的。重构后需要和MG(netty实现)服务端进行整合,在经过一系列尝试以后终于将后端能力重构到netty上,并且将复杂的逻辑关系用磁盘存储方式成功避免事物问题,从而去掉了关系型数据库。

页面初加载

本以为万事大吉,数据库都去掉了,就一个简单的页面而已,然噩梦刚开始。

MG的架构采用 netty+netty-resteasy+spring实现的,如果想要加载页面就必需增加自己的handler实现静态服务器。

开始一通百度然而没有这么做的案例,netty做静态服务的例子到时有,于是开始翻netty-resteasy的源码,有没有相关的扩展点。

服务入口是从这里开始的:


Properties pro = getNettyConfig();
NettyJaxrsServer netty = new NettyJaxrsServer();
netty.setDeployment(initDeployment());
netty.setPort(Integer.parseInt(pro.getProperty("port")));
netty.setRootResourcePath("/");
netty.setSecurityDomain(null);
netty.setExecutorThreadCount(Integer.parseInt(pro.getProperty("workerCount")));
netty.setMaxRequestSize(Integer.parseInt(pro.getProperty("maxPostSize")));
netty.setBacklog(Integer.parseInt(pro.getProperty("backlogSize")));
netty.setIdleTimeout(Integer.parseInt(pro.getProperty("readTimeout")));
Map<ChannelOption, Object> channelOptions = new HashMap<ChannelOption, Object>();
channelOptions.put(ChannelOption.TCP_NODELAY, true);
channelOptions.put(ChannelOption.CONNECT_TIMEOUT_MILLIS, Integer.parseInt(pro.getProperty("connectTimeout")));
channelOptions.put(ChannelOption.SO_RCVBUF, Integer.parseInt(pro.getProperty("receiveBuffer")));
channelOptions.put(ChannelOption.SO_SNDBUF, Integer.parseInt(pro.getProperty("sendBuffer")));
channelOptions.put(ChannelOption.SO_TIMEOUT, Integer.parseInt(pro.getProperty("readTimeout")));
channelOptions.put(ChannelOption.SO_KEEPALIVE, false);
netty.setChannelOptions(channelOptions);
netty.start();

找到start方法看到这是传统的netty主从reactor模式的标准写法。


public void start() {
        eventLoopGroup = new NioEventLoopGroup(ioWorkerCount);
        eventExecutor = new NioEventLoopGroup(executorThreadCount);
        deployment.start();
        // Configure the server.
        bootstrap.group(eventLoopGroup)
                .channel(NioServerSocketChannel.class)
                .childHandler(createChannelInitializer())
                .option(ChannelOption.SO_BACKLOG, backlog)
                .childOption(ChannelOption.SO_KEEPALIVE, true);

        for (Map.Entry<ChannelOption, Object> entry : channelOptions.entrySet()) {
            bootstrap.option(entry.getKey(), entry.getValue());
        }

        for (Map.Entry<ChannelOption, Object> entry : childChannelOptions.entrySet()) {
            bootstrap.childOption(entry.getKey(), entry.getValue());
        }

        final InetSocketAddress socketAddress;
        if (null == hostname || hostname.isEmpty()) {
            socketAddress = new InetSocketAddress(configuredPort);
        } else {
            socketAddress = new InetSocketAddress(hostname, configuredPort);
        }

        Channel channel = bootstrap.bind(socketAddress).syncUninterruptibly().channel();
        runtimePort = ((InetSocketAddress) channel.localAddress()).getPort();
    }

可以看到createChannelInitializer()这个方法其实是真正去注册accept事件的,那我们需要把我们自己的handler给到ChannelInitializer正常的话就可以实现我们的需求了。

于是继续跟踪createChannelInitializer:

private ChannelInitializer<SocketChannel> createChannelInitializer() {
        final RequestDispatcher dispatcher = createRequestDispatcher();
        if (sslContext == null && sniConfiguration == null) {
            return new ChannelInitializer<SocketChannel>() {
                @Override
                public void initChannel(SocketChannel ch) throws Exception {
                    setupHandlers(ch, dispatcher, HTTP);
                }
            };
        } else if (sniConfiguration == null) {
            return new ChannelInitializer<SocketChannel>() {
                @Override
                public void initChannel(SocketChannel ch) throws Exception {
                    SSLEngine engine = sslContext.createSSLEngine();
                    engine.setUseClientMode(false);
                    ch.pipeline().addFirst(new SslHandler(engine));
                    setupHandlers(ch, dispatcher, HTTPS);
                }
            };
        } else {
            return new ChannelInitializer<SocketChannel>() {
                @Override
                public void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline().addFirst(new SniHandler(sniConfiguration.buildMapping()));
                    setupHandlers(ch, dispatcher, HTTPS);
                }
            };
        }
    }
    
    // 以上几个方法最后都会调用这个方法
     private void setupHandlers(SocketChannel ch, RequestDispatcher dispatcher, RestEasyHttpRequestDecoder.Protocol protocol) {
        ChannelPipeline channelPipeline = ch.pipeline();
        channelPipeline.addLast(channelHandlers.toArray(new ChannelHandler[channelHandlers.size()]));
        channelPipeline.addLast(new HttpRequestDecoder(maxInitialLineLength, maxHeaderSize, maxChunkSize));
        channelPipeline.addLast(new HttpResponseEncoder());
        channelPipeline.addLast(new HttpObjectAggregator(maxRequestSize));
        channelPipeline.addLast(httpChannelHandlers.toArray(new ChannelHandler[httpChannelHandlers.size()]));
        channelPipeline.addLast(new RestEasyHttpRequestDecoder(dispatcher.getDispatcher(), root, protocol));
        channelPipeline.addLast(new RestEasyHttpResponseEncoder());
        if (idleTimeout > 0) {
            channelPipeline.addLast("idleStateHandler", new IdleStateHandler(0, 0, idleTimeout));
        }
        channelPipeline.addLast(eventExecutor, new RequestHandler(dispatcher));
    }

我们可以看到setupHandlers函数中一段关键代码

channelPipeline.addLast(httpChannelHandlers.toArray(new ChannelHandler[httpChannelHandlers.size()]));

这里把一个handlerlist 全部进行了绑定,我们跟踪这个httpChannelHandlers发现它是个空数组,全局搜索引用找到如下方法:


public void setHttpChannelHandlers(final List<ChannelHandler> httpChannelHandlers) {
        this.httpChannelHandlers = httpChannelHandlers == null ? Collections.<ChannelHandler>emptyList() : httpChannelHandlers;
}

看到希望了,这不就是给扩展使用的么,于是开始实现自己StaticServerHandler 对静态文件进行解析


@ChannelHandler.Sharable
public class StaticServerHandler extends SimpleChannelInboundHandler {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        if(msg instanceof FullHttpRequest ) {
            FullHttpRequest request= (FullHttpRequest)msg;
            //处理错误或者无法解析的http请求
            String uri = request.uri();
            request.retain();
            routerPaser(ctx, msg, request, uri);
        }
    }

    private void routerPaser(ChannelHandlerContext ctx, Object msg, FullHttpRequest request, String uri) throws IOException {
        if(uri.startsWith("/api/")) {
            //解决前端代理问题
            request.setUri(uri.replaceAll("/api", ""));
            ctx.fireChannelRead(request);
        }else if (uri.startsWith("/h5sign")||uri.startsWith("/mssg2")||uri.startsWith("/mssgApi")) {
            //其他请求往下进行。
            ctx.fireChannelRead(msg);
        }else {
            //页面解析逻辑
            doHander(ctx, request, getPath(ctx, uri));
        }
    }

    private String getPath(ChannelHandlerContext ctx, String uri) {
        String basePath;
        if(null== HomePathUtils.getH5HtmlUrl()||HomePathUtils.getH5HtmlUrl().length()==0) {
            String path = this.getClass().getClassLoader().getResource("templates").getPath();
            if(null==path||path.length()==0){
                notFound404Hander(ctx);
            }
            basePath = path+uri;
        }else{
            //如有设置了H5页面地址,则采用外部页面资源
            basePath = HomePathUtils.getH5HtmlUrl()+uri;
        }
        if(uri.equals(HomePathUtils.getH5StartPath())|| uri.equals(HomePathUtils.getH5JHStartPath())){
            basePath+="index.html";
            return basePath;
        }else if(basePath.contains("?")){
            basePath=basePath.substring(0,basePath.indexOf("?"));
            return basePath;
        }
        return basePath;
    }

    private void doHander(ChannelHandlerContext ctx, FullHttpRequest request, String path) throws IOException {
        File file = new File(path);
        //文件没有发现设置404
        if (!file.exists()) {
            notFound404Hander(ctx);
        }else {
            ctx.write(getHttpResponse(request, path, file));
            RandomAccessFile r = new RandomAccessFile(file, "r");
            ctx.write(new DefaultFileRegion(r.getChannel(), 0, file.length()));
            ChannelFuture channelFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
            if (!HttpUtil.isKeepAlive(request)) {
                channelFuture.addListener(ChannelFutureListener.CLOSE);
            }
            r.close();
        }
    }

    private HttpResponse getHttpResponse(FullHttpRequest request, String path, File file) {
        HttpResponse response = new DefaultHttpResponse(request.protocolVersion(), HttpResponseStatus.OK);
        //设置文件格式内容
        if (path.endsWith(".html")) {
            response.headers().set("Content-Type", "text/html;charset=UTF-8");
        } else if (path.endsWith(".js")) {
            response.headers().set("Content-Type", "application/javascript;charset=UTF-8");
        } else if (path.endsWith(".css")) {
            response.headers().set("Content-Type", "text/css;charset=UTF-8");
        } else if (path.endsWith(".svg")){
            response.headers().set("Content-Type","image/svg+xml");
        } else if (path.endsWith(".png")){
            response.headers().set("Content-Type","image/png");
        } else if (path.endsWith(".ico")){
            response.headers().set("Content-Type","image/x-icon");
        }
        response.headers().set("Accept-Ranges","bytes");
        response.headers().set("Content-Length", file.length());
        response.headers().set("Connection", "keep-alive");
        response.headers().set("Last-Modified",new Date(file.lastModified()));
        try {
            response.headers().set("ETag",generateETagHeaderValue(FileUtils.readFileToByteArray(file),file.lastModified()));
        } catch (IOException e) {
            e.printStackTrace();
        }
        return response;
    }

    public void notFound404Hander(ChannelHandlerContext ctx) {
        FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_FOUND);
        response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain;charset=UTF-8");
        response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
        ctx.write(response);
        ChannelFuture channelFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
        channelFuture.addListener(ChannelFutureListener.CLOSE);
    }

    protected StringBuilder generateETagHeaderValue(byte[] bytes,Long str) {
        StringBuilder builder = new StringBuilder(str+"-");
        DigestUtils.appendMd5DigestAsHex(bytes, builder);
        return builder;
    }
}

然后将自己的handler StaticServerHandler交给resteasy 

ArrayList<ChannelHandler> handlers = new ArrayList<>();handlers.add(new StaticServerHandler());netty.setHttpChannelHandlers(handlers);

页面被成功的加载了起来,开始提测,开心的下去叫了杯凉饮。但是没几个小时就被提了几个bug,接着噩梦就开始了。

修成正果

谷歌测试一切正常,但是我们的系统是给政务用的,他们大多都是ie浏览器,netty加载的页面居然在ie上不兼容。

在ie地址栏打开地址什么反应都没有,很奇怪的是偶尔会正常,于是开始f12 一丢丢的看,发现有个js总是加载不完整,从而导致页面展现不出来。

但是偶尔几次可以完整加载,这个文件也不大就1.3MB,开始一通百度,各种方法都尝试了。

Etag也算了以后比之前好多了,成功的频率比之前多了,但是仍然解决根本问题。

开始对比nginx和基于netty实现的静态服务器返回的请求头等是否缺少参数,也都补全了问题依旧。

就这么过了2天自己都想放弃整合页面了,想着干脆用nginx加载算了,就跟领导说实在解决不了了,正在吃早餐的时候突然有个想法从脑中闪过,马上尝试结构ok了。

原来问题出在资源释放哪里,网上例子都是这么释放的,谷歌也正常,但是ie就不行就是如下这段代码


 RandomAccessFile r = new RandomAccessFile(file, "r");
 ctx.write(new DefaultFileRegion(r.getChannel(), 0, file.length()));
 ChannelFuture channelFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
 if (!HttpUtil.isKeepAlive(request)) {
       channelFuture.addListener(ChannelFutureListener.CLOSE);
 }
 r.close();

此处RandomAccessFile 就不用close。如果次数进行了close ie就会出现各种问题,因为netty已经替我们做了释放操作,将此处改为:


 ctx.write(new DefaultFileRegion(file, 0, file.length()));
            ChannelFuture channelFuture = 
 ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
            if (!HttpUtil.isKeepAlive(request)) {
                channelFuture.addListener(ChannelFutureListener.CLOSE);
 }

其实DefaultFileRegion底层也是直接new的RandomAccessFile

    public void open() throws IOException {
        if (!isOpen() && refCnt() > 0) {
            // Only open if this DefaultFileRegion was not released yet.
            file = new RandomAccessFile(f, "r").getChannel();
        }
    }  

    @Override
    protected void deallocate() {
        FileChannel file = this.file;

        if (file == null) {
            return;
        }
        this.file = null;

        try {
            file.close();
        } catch (IOException e) {
            if (logger.isWarnEnabled()) {
                logger.warn("Failed to close a file.", e);
            }
        }
    }

总结

看来百度的代码不能全信啊,有时候还会给我们带来不必要的麻烦。就拿我这例子来说百度出来的帖子全是一个模板出来的。

所以大家以后还是要多多看优秀框架的源码,了解其底层实现原理,才可以玩的转啊。


欢迎关注个人公众号!

                                            

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值