手写简易版 Spring 框架

目录

1 编写一个简易版 SpringMVC 框架

1.1 回顾反射

1.2 目的和实现

2 开发流程

2.1 DispatcherServlet 创建和配置

2.2 重写 init

2.3 加载配置项

2.4 递归获取类路径

2.5 扫描 Bean

2.6 扫描依赖,注入依赖

2.7 请求分配处理

3 测试

4 总结

spring

springMVC


码字不易,喜欢就点个关注❤,持续更新技术内容。

完整的源码包请私信。

1 编写一个简易版 SpringMVC 框架

1.1 回顾反射

编写一个简易版 Spring 框架首先我们需要温习一下反射的知识。我们可以通过三种方式获取到反射的基石——Class 类对象,也就是字节码文件的对象,字节码文件对象就是由编译好的字节码文件加载了 JVM 的对象。通过类对象我们可以获取到它的所有信息,包括构造器,成员变量、方法等。

// 1.通过源代码阶段的,A.class 文件获取
Class clazz1 = Class.forName("全限定类名");
// 2.通过加载阶段:一般用于当作参数,像锁对象就是一个类对象。
Class clazz2 = A.class;
// 3.通过运行阶段
A a = new A();
Class clazz3 = a.getClass();

那接下来给你一个如下的类,如何通过反射获取类上的注解,以及里面的所有成员属性及其注解、成员方法呢?

@MyController
public class TestController {
    @MyAutowired
    private TestService testService;
​
    @MyGetMapping("/query")
    public void query(HttpServletRequest request, HttpServletResponse response, @MyRequestParam("name") String name) throws IOException {
        String result = testService.queryName(name);
​
        response.setContentType("text/html;charset=utf-8");
        response.getWriter().write("查询结果: " + result);
    }
}

如图,我们首先复制它的相对路径,因为编译后字节码文件也在同一路径下。

反射代码过程:

public class test01 {
    public static void main(String[] args) throws ClassNotFoundException {
​
        // 反射的基石:字节码文件对象
        Class<?> clazz = Class.forName("com.bree.controller.TestController");
​
        // 1.获取类上的注解
        // 1)获取类上所有注解
        Annotation[] declaredAnnotations = clazz.getDeclaredAnnotations();
        System.out.println("获取类上的所有注解:"+Arrays.toString(declaredAnnotations));
        // 2)或者指定获取类上的某个注解
        MyController annotation = clazz.getAnnotation(MyController.class);
        System.out.println("获取类上指定注解:"+annotation.annotationType().getSimpleName());
        // 3)获取注解中的属性值
        String value = annotation.value();
        System.out.println("获取类上指定注解中的值:"+value);
​
        System.out.println("=====================================================");
​
        // 2.获取类中所有属性对象,并获取属性成员上的注解。注意getFields和getDeclaredFields的区别
        Field[] fields = clazz.getDeclaredFields();
        for(Field field : fields) {
            // 1)判断属性上是否存在指定注解
            boolean annotationPresent = field.isAnnotationPresent(MyService.class);
            if (annotationPresent) {
                System.out.println(field.getType().getSimpleName()+"属性上存在注解:"+MyService.class.getSimpleName());
            }
            // 2)获取属性对象的类型
            Class<?> type = field.getType();
            System.out.println("该属性的类型为:"+type.getSimpleName());
            // 3)获取该属性类上是否存在指定注解
            MyService annotation1 = type.getAnnotation(MyService.class);
            if (annotation1 != null) {
                System.out.println(type.getSimpleName()+"类上存在注解:"+annotation1.annotationType().getSimpleName());
            }
        }
​
        System.out.println("=====================================================");
​
        // 3.获取类中成员方法对象。注意getMethods和getDeclaredMethods的区别。
        Method[] methods = clazz.getDeclaredMethods();
        Map<String, Object> handlerMappingMap = new HashMap<>();
        for (Method method : methods) {
            // 1)获取方法中的参数,以及参数的注解
            Parameter[] parameters = method.getParameters();
            Annotation[][] parameterAnnotations = method.getParameterAnnotations();
            System.out.println("方法中的参数:"+Arrays.toString(parameters));
            System.out.println("方法中参数的注解:"+Arrays.toString(parameterAnnotations));
​
            // 2)判断方法上的注解并获取其值,将路径与方法映射
            if(!method.isAnnotationPresent(MyGetMapping.class)) {
                continue;
            }
​
            String url = method.getAnnotation(MyGetMapping.class).value();
            if(handlerMappingMap.containsKey(url)) {
                throw new IllegalArgumentException(method.getName() + "上路径重复");
            }
            handlerMappingMap.put(url, method);
        }
​
​
    }
}

输出:

1.2 目的和实现

和 SpringMVC 一样,该简易版的 SpringMVC 框架就是基于 servlet 实现的。没有 servlet 基础的可以看我上面的相关文章。该简易版的 SpringMVC 框架目的就是一个请求进来时能将其准确分配到 controller 中的一个方法进行处理。

过程就是一个请求进来先进入 doGet() 或 doPost() 方法,都去调用 doDispatch() 方法,在方法中获取该请求的请求路径,通过 handlerMappingMap 映射是否存在该路径,因为 handlerMappingMap 中的存储的 key 为接口路径,而 value 为请求处理的方法。获取方法中参数类型进行赋值,最后反射调用 controller 方法。在实现过程中还会细讲。

我们要实现的是只需要在 controller 中定义好相关接口和路径,不需要写很多的 servlet 和配置。当 Web 项目启动时,用户直接就能调用到控制器中的接口方法。和原来的 servlet 相比看起来不是那么直观,不过它本质就是大的 servlet,只是用到了 Java 中非常重要的功能——反射。我们需要通过反射将声明好的这些接口和路径存起来,请求进来时我们就可以进行匹配调用。

实现过程:

  1. 首先需要加载配置文件获取基础包的全限定名

  2. 扫描基础包全限定获取所有类文件的全限定路径名,存入 List 集合中。

  3. 将 List 中所有类路径下的类通过反射创建,放入 Map 集合中,就是所谓的 IOC 容器,Spring 中也是一个 Map 集合。

  4. 依赖注入。将 IOC 容器中的类进行赋值,并且将 controller 中的接口方法跟请求路径进行映射(放入handlerMappingMap 中)。

现在就直接开始上手,最后再来进行总结,看看 SpringMVC 和 Spring 中 IOC DI 都是怎么个事。

2 开发流程

2.1 DispatcherServlet 创建和配置

如下,创建一个 DispatcherServlet 继承 HttpServlet,重写 doGet 和 doPost 方法,它们最终都去调用 doDispatch 方法。这个后面讲。

public class MyDispatcherServlet extends HttpServlet {
​
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doPost(request, response);
    }
​
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        try {
            this.doDispatch(request, response);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

然后在 web.xml 中进行 servlet 的配置:(和普通的 servlet 配置没什么区别,只是多了一个配置文件 application.properties)

<servlet>
    <servlet-name>dispatcherServlet</servlet-name>
    <servlet-class>com.bree.spring.framework.MyDispatcherServlet</servlet-class>
    <init-param>
        <param-name>defaultConfig</param-name>
        <param-value>application.properties</param-value>
    </init-param>
</servlet>
​
<servlet-mapping>
    <servlet-name>dispatcherServlet</servlet-name>
    <url-pattern>/*</url-pattern>
</servlet-mapping>

2.2 重写 init

我们需要在 resources 目录下创建 application.properties 文件,在其中添加基础包的全限定类名配置:

basePackages=com.bree.spring

然后在程序启动时,也就是在运行我们写好的 servlet 时,需要去获取该配置路径然后扫描该路径下的文件。所以我们就使用到了 servlet 中的 init 方法,我们只需要在 MyDispatcherServlet 类中重写 init 方法,程序运行就能自动执行该方法下的程序。

实现如下:

@Override
    public void init(ServletConfig config) throws ServletException {
        //1.加载配置文件
        try {
            // 1.获取 application.properties 文件,读取其中基础包的配置
            doLoadConfig(config.getInitParameter("defaultConfig"));
            System.out.println("加载配置文件成功");
        } catch (Exception e) {
            System.out.println("加载配置文件失败");
            return;
        }
​
        // 2.扫描基础包下的所有类路径,放入 List 中
        doScanPacakge(myConfig.getBasePackages());
        System.out.println("类路径扫描完成");
​
        // 3.将扫描到的类进行初始化,并存放到IOC容器
        doInitializedClass();
        System.out.println("自动装配完成");
​
        // 4.依赖注入
        doDependencyInjection();
        System.out.println("依赖注入完成");
        
        System.out.println("DispatchServlet 初始化完成");
    }

首先大体解释以下初始化完成的内容:(和前面说的实现过程一样)

  1. 首先需要加载配置文件获取基础包的全限定名

  2. 扫描基础包全限定获取所有类文件的全限定路径名,存入 List 集合中。

  3. 将 List 中所有类路径下的类通过反射创建,放入 Map 集合中,就是所谓的 IOC 容器,Spring 中也是一个 Map 集合。

  4. 依赖注入。将 IOC 容器中的类进行赋值,并且将 controller 中的接口方法跟请求路径进行映射(放入handlerMappingMap 中)。

接下来就是具体开发过程。

2.3 加载配置项

首先来看第一步 doLoadConfig 方法:

// 将配置信息读取到 config 对象中
private void doLoadConfig(String configPath) {
    // 1.通过输入流加载配置文件
    InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(configPath);
    Properties properties = new Properties();
    try {
        properties.load(inputStream);
    } catch (IOException e) {
        e.printStackTrace();
        System.out.println("配置文件加载失败");
    }
    // 2.获取配置文件中的配置项,并通过反射获取 config 中属性k的属性对象,并为成员对象设置属性,即配置项中值v。
    properties.forEach((k, v) -> {
        try {
            Field[] declaredFields = myConfig.getClass().getDeclaredFields();
            for (Field f : declaredFields) {
                System.out.println(f.getName());
            }
            Field field = myConfig.getClass().getDeclaredField((String) k);
            field.setAccessible(true);
            field.set(myConfig, v);
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("初始化配置类失败");
            return;
        }
    });
}

2.4 递归获取类路径

获取到基础包路径的配置信息后,就可以调用 doScanPacakge 方法扫描路径下的所有类,然后将类路径放入 classNameList中,这部分涉及到文件的递归扫描,将所有类文件的路径插入到 List 集合中:

private void doScanPacakge(String basePackages) {
    if (StringUtils.isBlank(basePackages)) {
        return;
    }
    // 把包名的.替换为/
    String scanPath = "/" + basePackages.replaceAll("\\.", "/");
    // 获取到当前包所在磁盘的全路径
    URL url = this.getClass().getClassLoader().getResource(scanPath);
    // 获取当前路径下所有文件
    File files = new File(url.getFile());
    File[] listFiles = files.listFiles(); // 注意,tomcat所在目录千万不能带空格
    if (listFiles != null) {
        // 开始扫描路径下的所有文件
        for (File file : files.listFiles()) {
            // 开始扫描路径下的所有文件
            if (file.isDirectory()) {
                doScanPacakge(basePackages + "." + file.getName());
            } else {
                // 如果是文件则添加到集合。因为上面是通过类加载器获取到的文件路径,所以实际上是class文件所在路径
                classNameList.add(basePackages + "." + file.getName().replace(".class", ""));
            }
        }
    }
​
}

2.5 扫描 Bean

继续往下,在 doInitializedClass 方法中遍历 classNameList 中的类路径,获取到类对象,然后判断该类上是否有注解,比如像 @Controller @Service 这种注解,然后判断注解是否设置了别名,否则使用 getSimpleName() 后转为首字母小写,作为 key,反射获取构造器创建对象作为 value,然后插入到 Map 中,也就是 IOC 容器。

// 实例化要交给 IOC 创建的所有类,也就是有 `@Component` `@Controller` `@Service` 这些注解的类,将它们放入 IOC 容器中,也就是一个 ContainerMap。
private void doInitializedClass() {
    if (classNameList.isEmpty()) {
        return;
    }
    for (String className : classNameList) {
        if (StringUtils.isEmpty(className)) {
            continue;
        }
        Class clazz;
        try {
            // 获取字节码文件对象,判断为 Bean 后反射创建存入 Map 容器中
            clazz = Class.forName(className);
            if (clazz.isAnnotationPresent(MyController.class)) {
                String value = ((MyController) clazz.getAnnotation(MyController.class)).value();
                // 如果直接指定了value则取value,否则取首字母小写类名作为key值存储类的实例对象
                iocContainerMap.put(StringUtils.isBlank(value) ? StringUtils.uncapitalize(clazz.getSimpleName()) : value, clazz.getDeclaredConstructor().newInstance());
            } else if (clazz.isAnnotationPresent(MyService.class)) {
                String value = ((MyService) clazz.getAnnotation(MyService.class)).value();
                iocContainerMap.put(StringUtils.isBlank(value) ? StringUtils.uncapitalize(clazz.getSimpleName()) : value, clazz.getDeclaredConstructor().newInstance());
            } else if (clazz.isAnnotationPresent(MyMapper.class)) {
                String value = ((MyMapper) clazz.getAnnotation(MyMapper.class)).value();
                iocContainerMap.put(StringUtils.isBlank(value) ? StringUtils.uncapitalize(clazz.getSimpleName()) : value, clazz.getDeclaredConstructor().newInstance());
            } else {
                System.out.println("不考虑其他注解的情况");
            }
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("初始化类失败,className为" + className);
        }
    }
​
}

2.6 扫描依赖,注入依赖

最后最重要的一步,依赖注入,扫描所有类反射获取字节码对象,判断其中有没有注入注解,也就是我们使用的 @Autowired @Resource,我们从容器中获取对应的对象赋值给这些注解修饰的成员变量。

doDependencyInjection 方法实现注入的过程如下:

// Container 中存放着某某 service,某某 controller。其中就声明了带着注入注解的类属性,也就是依赖的类。
// 比如开发中 service 就常常注入 mapper 代理对象。controller 中就常常注入业务逻辑处理类
// 
private void doDependencyInjection() {
    if (iocContainerMap.size() == 0) {
        return;
    }
    // 获取 Container 中键值对set集合的迭代器
    Iterator<Map.Entry<String, Object>> iterator = iocContainerMap.entrySet().iterator();
​
    // 遍历每个 Bean,其中可能有所依赖的类
    while (iterator.hasNext()) {
        // 首先获取所有属性对象
        Map.Entry<String, Object> entry = iterator.next();
        Class<?> clazz = entry.getValue().getClass();
        Field[] fields = clazz.getDeclaredFields();
​
        // 遍历当前类中每一个属性
        for (Field field : fields) {
            // 1.判断该属性对象有 MyAutowired 注解,且该类型的类在定义时由一个Bean注解修饰,也就是该对象已经通过 IOC 创建放到 Container中的(暂时不考虑其他注解)
            if ( field.isAnnotationPresent(MyAutowired.class) && 
                ( field.getType().isAnnotationPresent(MyService.class)||
                  field.getType().isAnnotationPresent(MyMapper.class) 
                ) 
               ) {
                // 2.获取 Bean 的名字
                // 默认bean的value为类名首字母小写
                String simpleName = StringUtils.uncapitalize(field.getType().getSimpleName());
                // 如果依赖对象中的Bean注解中设置了别名,key 就是别名。
                if(field.getType().isAnnotationPresent(MyService.class)) {
                    String simpleName1 = field.getType().getAnnotation(MyService.class).value();
                    if (!Objects.equals(simpleName1, "")) {
                        simpleName = simpleName1;
                    }
                }
​
                if(field.getType().isAnnotationPresent(MyMapper.class)) {
                    String simpleName2 = field.getType().getAnnotation(MyMapper.class).value();
                    if (!Objects.equals(simpleName2, "")) {
                        simpleName = simpleName2;
                    }
                }
​
                // 通过别名从 Container 中获取对应创建好的对象,然后赋值当前类的属性。
                field.setAccessible(true);
                try {
                    // 3.将ioc中对应的对象赋值给该属性对象。所以说所谓的注入不过是使用反射进行赋值。
                    field.set(entry.getValue(), iocContainerMap.get(simpleName));
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
        }
​
        // Controller 类还需要进一步处理,因为其中声明了很多接口方法,每一个接口都对应一个 HandlerMapping
        // 初始化 HandlerMapping,以键为请求路由,值为 HandlerMapping 存入 handlerMappingMap。
        String requestUrl = "";
        // 1.获取 Controller 类上的请求路径
        if (clazz.isAnnotationPresent(MyController.class)) {
            requestUrl = clazz.getAnnotation(MyController.class).value();
        }
​
        // 2.循环类中的接口方法,获取方法上的路径,将它们拼接一起就是一个完整的接口路径
        // 就是要做两件事,获取接口方法的请求路由为键,初始化 handlerMapping 为值存入 handlerMappingMap 中
        Method[] methods = clazz.getMethods();
        for (Method method : methods) {
            // 假设只有 WolfGetMapping 这一种注解
            if (!method.isAnnotationPresent(MyGetMapping.class)) {
                continue;
            }
            MyGetMapping wolfGetMapping = method.getDeclaredAnnotation(MyGetMapping.class);
            requestUrl = requestUrl + "/" + wolfGetMapping.value(); // 拼成完成的请求路径
​
            // 不考虑正则匹配路径/xx/* 的情况,只考虑完全匹配的情况
            if (handlerMappingMap.containsKey(requestUrl)) {
                System.out.println("重复路径");
                continue;
            }
​
            // 3.获取接口方法中参数注解中的参数名称,存入参数集合中
            Annotation[][] annotationArr = method.getParameterAnnotations();
​
            Map<Integer, String> methodParam = new HashMap<>(); // 存储参数的顺序和参数名
            retryParam:
            for (int i = 0; i < annotationArr.length; i++) {
                for (Annotation annotation : annotationArr[i]) {
                    if (annotation instanceof MyRequestParam) {
                        MyRequestParam wolfRequestParam = (MyRequestParam) annotation;
                        methodParam.put(i, wolfRequestParam.value()); // 存储参数的位置和注解中定义的参数名
                        continue retryParam;
                    }
                }
            }
​
            // 4.最后每个接口方法都生成一个 HandlerMapping,以请求路径为键存入 handlerMappingMap 中
            requestUrl = this.formatUrl(requestUrl); // 主要是防止路径多了/导致路径匹配不上
            HandlerMapping handlerMapping = new HandlerMapping();
            handlerMapping.setRequestUrl(requestUrl); //请求路径
            handlerMapping.setMethod(method); // 请求方法
            handlerMapping.setTarget(entry.getValue()); // 请求方法所在controller对象
            handlerMapping.setMethodParams(methodParam); // 请求方法的参数信息
            handlerMappingMap.put(requestUrl, handlerMapping); // 存入 hashmap
        }
    }
}

以上就是在程序启动时所有需要准备的事情。接下来就是一个请求过来,是怎么将该区请求分配给对应的接口进行处理的。

2.7 请求分配处理

这部分比较简单,我们以及准备好了 handlerMappingMap,只需根据请求路径查询对应的 handlerMapping,然后就可以直接反射调用接口方法,因为 handlerMapping 中存的有对应的接口方法对象,其中定义执行相关功能的程序,然后进行结果的响应。

private void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    // 对url的纠正
    String requestUrl = this.formatUrl(request.getRequestURI());
    // 1.首先查询该请求路径有没有对应的 handlerMapping
    HandlerMapping handlerMapping = handlerMappingMap.get(requestUrl);
    if (null == handlerMapping) {
        response.getWriter().write("404 Not Found");
        return;
    }
​
    // 2.参数处理
    // 获取接口方法中的参数类型
    Class<?>[] paramTypeArr = handlerMapping.getMethod().getParameterTypes();
    Object[] paramArr = new Object[paramTypeArr.length];
    // 遍历每个参数类型
    for (int i = 0; i < paramTypeArr.length; i++) {
        Class<?> clazz = paramTypeArr[i];
        // 参数只考虑三种类型,其他不考虑
        if (clazz == HttpServletRequest.class) {
            paramArr[i] = request;
        } else if (clazz == HttpServletResponse.class) {
            paramArr[i] = response;
        } else if (clazz == String.class) {
            Map<Integer, String> methodParam = handlerMapping.getMethodParams();
            paramArr[i] = request.getParameter(methodParam.get(i)); // 比如通过第一参数的名称获取到该参数的值
        } else {
            System.out.println("暂不支持的参数类型");
        }
    }
    
    // 3.反射调用controller方法
    handlerMapping.getMethod().invoke(handlerMapping.getTarget(), paramArr);
}
​
private String formatUrl(String requestUrl) {
    requestUrl = requestUrl.replaceAll("/+", "/");
    if (requestUrl.lastIndexOf("/") == requestUrl.length() - 1) {
        requestUrl = requestUrl.substring(0, requestUrl.length() - 1);
    }
    return requestUrl;
}

3 测试

首先定义一个控制器,用自定义的 @MyController 标识为控制器,相当于声明了一个特殊的 Bean。在其中定义一个查询接口,该接口去调用 TestService 的 queryName 方法,该方法接收参数查询后返回结果。

@MyController
public class TestController {
    @MyAutowired
    private TestService testService;
​
    @MyGetMapping("/query")
    public void query(HttpServletRequest request, HttpServletResponse response, @MyRequestParam("name") String name) throws IOException {
        String result = testService.queryName(name);
​
        response.setContentType("text/html;charset=utf-8");
        response.getWriter().write("查询结果: " + result);
    }
}

在 TestService 中我们又去调用 TestMapper 进行真正的数据库查询操作。

@MyService(value = "queryService")//为了演示能否正常取value属性
public class TestService {
​
    @MyAutowired
    private TestMapper testMapper;
​
    public String queryName(String name) {
        return testMapper.queryUserName(name);
    }
}

当然这里做个简单的判断就行了。

@MyMapper
public class TestMapper {
    public String queryUserName(String name) {
        if(Objects.equals(name, "Max")) {
            return name;
        }
        return "无该用户";
    }
}

我们主要是关注注入的这些依赖是否成功生效。运行起来。首先打印日志显示成功注入。

我们发送请求试试,首先不带参数查询。未查询到。

再带一个有参数,但是不符合条件的参数查询,查无此人。

最后我们查询存在的用户,成功查询。

4 总结

所以这个简易的 Spring 和 SpringMVC 可以学到什么呢?我想通过去背一些专业术语和概念是不能够真正理解其奥妙的,那些庞杂的框架一口吞不下,需要抽丝剥茧地去理解。经过这样抽丝剥茧的学习,当再去面对 Spring 中庞杂的 BeanFactory 继承体系时就不会迷失方向了。

spring

首先 Spring,不严谨的说 Spring 就是一个容器 (在实际的 Spring 中有两个容器)。我们通过将反射每个声明的 Bean 初始化,并装配到容器中,然后由通过反射完成依赖的赋值。也就是 Spring 涉及到两次主要的反射过程,一次是反射创建,一次是反射赋值。

所以 Spring 真正给我们带来的是什么呢,就是简单的不用自己实例化?其实就是这么简单,但是其作用非常大,除了规范的开发流程以外,Spring "不用自己实例化" 的作用让它成为了许多中间件的港湾,使得很多框架可以很容易地整合到 Spring,开袋即食。

开发过程就是面向反射开发以及面向注解开发。核心是 IocContainerMap。IocContainer 存储了可以注入使用的对象,所以我们通过 IocContainerMap 来总结。

一句话总结就是程序运行起来通过扫描所有类,将加了Bean注解的类通过反射将它们自动装配到 IocContainerMap 集合中,依赖注入紧跟其后,在注入依赖的时候需要去扫一遍 IocContainerMap 集合判断类中是否有注入注解获取到属性对象,然后通过反射进行赋值。

springMVC

而 MVC 部分,核心思想就是通过一个大 Servlet 拦截所有请求,然后它通过路径匹配之后,反射调用对应的接口方法处理。本质上每个接口还是一个 Servlet。

我们只实现控制器部分,MVC 是很早就有的概念了,现在流行的分离式开发其实是对 V 的精细化。现在后端只需要关注接收数据进行业务逻辑的处理,也就是我们现在的 Java 后端工程大部分就是基于 Spring 的 REST 服务。RESTful 接口开发本质是 HTTP 的请求响应模型。

  • 23
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Maxlec

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值