SpringApplication对象是如何构建的? SpringBoot源码(八)

简介: SpringApplication对象是如何构建的? SpringBoot源码(八)

注:该源码分析对应SpringBoot版本为2.1.0.RELEASE

本篇接 SpringBoot的启动流程是怎样的?SpringBoot源码(七)

1 温故而知新

温故而知新,我们来简单回顾一下上篇的内容,上一篇我们分析了SpringBoot的启动流程,现将关键步骤再浓缩总结下:

  1. 构建SpringApplication对象,用于启动SpringBoot;
  2. spring.factories配置文件中加载EventPublishingRunListener对象用于在不同的启动阶段发射不同的生命周期事件;
  3. 准备环境变量,包括系统变量,环境变量,命令行参数及配置文件(比如application.properties)等;
  4. 创建容器ApplicationContext;
  5. 为第4步创建的容器对象做一些初始化工作,准备一些容器属性值等,同时调用各个ApplicationContextInitializer的初始化方法来执行一些初始化逻辑等;
  6. 刷新容器,这一步至关重要,是重点中的重点,太多复杂逻辑在这里实现;
  7. 调用ApplicationRunnerCommandLineRunner的run方法,可以实现这两个接口在容器启动后来加载一些业务数据等;

在SpringBoot启动过程中,每个不同的启动阶段会分别发射不同的内置生命周期事件,然后相应的监听器会监听这些事件来执行一些初始化逻辑工作比如ConfigFileApplicationListener会监听onApplicationEnvironmentPreparedEvent事件来加载环境变量等。

2 引言

上篇文章在讲解SpringBoot的启动流程中,我们有看到新建了一个SpringApplication对象用来启动SpringBoot项目。那么,我们今天就来看看SpringApplication对象的构建过程,同时讲解一下SpringBoot自己实现的SPI机制。

3 SpringApplication对象的构建过程

本小节开始讲解SpringApplication对象的构造过程,因为一个对象的构造无非就是在其构造函数里给它的一些成员属性赋值,很少包含其他额外的业务逻辑(当然有时候我们可能也会在构造函数里开启一些线程啥的)。那么,我们先来看下构造SpringApplication对象时需要用到的一些成员属性哈:

// SpringApplication.java

/**
 * SpringBoot的启动类即包含main函数的主类
 */
private Set<Class<?>> primarySources;
/**
 * 包含main函数的主类
 */
private Class<?> mainApplicationClass;
/**
 * 资源加载器
 */
private ResourceLoader resourceLoader;
/**
 * 应用类型
 */
private WebApplicationType webApplicationType;
/**
 * 初始化器
 */
private List<ApplicationContextInitializer<?>> initializers;
/**
 * 监听器
 */
private List<ApplicationListener<?>> listeners;

可以看到构建SpringApplication对象时主要是给上面代码中的六个成员属性赋值,现在我接着来看SpringApplication对象的构造过程。

我们先回到上一篇文章讲解的构建SpringApplication对象的代码处:

// SpringApplication.java

// run方法是一个静态方法,用于启动SpringBoot
public static ConfigurableApplicationContext run(Class<?>[] primarySources,
        String[] args) {
    // 构建一个SpringApplication对象,并调用其run方法来启动
    return new SpringApplication(primarySources).run(args);
}

跟进SpringApplication的构造函数中:

// SpringApplication.java

public SpringApplication(Class<?>... primarySources) {
    // 继续调用SpringApplication另一个构造函数
    this(null, primarySources);
}

继续跟进SpringApplication另一个构造函数:

// SpringApplication.java

public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources) {
    // 【1】给resourceLoader属性赋值,注意传入的resourceLoader参数为null
    this.resourceLoader = resourceLoader;
    Assert.notNull(primarySources, "PrimarySources must not be null");
    // 【2】给primarySources属性赋值,传入的primarySources其实就是SpringApplication.run(MainApplication.class, args);中的MainApplication.class
    this.primarySources = new LinkedHashSet<>(Arrays.asList(primarySources));
    // 【3】给webApplicationType属性赋值,根据classpath中存在哪种类型的类来确定是哪种应用类型
    this.webApplicationType = WebApplicationType.deduceFromClasspath();
    // 【4】给initializers属性赋值,利用SpringBoot自定义的SPI从spring.factories中加载ApplicationContextInitializer接口的实现类并赋值给initializers属性
    setInitializers((Collection) getSpringFactoriesInstances(
            ApplicationContextInitializer.class));
    // 【5】给listeners属性赋值,利用SpringBoot自定义的SPI从spring.factories中加载ApplicationListener接口的实现类并赋值给listeners属性
    setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));
    // 【6】给mainApplicationClass属性赋值,即这里要推断哪个类调用了main函数,然后再赋值给mainApplicationClass属性,用于后面启动流程中打印一些日志。
    this.mainApplicationClass = deduceMainApplicationClass();
}

可以看到构建SpringApplication对象时其实就是给前面讲的6个SpringApplication类的成员属性赋值而已,做一些初始化工作:

  1. resourceLoader属性赋值resourceLoader属性,资源加载器,此时传入的resourceLoader参数为null
  2. primarySources属性赋值primarySources属性即SpringApplication.run(MainApplication.class,args);中传入的MainApplication.class,该类为SpringBoot项目的启动类,主要通过该类来扫描Configuration类加载bean
  3. webApplicationType属性赋值webApplicationType属性,代表应用类型,根据classpath存在的相应Application类来判断。因为后面要根据webApplicationType来确定创建哪种Environment对象和创建哪种ApplicationContext,详细分析请见后面的第3.1小节
  4. initializers属性赋值initializers属性为List<ApplicationContextInitializer<?>>集合,利用SpringBoot的SPI机制从spring.factories配置文件中加载,后面在初始化容器的时候会应用这些初始化器来执行一些初始化工作。因为SpringBoot自己实现的SPI机制比较重要,因此独立成一小节来分析,详细分析请见后面的第4小节
  5. listeners属性赋值listeners属性为List<ApplicationListener<?>>集合,同样利用利用SpringBoot的SPI机制从spring.factories配置文件中加载。因为SpringBoot启动过程中会在不同的阶段发射一些事件,所以这些加载的监听器们就是来监听SpringBoot启动过程中的一些生命周期事件的;
  6. mainApplicationClass属性赋值mainApplicationClass属性表示包含main函数的类,即这里要推断哪个类调用了main函数,然后把这个类的全限定名赋值给mainApplicationClass属性,用于后面启动流程中打印一些日志,详细分析见后面的第3.2小节

3.1 推断项目应用类型

我们接着分析构造SpringApplication对象的第【3】WebApplicationType.deduceFromClasspath();这句代码:

// WebApplicationType.java

public enum WebApplicationType {
        // 普通的应用
    NONE,
    // Servlet类型的web应用
    SERVLET,
    // Reactive类型的web应用
    REACTIVE;

    private static final String[] SERVLET_INDICATOR_CLASSES = { "javax.servlet.Servlet",
            "org.springframework.web.context.ConfigurableWebApplicationContext" };
    private static final String WEBMVC_INDICATOR_CLASS = "org.springframework."
            + "web.servlet.DispatcherServlet";
    private static final String WEBFLUX_INDICATOR_CLASS = "org."
            + "springframework.web.reactive.DispatcherHandler";
    private static final String JERSEY_INDICATOR_CLASS = "org.glassfish.jersey.servlet.ServletContainer";
    private static final String SERVLET_APPLICATION_CONTEXT_CLASS = "org.springframework.web.context.WebApplicationContext";
    private static final String REACTIVE_APPLICATION_CONTEXT_CLASS = "org.springframework.boot.web.reactive.context.ReactiveWebApplicationContext";

    static WebApplicationType deduceFromClasspath() {
        // 若classpath中不存在"org.springframework." + "web.servlet.DispatcherServlet"和"org.glassfish.jersey.servlet.ServletContainer"
        // 则返回WebApplicationType.REACTIVE,表明是reactive应用
        if (ClassUtils.isPresent(WEBFLUX_INDICATOR_CLASS, null)
                && !ClassUtils.isPresent(WEBMVC_INDICATOR_CLASS, null)
                && !ClassUtils.isPresent(JERSEY_INDICATOR_CLASS, null)) {
            return WebApplicationType.REACTIVE;
        }
        // 若{ "javax.servlet.Servlet",
        //       "org.springframework.web.context.ConfigurableWebApplicationContext" }
        // 都不存在在classpath,则说明是不是web应用
        for (String className : SERVLET_INDICATOR_CLASSES) {
            if (!ClassUtils.isPresent(className, null)) {
                return WebApplicationType.NONE;
            }
        }
        // 最终返回普通的web应用
        return WebApplicationType.SERVLET;
    }
}

如上代码,根据classpath判断应用类型,即通过反射加载classpath判断指定的标志类存在与否来分别判断是Reactive应用,Servlet类型的web应用还是普通的应用。

3.2 推断哪个类调用了main函数

我们先跳过构造SpringApplication对象的第【4】步和第【5】步,先来分析构造SpringApplication对象的第【6】this.mainApplicationClass = deduceMainApplicationClass();这句代码:

// SpringApplication.java

private Class<?> deduceMainApplicationClass() {
    try {
        // 获取StackTraceElement对象数组stackTrace,StackTraceElement对象存储了调用栈相关信息(比如类名,方法名等)
        StackTraceElement[] stackTrace = new RuntimeException().getStackTrace();
        // 遍历stackTrace数组
        for (StackTraceElement stackTraceElement : stackTrace) {
            // 若stackTraceElement记录的调用方法名等于main
            if ("main".equals(stackTraceElement.getMethodName())) {
                // 那么就返回stackTraceElement记录的类名即包含main函数的类名
                return Class.forName(stackTraceElement.getClassName());
            }
        }
    }
    catch (ClassNotFoundException ex) {
        // Swallow and continue
    }
    return null;
}

可以看到deduceMainApplicationClass方法的主要作用就是从StackTraceElement调用栈数组中获取哪个类调用了main方法,然后再返回赋值给mainApplicationClass属性,然后用于后面启动流程中打印一些日志。

4 SpringBoot的SPI机制原理解读

由于SpringBoot的SPI机制是一个很重要的知识点,因此这里单独一小节来分析。我们都知道,SpringBoot没有使用Java的SPI机制(Java的SPI机制可以看看笔者的Java是如何实现自己的SPI机制的?,真的是干货满满),而是自定义实现了一套自己的SPI机制。SpringBoot利用自定义实现的SPI机制可以加载初始化器实现类,监听器实现类和自动配置类等等。如果我们要添加自动配置类或自定义监听器,那么我们很重要的一步就是在spring.factories中进行配置,然后才会被SpringBoot加载。

好了,那么接下来我们就来重点分析下SpringBoot是如何是实现自己的SPI机制的

这里接第3小节的构造SpringApplication对象的第【4】步和第【5】步代码,因为第【4】步和第【5】步都是利用SpringBoot的SPI机制来加载扩展实现类,因此这里只分析第【4】步的setInitializers((Collection) getSpringFactoriesInstances(ApplicationContextInitializer.class));这句代码,看看getSpringFactoriesInstances方法中SpringBoot是如何实现自己的一套SPI来加载ApplicationContextInitializer初始化器接口的扩展实现类的?

// SpringApplication.java

private <T> Collection<T> getSpringFactoriesInstances(Class<T> type) {
    // 继续调用重载的getSpringFactoriesInstances方法进行加载
    return getSpringFactoriesInstances(type, new Class<?>[] {});
}

继续跟进重载的getSpringFactoriesInstances方法:

// SpringApplication.java

private <T> Collection<T> getSpringFactoriesInstances(Class<T> type,
        Class<?>[] parameterTypes, Object... args) {
    // 【1】获得类加载器
    ClassLoader classLoader = getClassLoader();
    // Use names and ensure unique to protect against duplicates
    // 【2】将接口类型和类加载器作为参数传入loadFactoryNames方法,从spring.factories配置文件中进行加载接口实现类
    Set<String> names = new LinkedHashSet<>(
            SpringFactoriesLoader.loadFactoryNames(type, classLoader));
    // 【3】实例化从spring.factories中加载的接口实现类
    List<T> instances = createSpringFactoriesInstances(type, parameterTypes,
            classLoader, args, names);
    // 【4】进行排序
    AnnotationAwareOrderComparator.sort(instances);
    // 【5】返回加载并实例化好的接口实现类
    return instances;
}

可以看到,SpringBoot自定义实现的SPI机制代码中最重要的是上面代码的【1】,【2】,【3】步,这3步下面分别进行重点分析。

4.1 获得类加载器

还记得Java是如何实现自己的SPI机制的?这篇文章中Java的SPI机制默认是利用线程上下文类加载器去加载扩展类的,那么,SpringBoot自己实现的SPI机制又是利用哪种类加载器去加载spring.factories配置文件中的扩展实现类呢?

我们直接看第【1】步的ClassLoader classLoader = getClassLoader();这句代码,先睹为快:

// SpringApplication.java

public ClassLoader getClassLoader() {
    // 前面在构造SpringApplicaiton对象时,传入的resourceLoader参数是null,因此不会执行if语句里面的逻辑
    if (this.resourceLoader != null) {
        return this.resourceLoader.getClassLoader();
    }
    // 获取默认的类加载器
    return ClassUtils.getDefaultClassLoader();
}

继续跟进getDefaultClassLoader方法:

// ClassUtils.java

public static ClassLoader getDefaultClassLoader() {
    ClassLoader cl = null;
    try {
            // 【重点】获取线程上下文类加载器
        cl = Thread.currentThread().getContextClassLoader();
    }
    catch (Throwable ex) {
        // Cannot access thread context ClassLoader - falling back...
    }
    // 这里的逻辑不会执行
    if (cl == null) {
        // No thread context class loader -> use class loader of this class.
        cl = ClassUtils.class.getClassLoader();
        if (cl == null) {
            // getClassLoader() returning null indicates the bootstrap ClassLoader
            try {
                cl = ClassLoader.getSystemClassLoader();
            }
            catch (Throwable ex) {
                // Cannot access system ClassLoader - oh well, maybe the caller can live with null...
            }
        }
    }
    // 返回刚才获取的线程上下文类加载器
    return cl;
}

可以看到,原来SpringBoot的SPI机制中也是用线程上下文类加载器去加载spring.factories文件中的扩展实现类的!

4.2 加载spring.factories配置文件中的SPI扩展类

我们再来看下第【2】步中的SpringFactoriesLoader.loadFactoryNames(type, classLoader)这句代码是如何加载spring.factories配置文件中的SPI扩展类的?

// SpringFactoriesLoader.java

public static List<String> loadFactoryNames(Class<?> factoryClass, @Nullable ClassLoader classLoader) {
        // factoryClass即SPI接口,比如ApplicationContextInitializer,EnableAutoConfiguration等接口
    String factoryClassName = factoryClass.getName();
    // 【主线,重点关注】继续调用loadSpringFactories方法加载SPI扩展类
    return loadSpringFactories(classLoader).getOrDefault(factoryClassName, Collections.emptyList());
}

继续跟进loadSpringFactories方法:

// SpringFactoriesLoader.java

/**
 * The location to look for factories.
 * <p>Can be present in multiple JAR files.
 */
public static final String FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories";

private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
    // 以classLoader作为键先从缓存中取,若能取到则直接返回
    MultiValueMap<String, String> result = cache.get(classLoader);
    if (result != null) {
        return result;
    }
    // 若缓存中无记录,则去spring.factories配置文件中获取
    try {
        // 这里加载所有jar包中包含"MATF-INF/spring.factories"文件的url路径
        Enumeration<URL> urls = (classLoader != null ?
                classLoader.getResources(FACTORIES_RESOURCE_LOCATION) :
                ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));
        result = new LinkedMultiValueMap<>();
        // 遍历urls路径,将所有spring.factories文件的键值对(key:SPI接口类名 value:SPI扩展类名)
        // 加载放到 result集合中
        while (urls.hasMoreElements()) {
            // 取出一条url
            URL url = urls.nextElement();
            // 将url封装到UrlResource对象中
            UrlResource resource = new UrlResource(url);
            // 利用PropertiesLoaderUtils的loadProperties方法将spring.factories文件键值对内容加载进Properties对象中
            Properties properties = PropertiesLoaderUtils.loadProperties(resource);
            // 遍历刚加载的键值对properties对象
            for (Map.Entry<?, ?> entry : properties.entrySet()) {
                // 取出SPI接口名
                String factoryClassName = ((String) entry.getKey()).trim();
                // 遍历SPI接口名对应的实现类即SPI扩展类
                for (String factoryName : StringUtils.commaDelimitedListToStringArray((String) entry.getValue())) {
                    // SPI接口名作为key,SPI扩展类作为value放入result中
                    result.add(factoryClassName, factoryName.trim());
                }
            }
        }
        // 以classLoader作为key,result作为value放入cache缓存
        cache.put(classLoader, result);
        // 最终返回result对象
        return result;
    }
    catch (IOException ex) {
        throw new IllegalArgumentException("Unable to load factories from location [" +
                FACTORIES_RESOURCE_LOCATION + "]", ex);
    }
}

如上代码,loadSpringFactories方法主要做的事情就是利用之前获取的线程上下文类加载器将classpath中的所有spring.factories配置文件中所有SPI接口的所有扩展实现类给加载出来,然后放入缓存中。注意,这里是一次性加载所有的SPI扩展实现类哈,所以之后根据SPI接口就直接从缓存中获取SPI扩展类了,就不用再次去spring.factories配置文件中获取SPI接口对应的扩展实现类了。比如之后的获取ApplicationListener,FailureAnalyzerEnableAutoConfiguration接口的扩展实现类都直接从缓存中获取即可。

思考1: 这里为啥要一次性从spring.factories配置文件中获取所有的扩展类放入缓存中呢?而不是每次都是根据SPI接口去spring.factories配置文件中获取呢?

思考2: 还记得之前讲的SpringBoot的自动配置源码时提到的AutoConfigurationImportFilter这个接口的作用吗?现在我们应该能更清楚的理解这个接口的作用了吧。

将所有的SPI扩展实现类加载出来后,此时再调用getOrDefault(factoryClassName, Collections.emptyList())方法根据SPI接口名去筛选当前对应的扩展实现类,比如这里传入的factoryClassName参数名为ApplicationContextInitializer接口,那么这个接口将会作为key从刚才缓存数据中取出ApplicationContextInitializer接口对应的SPI扩展实现类。其中从spring.factories中获取的ApplicationContextInitializer接口对应的所有SPI扩展实现类如下图所示:

4.3 实例化从spring.factories中加载的SPI扩展类

前面从spring.factories中获取到ApplicationContextInitializer接口对应的所有SPI扩展实现类后,此时会将这些SPI扩展类进行实例化。

此时我们再来看下前面的第【3】步的实例化代码:
`List instances = createSpringFactoriesInstances(type, parameterTypes,

            classLoader, args, names);`。
// SpringApplication.java

private <T> List<T> createSpringFactoriesInstances(Class<T> type,
        Class<?>[] parameterTypes, ClassLoader classLoader, Object[] args,
        Set<String> names) {
    // 新建instances集合,用于存储稍后实例化后的SPI扩展类对象
    List<T> instances = new ArrayList<>(names.size());
    // 遍历name集合,names集合存储了所有SPI扩展类的全限定名
    for (String name : names) {
        try {
            // 根据全限定名利用反射加载类
            Class<?> instanceClass = ClassUtils.forName(name, classLoader);
            // 断言刚才加载的SPI扩展类是否属于SPI接口类型
            Assert.isAssignable(type, instanceClass);
            // 获得SPI扩展类的构造器
            Constructor<?> constructor = instanceClass
                    .getDeclaredConstructor(parameterTypes);
            // 实例化SPI扩展类
            T instance = (T) BeanUtils.instantiateClass(constructor, args);
            // 添加进instances集合
            instances.add(instance);
        }
        catch (Throwable ex) {
            throw new IllegalArgumentException(
                    "Cannot instantiate " + type + " : " + name, ex);
        }
    }
    // 返回
    return instances;
}

上面代码很简单,主要做的事情就是实例化SPI扩展类。
好了,SpringBoot自定义的SPI机制就已经分析完了。

思考3: SpringBoot为何弃用Java的SPI而自定义了一套SPI?

5 小结

好了,本片就到此结束了,先将前面的知识点再总结下:

  1. 分析了SpringApplication对象的构造过程;
  2. 分析了SpringBoot自己实现的一套SPI机制。

6 有感而发

从自己2月开始写源码分析文章以来,也认识了一些技术大牛,从他们身上看到,越厉害的人越努力。回想一下,自己现在知识面也很窄,更重要的是对自己所涉及的技术没有深度,一句话概括,还很菜,而看到比自己厉害的大牛们都还那么拼,自己有啥理由不努力呢?很喜欢丁威老师的一句话:"唯有坚持不懈"。然后自己一步一个脚印,相信自己能取得更大的进步,继续加油。

点赞和转发是对笔者最大的激励哦!

由于笔者水平有限,若文中有错误还请指出,谢谢。

相关文章
|
1月前
|
数据采集 监控 前端开发
二级公立医院绩效考核系统源码,B/S架构,前后端分别基于Spring Boot和Avue框架
医院绩效管理系统通过与HIS系统的无缝对接,实现数据网络化采集、评价结果透明化管理及奖金分配自动化生成。系统涵盖科室和个人绩效考核、医疗质量考核、数据采集、绩效工资核算、收支核算、工作量统计、单项奖惩等功能,提升绩效评估的全面性、准确性和公正性。技术栈采用B/S架构,前后端分别基于Spring Boot和Avue框架。
|
1月前
|
JavaScript 安全 Java
如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个具有动态路由和菜单功能的前后端分离应用。
本文介绍了如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个具有动态路由和菜单功能的前后端分离应用。首先,创建并配置 Spring Boot 项目,实现后端 API;然后,使用 Ant Design Pro Vue 创建前端项目,配置动态路由和菜单。通过具体案例,展示了如何快速搭建高效、易维护的项目框架。
117 62
|
10天前
|
存储 JavaScript 前端开发
基于 SpringBoot 和 Vue 开发校园点餐订餐外卖跑腿Java源码
一个非常实用的校园外卖系统,基于 SpringBoot 和 Vue 的开发。这一系统源于黑马的外卖案例项目 经过站长的进一步改进和优化,提供了更丰富的功能和更高的可用性。 这个项目的架构设计非常有趣。虽然它采用了SpringBoot和Vue的组合,但并不是一个完全分离的项目。 前端视图通过JS的方式引入了Vue和Element UI,既能利用Vue的快速开发优势,
64 13
|
18天前
|
JavaScript 安全 Java
java版药品不良反应智能监测系统源码,采用SpringBoot、Vue、MySQL技术开发
基于B/S架构,采用Java、SpringBoot、Vue、MySQL等技术自主研发的ADR智能监测系统,适用于三甲医院,支持二次开发。该系统能自动监测全院患者药物不良反应,通过移动端和PC端实时反馈,提升用药安全。系统涵盖规则管理、监测报告、系统管理三大模块,确保精准、高效地处理ADR事件。
|
23天前
|
负载均衡 Java 开发者
深入探索Spring Cloud与Spring Boot:构建微服务架构的实践经验
深入探索Spring Cloud与Spring Boot:构建微服务架构的实践经验
72 5
|
26天前
|
消息中间件 Java Kafka
Spring Boot 与 Apache Kafka 集成详解:构建高效消息驱动应用
Spring Boot 与 Apache Kafka 集成详解:构建高效消息驱动应用
39 1
|
12天前
|
XML 安全 Java
Spring Boot中使用MapStruct进行对象映射
本文介绍如何在Spring Boot项目中使用MapStruct进行对象映射,探讨其性能高效、类型安全及易于集成等优势,并详细说明添加MapStruct依赖的步骤。
|
1月前
|
Java
SpringBoot构建Bean(RedisConfig + RestTemplateConfig)
SpringBoot构建Bean(RedisConfig + RestTemplateConfig)
44 2
|
1月前
|
JavaScript 安全 Java
如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个前后端分离的应用框架,实现动态路由和菜单功能
本文介绍了如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个前后端分离的应用框架,实现动态路由和菜单功能。首先,确保开发环境已安装必要的工具,然后创建并配置 Spring Boot 项目,包括添加依赖和配置 Spring Security。接着,创建后端 API 和前端项目,配置动态路由和菜单。最后,运行项目并分享实践心得,帮助开发者提高开发效率和应用的可维护性。
82 2
|
1月前
|
JavaScript Java 项目管理
Java毕设学习 基于SpringBoot + Vue 的医院管理系统 持续给大家寻找Java毕设学习项目(附源码)
基于SpringBoot + Vue的医院管理系统,涵盖医院、患者、挂号、药物、检查、病床、排班管理和数据分析等功能。开发工具为IDEA和HBuilder X,环境需配置jdk8、Node.js14、MySQL8。文末提供源码下载链接。

热门文章

最新文章