MiniSpring框架学习笔记-动态代理:如何在运行时插入逻辑?

MiniSpring框架学习笔记-动态代理:如何在运行时插入逻辑?

教程: https://github.com/YaleGuo/minis

极客时间: 手把手带你写一个 MiniSpring

17. 动态代理:如何在运行时插入逻辑?

前面几章里,MiniSpring 已经有了 IoC、MVC、JDBC 模板和 SQL 配置化。接下来如果想继续理解 Spring 里的 AOP,就需要先补一块很重要的前置知识:代理,尤其是 JDK 动态代理。

AOP 是面向切面编程,全称是 Aspect Oriented Programming。它想解决的问题很直观:日志、权限检查、事务控制、性能监控这类代码,通常会散落在很多业务方法前后。如果每个业务方法都手写一遍,代码会越来越乱。

AOP 的核心想法是:

text 复制代码
业务方法只写业务逻辑
        ↓
日志、权限、事务等通用逻辑抽出来
        ↓
程序运行时,把这些通用逻辑插到业务方法前后

这一节先不做完整 AOP,只讲它最关键的一种实现手段:动态代理。

本节主线是:

text 复制代码
先理解代理模式
        ↓
再看 JDK 动态代理怎么用
        ↓
最后把动态代理接入 MiniSpring 容器

一、先理解代理是什么

代理的意思是:调用方不直接访问真实对象,而是先访问一个代理对象,再由代理对象去调用真实对象。

用生活里的例子看:

text 复制代码
你
 ↓
房屋中介
 ↓
房东

你真正想找的是房东,但中介可以在中间做一些额外的事情,比如预约看房、准备合同、沟通价格。放到代码里也是一样,业务对象负责真正的业务,代理对象负责在调用前后加一些通用逻辑。

假设我们有一个业务接口:

java 复制代码
package com.test.service;

public interface IAction {

    void doAction();
}

真实业务类只关心业务本身:

java 复制代码
package com.test.service;

public class Action1 implements IAction {

    @Override
    public void doAction() {
        System.out.println("do real action");
    }
}

如果用静态代理,可以手写一个代理类:

java 复制代码
package com.test.service;

/**
 * 静态代理:代理类提前写好,并且和真实类实现同一个接口。
 */
public class ActionProxy implements IAction {

    private final IAction target;

    public ActionProxy(IAction target) {
        this.target = target;
    }

    @Override
    public void doAction() {
        System.out.println("before call real object");
        this.target.doAction();
    }
}

它们的关系可以画成这样:

text 复制代码
                IAction
                  ^
                  |
        +---------+---------+
        |                   |
     Action1           ActionProxy
  真实业务对象          静态代理对象
                            |
                            | target
                            v
                         Action1

调用方只面向 IAction

java 复制代码
IAction action = new ActionProxy(new Action1());
action.doAction();

静态代理很好理解,但缺点也明显:如果很多类都要加日志、事务、权限检查,我们就要为很多真实类手写对应的代理类。通用逻辑还没真正通用起来。

所以我们需要动态代理:代理类不用提前手写,而是在程序运行期间由 JVM 动态生成。

二、JDK 动态代理怎么用

JDK 提供的动态代理入口是:

java 复制代码
Proxy.newProxyInstance(
        类加载器,
        要代理的接口数组,
        方法调用处理器
)

三个参数分别表示:

参数 作用
ClassLoader 用哪个类加载器生成代理类
Class<?>[] interfaces 代理对象要实现哪些接口
InvocationHandler 代理对象的方法被调用时,统一交给谁处理

重点是第三个参数 InvocationHandler。只要调用代理对象的方法,JDK 都会转进它的 invoke(...) 方法:

java 复制代码
Object invoke(Object proxy, Method method, Object[] args) throws Throwable;

可以把调用链理解成:

text 复制代码
调用 proxyAction.doAction()
        ↓
进入 InvocationHandler.invoke(...)
        ↓
执行增强逻辑
        ↓
通过反射调用 target.doAction()

先写一个最直接的动态代理类:

java 复制代码
package com.test.service;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

/**
 * 教学版动态代理。
 *
 * 传入一个真实对象,返回一个实现相同接口的代理对象。
 */
public class DynamicProxy {

    private final Object target;

    public DynamicProxy(Object target) {
        if (target == null) {
            throw new IllegalArgumentException("Target must not be null");
        }
        this.target = target;
    }

    public Object getProxy() {
        Class<?>[] interfaces = this.target.getClass().getInterfaces();
        if (interfaces.length == 0) {
            throw new IllegalStateException(
                    "JDK dynamic proxy requires at least one interface");
        }

        return Proxy.newProxyInstance(
                this.target.getClass().getClassLoader(),
                interfaces,
                new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args)
                            throws Throwable {
                        if ("doAction".equals(method.getName())) {
                            System.out.println("before call real object");
                        }
                        return invokeTarget(method, args);
                    }
                });
    }

    private Object invokeTarget(Method method, Object[] args) throws Throwable {
        try {
            return method.invoke(this.target, args);
        } catch (InvocationTargetException ex) {
            throw ex.getTargetException();
        }
    }
}

这里有两个点要注意。

第一,JDK 动态代理只能代理接口。也就是说,Action1 必须实现 IAction,最终拿到的代理对象也应该转成 IAction 使用。

第二,invoke(...) 里不能只处理 doAction,其他方法就直接返回 null。代理对象上可能还会调用 toString()equals(...),或者接口里以后新增的方法。更稳妥的写法是:需要增强的方法加增强,不需要增强的方法也继续转发给真实对象。

在 Controller 里可以这样手动使用:

java 复制代码
package com.test.controller;

import com.minis.beans.factory.Autowired;
import com.minis.web.RequestMapping;
import com.test.service.DynamicProxy;
import com.test.service.IAction;

import java.io.IOException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class HelloWorldBean {

    @Autowired
    private IAction action;

    @RequestMapping("/testaop")
    public void doTestAop(HttpServletRequest request,
                          HttpServletResponse response) {
        IAction proxyAction = (IAction) new DynamicProxy(this.action).getProxy();
        proxyAction.doAction();

        try {
            response.getWriter().write("test aop, hello world!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这段代码能跑通动态代理,但它有一个问题:业务代码侵入太强。

调用方本来只想写:

java 复制代码
action.doAction();

结果现在必须手动写:

java 复制代码
DynamicProxy proxy = new DynamicProxy(action);
IAction proxyAction = (IAction) proxy.getProxy();
proxyAction.doAction();

这就不像框架了。我们更希望业务代码看起来还是直接调用接口,至于拿到的是普通对象还是代理对象,由 MiniSpring 容器帮我们决定。

三、用 FactoryBean 把代理交给容器

为了让业务代码直接注入代理对象,我们可以引入 FactoryBean

普通 Bean 的逻辑是:

text 复制代码
getBean("action")
        ↓
返回 action 这个 Bean 本身

FactoryBean 的逻辑多了一层:

text 复制代码
getBean("action")
        ↓
发现 action 是一个 FactoryBean
        ↓
调用 factoryBean.getObject()
        ↓
返回 getObject() 生产出来的对象

在这一节里,action 这个 Bean 本身是 ProxyFactoryBean,但调用方真正拿到的是它创建出来的动态代理对象。

XML 可以这样配置:

xml 复制代码
<bean id="realaction" class="com.test.service.Action1"/>

<bean id="action" class="com.minis.aop.ProxyFactoryBean">
    <property type="java.lang.Object" name="target" ref="realaction"/>
</bean>

这里有两个 Bean:

Bean id 实际类型 作用
realaction Action1 真实业务对象
action ProxyFactoryBean 生产 realaction 的代理对象

因为当前 MiniSpring 的 @Autowired 是按字段名找 Bean,所以 Controller 里字段名写成 action,就会拿到 action 这个 Bean 对应的对象。由于 actionFactoryBean,最终注入进去的是代理对象。

四、FactoryBean 接口

FactoryBean 的接口很小:

java 复制代码
package com.minis.beans.factory;

public interface FactoryBean<T> {

    /**
     * 返回这个工厂真正生产出来的对象。
     */
    T getObject() throws Exception;

    /**
     * 返回生产对象的类型,当前教学版可以先用于说明和调试。
     */
    Class<?> getObjectType();

    /**
     * 默认按单例处理,也就是同一个 FactoryBean 只创建一个产品对象。
     */
    default boolean isSingleton() {
        return true;
    }
}

它和普通工厂类的区别在于:容器认识 FactoryBean,并且会在 getBean(...) 时对它做特殊处理。

五、BeanFactory 里怎么识别 FactoryBean

AbstractBeanFactory.getBean(...) 原来的职责是创建普通 Bean。现在它需要在返回前多判断一步:如果当前 Bean 是 FactoryBean,就返回它生产出来的对象。

只看关键逻辑,可以整理成这样:

java 复制代码
package com.minis.beans.factory.support;

import com.minis.beans.BeansException;
import com.minis.beans.factory.FactoryBean;
import com.minis.beans.factory.config.BeanDefinition;

public abstract class AbstractBeanFactory extends FactoryBeanRegistrySupport
        implements ConfigurableBeanFactory, BeanDefinitionRegistry {

    @Override
    public Object getBean(String beanName) throws BeansException {
        Object singleton = this.getSingleton(beanName);

        if (singleton == null) {
            BeanDefinition beanDefinition = this.beanDefinitionMap.get(beanName);
            if (beanDefinition == null) {
                return null;
            }

            singleton = createBean(beanDefinition);
            this.registerBean(beanName, singleton);

            applyBeanPostProcessorsBeforeInitialization(singleton, beanName);
            invokeInitMethodIfNecessary(beanDefinition, singleton);
            applyBeanPostProcessorsAfterInitialization(singleton, beanName);
        }

        return getObjectForBeanInstance(singleton, beanName);
    }

    protected Object getObjectForBeanInstance(Object beanInstance,
                                              String beanName) {
        if (!(beanInstance instanceof FactoryBean)) {
            return beanInstance;
        }

        FactoryBean<?> factoryBean = (FactoryBean<?>) beanInstance;
        return getObjectFromFactoryBean(factoryBean, beanName);
    }

    private void invokeInitMethodIfNecessary(BeanDefinition beanDefinition,
                                             Object bean) {
        String initMethodName = beanDefinition.getInitMethodName();
        if (initMethodName != null && !initMethodName.isEmpty()) {
            invokeInitMethod(beanDefinition, bean);
        }
    }
}

如果 FactoryBean 生产的是单例对象,还可以在 FactoryBeanRegistrySupport 里做一层缓存:

java 复制代码
package com.minis.beans.factory.support;

import com.minis.beans.factory.FactoryBean;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public abstract class FactoryBeanRegistrySupport
        extends DefaultSingletonBeanRegistry {

    private final Map<String, Object> factoryBeanObjectCache =
            new ConcurrentHashMap<>();

    protected Object getObjectFromFactoryBean(FactoryBean<?> factory,
                                              String beanName) {
        if (factory.isSingleton()) {
            return this.factoryBeanObjectCache.computeIfAbsent(
                    beanName, key -> doGetObjectFromFactoryBean(factory, key));
        }
        return doGetObjectFromFactoryBean(factory, beanName);
    }

    private Object doGetObjectFromFactoryBean(FactoryBean<?> factory,
                                              String beanName) {
        try {
            return factory.getObject();
        } catch (Exception ex) {
            throw new IllegalStateException(
                    "FactoryBean getObject failed: " + beanName, ex);
        }
    }
}

这样 getBean("action") 的结果就不是 ProxyFactoryBean 本身,而是它生产出来的代理对象。

六、ProxyFactoryBean:生产代理对象

ProxyFactoryBean 是这一节最核心的类。它自己是一个 Bean,但它返回给业务层的是代理对象。

java 复制代码
package com.minis.aop;

import com.minis.beans.factory.FactoryBean;

/**
 * 用 FactoryBean 把代理对象交给 IoC 容器管理。
 */
public class ProxyFactoryBean implements FactoryBean<Object> {

    private AopProxyFactory aopProxyFactory = new DefaultAopProxyFactory();

    /**
     * 真实业务对象,例如 Action1。
     */
    private Object target;

    /**
     * 缓存生成好的代理对象。
     */
    private Object singletonInstance;

    public void setAopProxyFactory(AopProxyFactory aopProxyFactory) {
        if (aopProxyFactory == null) {
            throw new IllegalArgumentException("AopProxyFactory must not be null");
        }
        this.aopProxyFactory = aopProxyFactory;
    }

    public Object getTarget() {
        return this.target;
    }

    public void setTarget(Object target) {
        this.target = target;
    }

    @Override
    public Object getObject() {
        return getSingletonInstance();
    }

    @Override
    public Class<?> getObjectType() {
        if (this.target == null) {
            return null;
        }

        Class<?>[] interfaces = this.target.getClass().getInterfaces();
        return interfaces.length > 0 ? interfaces[0] : this.target.getClass();
    }

    private synchronized Object getSingletonInstance() {
        if (this.singletonInstance == null) {
            this.singletonInstance = createAopProxy().getProxy();
        }
        return this.singletonInstance;
    }

    private AopProxy createAopProxy() {
        if (this.target == null) {
            throw new IllegalStateException("Target must be configured");
        }
        return this.aopProxyFactory.createAopProxy(this.target);
    }
}

它的流程是:

text 复制代码
容器创建 ProxyFactoryBean
        ↓
给 target 注入 realaction
        ↓
业务代码 getBean("action")
        ↓
容器发现它是 FactoryBean
        ↓
调用 ProxyFactoryBean.getObject()
        ↓
创建并返回 JDK 动态代理对象

七、抽象 AopProxy

上面已经能通过 JDK 实现动态代理了,但 JDK 动态代理只支持接口。如果以后要支持没有接口的类,就需要换一种代理方式,比如 CGLIB 这类基于子类的代理。

真实 Spring AOP 会根据情况选择不同代理方式。简化到 MiniSpring 里,我们先抽象出两个接口:

java 复制代码
package com.minis.aop;

public interface AopProxy {

    Object getProxy();
}
java 复制代码
package com.minis.aop;

public interface AopProxyFactory {

    AopProxy createAopProxy(Object target);
}

这样 ProxyFactoryBean 不直接依赖 JDK 的 Proxy.newProxyInstance(...),而是依赖 AopProxyFactory

text 复制代码
ProxyFactoryBean
        ↓
AopProxyFactory
        ↓
AopProxy
        ↓
JdkDynamicAopProxy

这一层抽象的意义是:当前只实现 JDK 动态代理,以后如果要加其他代理方式,只需要新增一个 AopProxy 实现。

真实 Spring AOP 的思路也类似,只是完整很多:

text 复制代码
业务 Bean
        ↓
判断是否匹配切点
        ↓
ProxyFactory 创建代理
        ↓
选择 JDK 动态代理或 CGLIB 代理
        ↓
返回代理对象给容器

本节的 MiniSpring 先把重点放在"代理对象怎么生成、怎么交给容器"。所以增强逻辑暂时写死在 doAction 上。真实 Spring 不会这样硬编码方法名,而是用切点规则判断哪些方法需要增强,再把日志、事务等逻辑组织成拦截器链。

八、JdkDynamicAopProxy:JDK 代理实现

下面是 JDK 动态代理的封装。注意 InvocationHandler 是 JDK 提供的接口,核心方法是:

java 复制代码
Object invoke(Object proxy, Method method, Object[] args) throws Throwable;

因为 JdkDynamicAopProxy 实现了 InvocationHandler,所以 Proxy.newProxyInstance(..., this) 里的 this,指的就是当前这个方法调用处理器。以后代理对象上的方法被调用,都会进入这里的 invoke(...)

java 复制代码
package com.minis.aop;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

/**
 * 基于 JDK Proxy 的 AopProxy 实现。
 */
public class JdkDynamicAopProxy implements AopProxy, InvocationHandler {

    private final Object target;

    public JdkDynamicAopProxy(Object target) {
        if (target == null) {
            throw new IllegalArgumentException("Target must not be null");
        }
        this.target = target;
    }

    @Override
    public Object getProxy() {
        Class<?>[] interfaces = this.target.getClass().getInterfaces();
        if (interfaces.length == 0) {
            throw new IllegalStateException(
                    "JDK dynamic proxy requires at least one interface");
        }

        return Proxy.newProxyInstance(
                this.target.getClass().getClassLoader(),
                interfaces,
                this);
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args)
            throws Throwable {
        if ("doAction".equals(method.getName())) {
            System.out.println(
                    "before call real object, dynamic proxy");
        }

        return invokeTarget(method, args);
    }

    private Object invokeTarget(Method method, Object[] args) throws Throwable {
        try {
            return method.invoke(this.target, args);
        } catch (InvocationTargetException ex) {
            throw ex.getTargetException();
        }
    }
}

默认工厂先直接返回 JDK 代理:

java 复制代码
package com.minis.aop;

public class DefaultAopProxyFactory implements AopProxyFactory {

    @Override
    public AopProxy createAopProxy(Object target) {
        return new JdkDynamicAopProxy(target);
    }
}

到这里,MiniSpring 的代理对象创建就被拆成了几个清楚的角色:

职责
ProxyFactoryBean 作为容器里的 Bean,负责对外生产代理对象
AopProxyFactory 决定使用哪种代理实现
AopProxy 屏蔽具体代理技术
JdkDynamicAopProxy 使用 JDK Proxy 创建代理对象,并处理方法调用

九、最终使用方式

配置仍然是:

xml 复制代码
<bean id="realaction" class="com.test.service.Action1"/>

<bean id="action" class="com.minis.aop.ProxyFactoryBean">
    <property type="java.lang.Object" name="target" ref="realaction"/>
</bean>

Controller 里不再手动 new DynamicProxy

java 复制代码
package com.test.controller;

import com.minis.beans.factory.Autowired;
import com.minis.web.RequestMapping;
import com.test.service.IAction;

import java.io.IOException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class HelloWorldBean {

    /**
     * 字段名 action 对应 XML 中 id="action" 的 Bean。
     * 这个 Bean 是 ProxyFactoryBean,所以最终注入的是代理对象。
     */
    @Autowired
    private IAction action;

    @RequestMapping("/testaop")
    public void doTestAop(HttpServletRequest request,
                          HttpServletResponse response) {
        this.action.doAction();

        try {
            response.getWriter().write("test aop, hello world!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

最终调用链是:

text 复制代码
浏览器请求 /testaop
        ↓
HelloWorldBean.doTestAop(...)
        ↓
action.doAction()
        ↓
action 实际是 JDK 动态代理对象
        ↓
JdkDynamicAopProxy.invoke(...)
        ↓
执行 before 增强逻辑
        ↓
method.invoke(target, args)
        ↓
Action1.doAction()

业务代码看到的还是普通接口:

java 复制代码
this.action.doAction();

但运行时真正被调用的是代理对象,所以通用逻辑就可以插入到业务方法前面。

十、三层实现思路总结

这一节其实是按三层递进来讲动态代理的。

第一层,最简单的动态代理实现,是新建一个 DynamicProxy 类。

text 复制代码
真实对象 target
        ↓ 注入
DynamicProxy
        ↓ getProxy()
JDK Proxy 生成的代理对象
        ↓ 调用方法
InvocationHandler.invoke(...)
        ↓
method.invoke(target, args)

这一层只解决一个问题:怎么用 JDK 动态代理在运行时生成代理对象。核心代码就是 Proxy.newProxyInstance(...),核心入口就是 getProxy()

第二层,为了让业务代码不被代理细节侵入,引入 FactoryBean

text 复制代码
XML 已经配置好:
realaction -> 真实对象
action     -> ProxyFactoryBean

业务代码:
@Autowired
private IAction action;

容器 getBean("action")
        ↓
发现 action 是 FactoryBean
        ↓
调用 ProxyFactoryBean.getObject()
        ↓
返回代理对象,而不是返回 ProxyFactoryBean 本身

这一步的关键不是"怎样生成代理",而是"怎样让业务代码无感拿到代理"。是否需要代理,早就在 XML 里通过 action -> ProxyFactoryBean 配好了;getBean(...) 只是在返回对象前判断一下当前 Bean 是否是 FactoryBean。如果是,就调用 getObject() 拿产品对象;如果不是,就返回普通 Bean。

第三层,是向 Spring AOP 靠近的抽象。

text 复制代码
ProxyFactoryBean
        |
        | createAopProxy(target)
        v
AopProxyFactory
        |
        | 决定使用哪种代理技术
        v
AopProxy
        |
        | getProxy()
        v
JdkDynamicAopProxy
        |
        | Proxy.newProxyInstance(..., this)
        v
JDK 代理对象
        |
        | 方法调用
        v
InvocationHandler.invoke(...)

AopProxy 用来屏蔽具体代理技术,它只暴露 getProxy(),表示"给我一个代理对象"。AopProxyFactory 用来决定创建哪一种 AopProxy,当前默认创建的是 JdkDynamicAopProxy

invoke(...) 不是 AopProxy 接口的方法,而是 JDK 的 InvocationHandler 方法。JdkDynamicAopProxy 同时实现了 AopProxyInvocationHandler,所以它既能通过 getProxy() 创建代理对象,也能在代理对象的方法被调用时进入 invoke(...) 执行增强逻辑。

简单收一下:

层次 解决的问题 核心类/方法
直接使用 JDK 动态代理 运行时生成代理对象 DynamicProxy#getProxy()Proxy.newProxyInstance(...)
接入容器 业务代码无感拿到代理对象 FactoryBean#getObject()ProxyFactoryBeangetBean(...) 判断 FactoryBean
AOP 抽象 屏蔽具体代理技术,方便以后扩展 AopProxy#getProxy()AopProxyFactoryJdkDynamicAopProxy#invoke(...)

十一、学完后要能回答的问题

第一,什么是代理?

代理就是不直接访问真实对象,而是通过代理对象间接访问真实对象。代理对象和真实对象通常实现同一个接口,因此调用方可以面向接口编程。代理对象可以在调用真实对象前后增加日志、事务、权限检查等通用逻辑。

第二,什么是静态代理?

静态代理是提前手写代理类。它清晰、好理解,但每个需要增强的业务类都可能要配一个代理类,维护成本很高。

第三,什么是动态代理?

动态代理是不提前写代理类,而是在程序运行期间生成代理对象。JDK 动态代理需要目标类实现接口,并通过 InvocationHandler.invoke(...) 统一拦截方法调用。

第四,MiniSpring 为什么要引入 FactoryBean

因为我们不希望业务代码手动 new 代理对象。FactoryBean 可以让 ProxyFactoryBean 留在容器内部,而业务代码通过 getBean("action")@Autowired 拿到它生产出来的代理对象。

第五,AopProxy 抽象解决了什么问题?

它把"生产代理对象"这件事抽象出来。当前实现是 JdkDynamicAopProxy,以后如果要支持其他代理技术,可以新增实现,而不需要改业务代码和 ProxyFactoryBean 的主流程。

这一节可以总结成一句话:

动态代理让我们可以在运行时生成代理对象,再用 FactoryBean 把这个代理对象交给容器管理,业务代码保持 action.doAction() 不变,通用逻辑却已经被插入到了调用链中。

相关推荐
小蓝波2 小时前
docker 的命令
java·docker·容器
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题 第155题】【06_Spring篇】第15题:Spring 如何解决循环依赖问题?
java·spring·循环依赖·三级缓存·aop代理
zwenqiyu2 小时前
非线性字符串数据结构串讲
数据结构·c++·学习·算法
唐青枫2 小时前
Java Maven 实战指南:从 POM、依赖管理到多模块构建
java
硕风和炜2 小时前
【LeetCode: 1301. 最大得分的路径数目 + DP】
java·算法·leetcode·动态规划·dp·记忆化搜索
SamDeepThinking3 小时前
面向对象真正管理的,不是对象,而是依赖
java·后端·程序员
MartinYeung53 小时前
[论文学习]SecureGate:通过令牌级门控学习何时安全地揭示PII-深度解析
学习·安全
剑挑星河月3 小时前
94.二叉树的中序遍历
java·算法·leetcode
2301_801184753 小时前
docker 工作
java·docker·容器
老前端的功夫4 小时前
【Java从入门到入土】47:构建工具:Maven与Gradle的战争
java·python·maven