如何利用ChannelPipeline在Netty中搭建无懈可击的数据处理流水线?

在上篇文章(Netty 入门 --- ChannelHandler, Netty 的数据加工厂)提到 ChannelHandler 虽然是一个好的打工人,但是在我们实际业务线中,他不可能一个人干所有的活啊,毕竟都 21 世纪了,我们是要讲究分工的。所以 Netty 就需要一个好的组织者将这些 ChannelHandler 组织起来,形成一个条完整,高效的业务线,这个组织者就是 ChannelPipeline。

ChannelPipeline 概述

pipeline 翻译为管道、流水线,在 Netty 这个大工厂中,ChannelPipeline 就像一条流水线,数据流过 ChannelPipeline,被一步一步地加工,最后得到一个成熟的工艺品。

在 Netty 中,ChannelPipeline 是 Netty 的核心处理链,用于实现网络时间的动态编排和有序传播。它负责组织和编排各种 ChannelHandler,使他们能够有序地组织在一起,但实际的数据加工还是由 ChannelHandler 处理。

ChannelPipeline 的内部结构

ChannelPipeline 可以看作是 ChannelHandler 的容器载体,它是由一组 ChannelHandler 有序地组成的双向拦截链。每当新建一个 Channel 时都会新建一个 ChannelPipeline 与之绑定,而且这种绑定关系是永久的,当该 Channel 有 I/O 读写事件发生时,数据会贯穿整个 ChannelPipeline ,由里面的 ChannelHandler 依次拦截和处理。

我们知道 ChannelHandler 分为出站 handler 和入站 handler ,但是 ChannelPipeline 并没有将他们分开,而是将出站 handler 和入站handler 混编在一起的,当一个入站事件从 ChannelPipeline 的头部向尾部开始传播的时候,每一个 ChannelHandler 都会判断下一个 ChannelHandler 的类型是否与当前 ChannelHandler 的类型相同,如果是则将事件传播给他,不是则跳过该 ChannelHandler 传递下一个,直到找到跟他相同类型的 ChannelHandler。下图是一个入站的传播路径:

我们一般都是选择入站节点作为头部,出站节点作为尾部的。

ChannelPipeline 也提供了一些 API 用于维护该双向链。

api 描述
addLast() 将该 ChannelHandler 添加到 ChannelPipeline 的末尾
addBefore() 将该ChannelHandler 添加在指定名称的 ChannelHandler 之前
addAfter() 将该ChannelHandler 添加在指定名称的 ChannelHandler 之后
addFirst() 将该 ChannelHandler 添加到 ChannelPipeline 的第一个位置
remove() 删除指定的 ChannelHandler
replace() 替换指定的 ChannelHandler

ChannelHandlerContext

ChannelHandlerContext 用于保存 ChannelHandler 的上下文,它包含了 ChannelHandler 生命周期中的所有事件,如 connect

bindreadwrite

为什么会有一个 ChannelHandlerContext 呢?其实这是一种编程思想,我认为是单一职责,一个类只做一件事。试想下 ChannelHandler 是数据加工厂,但是我们现在又要他来维护它与周边 ChannelHandler 的关系,要负责事件的传播,还要维护其生命周期,累不累啊,功能严重耦合。所以我们需要 ChannelHandlerContext 来帮助他更好地工作,它只需要做事,其余的交给 ChannelHandlerContext 了(我靠,立刻脑补了我们的 996)。

ChannelHandlerContext 他代表了 ChannelHandler 和 ChannelPipeline 之间的关联,每当有一个 ChannelHandler 被添加到 ChannelPipeline 中时都会创建一个 ChannelHandlerContext 与之关联,它维护着该 ChannelHandler 与其他 ChannelHandler(同一个 ChannelPipeline) 的之间交互。

我们在初始化 ChannelPipeline 的时候会发现,ChannelPipeline 的双向链表其实是有特定的首尾节点的,其中首节点 HeadContext,尾节点 TailContext,我们所有自定义的 ChannelHandler 节点都是唯一这两个节点的中间。

从上图我们可以看出 HeadContext 既是 InboundHandler,也是 OutboundHandler,所以读事件则是从 HeadContext 开始,写事件也是在 HeadContext 结束。而 TailContext 则只是 OutboundHandler,它会在 ChannelPipeline 调用链的最后一步执行,用于终止 Inbound 的事件传播。TailContext 作为 Outbound 事件传播的第一站,它仅仅只是将 Outbound 事件进行传递。

加入 ChannelHandlerContext 的完整图如下:

ChannelPipeline 的 API

ChannelPipeline 的 API 不仅仅有对 ChannelHandler 的维护功能,还有一些入站和出站的方法。

  • 入站 API
方法 描述
fireChannelRegistered 调用 ChannelPipeline 中下一个 ChannelInboundHandler 的 channelRegistered 方法
fireChannelUnregistered 调用 ChannelPipeline 中下一个 ChannelInboundHandler 的 channelUnregistered 方法
fireChannelActive 调用 ChannelPipeline 中下一个 ChannelInboundHandler 的 channelActive 方法
fireChannelInactive 调用 ChannelPipeline 中下一个 ChannelInboundHandler 的 channelInactive 方法
fireExceptionCaught 调用 ChannelPipeline 中下一个 ChannelInboundHandler 的 exceptionCaught 方法
fireUserEventTriggered 调用 ChannelPipeline 中下一个 ChannelInboundHandler 的 userEventTriggered 方法
fireChannelRead 调用 ChannelPipeline 中下一个 ChannelInboundHandler 的 channelRead 方法
fireChannelReadComplete 调用 ChannelPipeline 中下一个 ChannelInboundHandler 的 channelReadComplete 方法

从这里就可以看出,所有的 fireXx() 方法其实就是将消息传递给下一个节点

  • 出站 API
方法 描述
bind 调用 ChannelPipeline 中下一个 ChannelOutboundHandler 的bind 方法,将 Channel 与本地地址绑定
connect 调用 ChannelPipeline 中下一个 ChannelOutboundHandler 的connect 方法,将 Channel 连接到远程节点
disconnect 调用 ChannelPipeline 中下一个 ChannelOutboundHandler 的disconnect 方法,将 Channel 与远程连接断开
close 调用 ChannelPipeline 中下一个 ChannelOutboundHandler 的close 方法,将 Channel 关闭
deregister 调用 ChannelPipeline 中下一个 ChannelOutboundHandler 的deregister 方法,将 Channel 从其对应的 EventLoop 注销
flush 调用 ChannelPipeline 中下一个 ChannelOutboundHandler 的flush 方法,将 Channel 的数据冲刷到远程节点
read 调用 ChannelPipeline 中下一个 ChannelOutboundHandler 的 read 方法,从 Channel 中读取数据
write 调用 ChannelPipeline 中下一个 ChannelOutboundHandler 的 write 方法,将数据写入 Channel
writeAndFlush 先调用 write 方法,然后调用flush方法,将数据写入并刷回远程节点

出站类的方法都是与 Channel 相关的。

ChannelPipeline 事件传播机制

记住:InboundHandler顺序执行,OutboundHandler逆序执行。

ChannelPipeline 将 ChannelHandler 编排好后,就响应等待 I/O 事件了,但是这个事件是如何传播的呢?大明哥通过一个例子来跟你细说。首先我们需要先构造一个如下图的传播链。

代码如下:

java 复制代码
public class ChannelPipelineTest_01_server {
    public static void main(String[] args) {
        new ServerBootstrap()
                .group(new NioEventLoopGroup())
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel ch) throws Exception {
                        ChannelPipeline pipeline = ch.pipeline();
                        // 这里一定要按照顺序来添加
                        pipeline.addLast(new InboundHandler("InboundHandler-1",false));
                        pipeline.addLast(new InboundHandler("InboundHandler-2",false));
                        pipeline.addLast(new OutboundHandler("OutboundHandler-1"));
                        pipeline.addLast(new OutboundHandler("OutboundHandler-2"));
                        pipeline.addLast(new InboundHandler("InboundHandler-3",true));
                    }
                })
                .bind(8081);
    }

    private static class InboundHandler extends ChannelInboundHandlerAdapter {
        // handler 的名称
        private String handlerName;

        // 是否写数据
        private Boolean flushFlag;

        public InboundHandler(String handlerName,Boolean flushFlag) {
            this.handlerName = handlerName;
            this.flushFlag = flushFlag;
        }

        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
            System.out.println("InboundHandler :" + handlerName);
            if (!flushFlag) {
                // 不需要写数据,传递给下一个节点
                ctx.fireChannelRead(msg);
            } else {
                // 写数据,则调用 channel.writeAndFlush()
                System.out.println("==============================");
                ctx.channel().writeAndFlush(msg);
            }
        }
    }

    private static class OutboundHandler extends ChannelOutboundHandlerAdapter {
        // handler 的名称
        private String handlerName;

        public OutboundHandler(String handlerName) {
            this.handlerName = handlerName;
        }

        @Override
        public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
            System.out.println("OutboundHandler :" + handlerName);
            super.write(ctx,msg,promise);
        }
    }
}

执行结果如下:

ruby 复制代码
InboundHandler :InboundHandler-1
InboundHandler :InboundHandler-2
InboundHandler :InboundHandler-3
==============================
OutboundHandler :OutboundHandler-2
OutboundHandler :OutboundHandler-1

由执行结果可见,Inbound 事件是有 Head ---> Tail,而 Outbound 事件则是由 Tail ---> Head,两者传播方向恰好相反。上面例子的运行流程如下:

红色为 Inbound 事件的响应路径,紫色为 Outbound 事件的响应路径。

在 InboundHandler 中,有段代码我们需要注意:

scss 复制代码
if (!flushFlag) {
    // 不需要写数据,传递给下一个节点
    ctx.fireChannelRead(msg);
} else {
    // 写数据,则调用 channel.writeAndFlush()
    System.out.println("==============================");
    ctx.channel().writeAndFlush(msg);
}

这了有小伙伴会有疑问,为什么传递到下一个节点的时候是调用 ChannelHandlerContext.fireChannelRead(),而写数据的时候调用的是 Channel.writeAndFlush(),其实如果小伙伴去看 API 的时候会发现 ChannelHandlerContext 也是有 writeAndFlush() 的,但是为什么不使用 ChannelHandlerContext 的,而使用 的呢?其实这正是 ChannelHandlerContext 与 Channel 或者 ChannelPipeline 的区别:

  • Channel 或 ChannelPipeline 的方法其影响是会沿着整个 ChannelPipeline 进行传播。
  • 而 ChannelHandlerContext 方法则是从与其相关联的 ChannelHandler 开始,且只会传播给该 ChannelPipeline 种下一个能处理该事件的 ChannelHandler。

有兴趣的小伙伴,可以将代码调整下:

scss 复制代码
ctx.channel().writeAndFlush(msg);
调整为
ctx.writeAndFlush(msg);

然后再运行下,大明哥就不再演示了。

ChannelPipeline 异常传播机制

任何 ChannelHandler 都有可能会产生异常,如果某一个 ChannelHandler 的处理逻辑出现了异常,会有什么情况呢?我们对上面的代码进行简单调整下:

scala 复制代码
public class ChannelPipelineTest_02_server {
    // 省略代码
    
    private static class InboundHandler extends ChannelInboundHandlerAdapter {
        // 省略代码
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
            System.out.println("InboundHandler :" + handlerName);

            if ("InboundHandler-2".equals(handlerName)) {
                throw new RuntimeException("InboundHandler Exception");
            }

            if (!flushFlag) {
                // 不需要写数据,传递给下一个节点
                ctx.fireChannelRead(msg);
            } else {
                // 写数据,则调用 channel.writeAndFlush()
                System.out.println("==============================");
                ctx.channel().writeAndFlush(msg);
            }
        }
    }

    // 省略代码
}

当 handler 为 InboundHandler-2 时,就抛出异常。运行结果:

php 复制代码
InboundHandler :InboundHandler-1
InboundHandler :InboundHandler-2
2022-06-25 22:29:22.224 [nioEventLoopGroup-2-2] WARN  io.netty.channel.DefaultChannelPipeline - An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception.
java.lang.RuntimeException: InboundHandler Exception
  at com.sike.netty.rumen.channelpipeline.ChannelPipelineTest_02_server$InboundHandler.channelRead(ChannelPipelineTest_02_server.java:48) ~[classes/:?]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at com.sike.netty.rumen.channelpipeline.ChannelPipelineTest_02_server$InboundHandler.channelRead(ChannelPipelineTest_02_server.java:53) [classes/:?]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:722) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:658) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:584) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:496) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:995) [netty-common-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) [netty-common-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) [netty-common-4.1.77.Final.jar:4.1.77.Final]
  at java.lang.Thread.run(Thread.java:748) [?:1.8.0_201]

日志中我们可以看出,事件仅仅只传播到了 InboundHandler-2 节点就没有传播下去了,同时还有一条 WARN io.netty.channel.DefaultChannelPipeline... 的告警日志,这是 TailContext 节点打印出来的,它的出现表明了用户没有对异常进行拦截和处理,最后只能有 TailContext 节点来处理了。

我们再来改造下:

scala 复制代码
public class ChannelPipelineTest_03_server {
    // 省略代码
    
    private static class InboundHandler extends ChannelInboundHandlerAdapter {
        // 省略代码
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
            System.out.println("InboundHandler :" + handlerName);

            if ("InboundHandler-2".equals(handlerName)) {
                throw new RuntimeException("InboundHandler Exception");
            }

            if (!flushFlag) {
                // 不需要写数据,传递给下一个节点
                ctx.fireChannelRead(msg);
            } else {
                // 写数据,则调用 channel.writeAndFlush()
                System.out.println("==============================");
                ctx.channel().writeAndFlush(msg);
            }
        }
        
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
            System.out.println("InBoundHandler Exception: " + handlerName);

            // 将异常传播下去
            ctx.fireExceptionCaught(cause);
        }
    }

    // 省略代码
}

InboundHandler 重写了 exceptionCaught(),在 Netty 入门 --- ChannelHandler, Netty 的数据加工厂 中讲到 exceptionCaught() 是当 ChannelHandler 在处理过程中出现异常时调用。ctx.fireExceptionCaught(cause); 表示将 Exception 传播下去。运行结果:

php 复制代码
InboundHandler :InboundHandler-1
InboundHandler :InboundHandler-2
InBoundHandler Exception: InboundHandler-2
InBoundHandler Exception: InboundHandler-3
2022-06-25 22:40:26.371 [nioEventLoopGroup-2-2] WARN  io.netty.channel.DefaultChannelPipeline - An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception.
java.lang.RuntimeException: InboundHandler Exception
  at com.sike.netty.rumen.channelpipeline.ChannelPipelineTest_03_server$InboundHandler.channelRead(ChannelPipelineTest_03_server.java:48) ~[classes/:?]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at com.sike.netty.rumen.channelpipeline.ChannelPipelineTest_03_server$InboundHandler.channelRead(ChannelPipelineTest_03_server.java:53) [classes/:?]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:722) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:658) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:584) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:496) [netty-transport-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:995) [netty-common-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) [netty-common-4.1.77.Final.jar:4.1.77.Final]
  at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) [netty-common-4.1.77.Final.jar:4.1.77.Final]
  at java.lang.Thread.run(Thread.java:748) [?:1.8.0_201]

从运行结果中可以看出,ctx.fireExceptionCaught 将异常从当前节点传播到了 TailContext 节点。异常信息依然由 TailContext 进行统一处理。

虽然 Netty 在 TailContext 中做了最后的兜底,但是这种情况并不满足我们实际业务的处理逻辑,对于具体的业务而言,响应方不仅仅需要对异常进行拦截和处理,还需要根据具体的异常类型做出不同的异常处理。

ChannelPipeline 异常的最佳实践

如上所描述的,在实际的业务场景中我们需要根据具体的异常类型做出不同的异常处理,不能简单粗暴地丢给 Netty 来做兜底处理,所以最好的方法是我们需要对异常进行统一拦击,然后从实际业务场景触发做出不同的异常处理机制。所以最好的方式是我们需要添加一个自定义的异常处理 Handler:ExceptionHandler。如下:

伪代码如下:

scala 复制代码
public class ExceptionHandler extends ChannelDuplexHandler {

    @Override

    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {

        if (cause instanceof BusinessException) {
              // dosomething
        }
        if (cause instanceof SystemException) {
              // dosomething
        }
        .....
    }

}

ChannelDuplexHandler 是一个比较特殊的 ChannelHandler ,它同时实现了 ChannelInboundHandler 和 ChannelOutboundHandler,所以它能同时响应入站事件和出站事件,而对于我们统一异常处理是不需要区分入站事件和出站事件的。

总结

最后,大明哥来做一个总结,加深各位小伙伴们的理解。

  1. ChannelPipeline 是一个有多个 ChannelHandler 组成的双向链表。

  2. 在创建 Channel 的时候会随之创建一个与其绑定的 ChannelPipeline,且这种绑定关系是持久的。

  3. ChannelHandlerContext 是 ChannelHandler 的封装,每一个 ChannelHandler 都对应一个 ChannelHandlerContext,ChannelHandlerContext 里面保存着与之对应的 ChannelHandler 的上下文,它维护着该 ChannelHandler 与其他 ChannelHandler 的关系。

  4. 事件传播

    1. Inbound 事件的传播方向是 Head ---> Tail。
    2. Outbound 事件的传播方向是 Tail ---> Head。
  5. 每个节点都有可能会有异常发生,我们对异常的处理方式是在 ChannelPipeline 链路上增加一个 ExceptionHandler 用来统一拦截处理异常。

示例代码:suo.nz/1vasBW

相关推荐
2402_8575893621 分钟前
Spring Boot新闻推荐系统设计与实现
java·spring boot·后端
繁依Fanyi25 分钟前
旅游心动盲盒:开启个性化旅行新体验
java·服务器·python·算法·eclipse·tomcat·旅游
J老熊30 分钟前
Spring Cloud Netflix Eureka 注册中心讲解和案例示范
java·后端·spring·spring cloud·面试·eureka·系统架构
蜜桃小阿雯32 分钟前
JAVA开源项目 旅游管理系统 计算机毕业设计
java·开发语言·jvm·spring cloud·开源·intellij-idea·旅游
CoderJia程序员甲32 分钟前
重学SpringBoot3-集成Redis(四)之Redisson
java·spring boot·redis·缓存
Benaso33 分钟前
Rust 快速入门(一)
开发语言·后端·rust
sco528233 分钟前
SpringBoot 集成 Ehcache 实现本地缓存
java·spring boot·后端
OLDERHARD1 小时前
Java - LeetCode面试经典150题 - 矩阵 (四)
java·leetcode·面试
原机小子1 小时前
在线教育的未来:SpringBoot技术实现
java·spring boot·后端
吾日三省吾码1 小时前
详解JVM类加载机制
后端