SpringBoot 动态加载 jar 包,动态配置方案

全新前后端微商城项目,手把手教学!

全新前后端微商城项目,手把手教学!

全新前后端微商城项目,手把手教学!

一、概述

1、背景

目前数据治理服务中有众多治理任务,当其中任一治理任务有改动需要升级或新增一个治理任务时,都需要将数据治理服务重启,会影响其他治理任务的正常运行。

2、目标
  • 能够动态启动、停止任一治理任务

  • 能够动态升级、添加治理任务

  • 启动、停止治理任务或升级、添加治理任务不能影响其他任务

3、方案
  • 为了支持业务代码尽量的解耦,把部分业务功能通过动态加载的方式加载到主程序中,以满足可插拔式的加载、组合式的部署。

  • 配合xxl-job任务调度框架,将数据治理任务做成xxl-job任务的方式注册到xxl-job中,方便统一管理。

二、动态加载

1、自定义类加载器

URLClassLoader 是一种特殊的类加载器,可以从指定的 URL 中加载类和资源。它的主要作用是动态加载外部的 JAR 包或者类文件,从而实现动态扩展应用程序的功。为了便于管理动态加载的jar包,自定义类加载器继承URLClassloader。

package cn.jy.sjzl.util;  
  
import java.lang.reflect.Method;  
import java.net.URL;  
import java.net.URLClassLoader;  
import java.util.Map;  
import java.util.concurrent.ConcurrentHashMap;  
  
/**  
 * 自定义类加载器  
 *  
 * @author lijianyu  
 * @date 2023/04/03 17:54  
 **/  
public class MyClassLoader extends URLClassLoader {  
  
    private Map<String, Class<?>> loadedClasses = new ConcurrentHashMap<>();  
  
    public Map<String, Class<?>> getLoadedClasses() {  
        return loadedClasses;  
    }  
  
    public MyClassLoader(URL[] urls, ClassLoader parent) {  
        super(urls, parent);  
    }  
  
    @Override  
    protected Class<?> findClass(String name) throws ClassNotFoundException {  
        // 从已加载的类集合中获取指定名称的类  
        Class<?> clazz = loadedClasses.get(name);  
        if (clazz != null) {  
            return clazz;  
        }  
        try {  
            // 调用父类的findClass方法加载指定名称的类  
            clazz = super.findClass(name);  
            // 将加载的类添加到已加载的类集合中  
            loadedClasses.put(name, clazz);  
            return clazz;  
        } catch (ClassNotFoundException e) {  
            e.printStackTrace();  
            return null;  
        }  
    }  
  
    public void unload() {  
        try {  
            for (Map.Entry<String, Class<?>> entry : loadedClasses.entrySet()) {  
                // 从已加载的类集合中移除该类  
                String className = entry.getKey();  
                loadedClasses.remove(className);  
                try{  
                    // 调用该类的destory方法,回收资源  
                    Class<?> clazz = entry.getValue();  
                    Method destory = clazz.getDeclaredMethod("destory");  
                    destory.invoke(clazz);  
                } catch (Exception e ) {  
                    // 表明该类没有destory方法  
                }  
            }  
            // 从其父类加载器的加载器层次结构中移除该类加载器  
            close();  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  
}
  • 自定义类加载器中,为了方便类的卸载,定义一个map保存已加载的类信息。key为这个类的ClassName,value为这个类的类信息。

  • 同时定义了类加载器的卸载方法,卸载方法中,将已加载的类的集合中移除该类。由于此类可能使用系统资源或调用线程,为了避免资源未回收引起的内存溢出,通过反射调用这个类中的destroy方法,回收资源。

  • 最后调用close方法。

2、动态加载

由于此项目使用spring框架,以及xxl-job任务的机制调用动态加载的代码,因此要完成以下内容

  • 将动态加载的jar包读到内存中

  • 将有spring注解的类,通过注解扫描的方式,扫描并手动添加到spring容器中。

  • 将@XxlJob注解的方法,通过注解扫描的方式,手动添加到xxljob执行器中。

package com.jy.dynamicLoad;  
  
import com.jy.annotation.XxlJobCron;  
import com.jy.classLoader.MyClassLoader;  
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;  
import com.xxl.job.core.handler.annotation.XxlJob;  
import com.xxl.job.core.handler.impl.MethodJobHandler;  
import org.slf4j.Logger;  
import org.slf4j.LoggerFactory;  
import org.springframework.beans.factory.InitializingBean;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.beans.factory.annotation.Value;  
import org.springframework.beans.factory.support.AbstractBeanDefinition;  
import org.springframework.beans.factory.support.BeanDefinitionBuilder;  
import org.springframework.beans.factory.support.DefaultListableBeanFactory;  
import org.springframework.context.ApplicationContext;  
import org.springframework.core.MethodIntrospector;  
import org.springframework.core.annotation.AnnotatedElementUtils;  
import org.springframework.stereotype.Component;  
  
import java.io.File;  
import java.io.IOException;  
import java.lang.reflect.Method;  
import java.net.JarURLConnection;  
import java.net.URL;  
import java.net.URLConnection;  
import java.util.Enumeration;  
import java.util.Map;  
import java.util.concurrent.ConcurrentHashMap;  
import java.util.jar.JarEntry;  
import java.util.jar.JarFile;  
  
/**  
 * @author lijianyu  
 * @date 2023/04/29 13:18  
 **/  
@Component  
public class DynamicLoad {  
  
    private static Logger logger = LoggerFactory.getLogger(DynamicLoad.class);  
  
    @Autowired  
    private ApplicationContext applicationContext;  
  
    private Map<String, MyClassLoader> myClassLoaderCenter = new ConcurrentHashMap<>();  
  
    @Value("${dynamicLoad.path}")  
    private String path;  
  
    /**  
     * 动态加载指定路径下指定jar包  
     * @param path  
     * @param fileName  
     * @param isRegistXxlJob  是否需要注册xxljob执行器,项目首次启动不需要注册执行器  
     * @return map<jobHander, Cron> 创建xxljob任务时需要的参数配置  
     */  
    public void loadJar(String path, String fileName, Boolean isRegistXxlJob) throws ClassNotFoundException, InstantiationException, IllegalAccessException {  
        File file = new File(path +"/" + fileName);  
        Map<String, String> jobPar = new HashMap<>();  
        // 获取beanFactory  
        DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();  
        // 获取当前项目的执行器  
        try {  
            // URLClassloader加载jar包规范必须这么写  
            URL url = new URL("jar:file:" + file.getAbsolutePath() + "!/");  
            URLConnection urlConnection = url.openConnection();  
            JarURLConnection jarURLConnection = (JarURLConnection)urlConnection;  
            // 获取jar文件  
            JarFile jarFile = jarURLConnection.getJarFile();  
            Enumeration<JarEntry> entries = jarFile.entries();  
  
            // 创建自定义类加载器,并加到map中方便管理  
            MyClassLoader myClassloader = new MyClassLoader(new URL[] { url }, ClassLoader.getSystemClassLoader());  
            myClassLoaderCenter.put(fileName, myClassloader);  
            Set<Class> initBeanClass = new HashSet<>(jarFile.size());  
            // 遍历文件  
            while (entries.hasMoreElements()) {  
                JarEntry jarEntry = entries.nextElement();  
                if (jarEntry.getName().endsWith(".class")) {  
                    // 1. 加载类到jvm中  
                    // 获取类的全路径名  
                    String className = jarEntry.getName().replace('/', '.').substring(0, jarEntry.getName().length() - 6);  
                    // 1.1进行反射获取  
                    myClassloader.loadClass(className);  
                }  
            }  
            Map<String, Class<?>> loadedClasses = myClassloader.getLoadedClasses();  
            XxlJobSpringExecutor xxlJobExecutor = new XxlJobSpringExecutor();  
            for(Map.Entry<String, Class<?>> entry : loadedClasses.entrySet()){  
                String className = entry.getKey();  
                Class<?> clazz = entry.getValue();  
                // 2. 将有@spring注解的类交给spring管理  
                // 2.1 判断是否注入spring  
                Boolean flag = SpringAnnotationUtils.hasSpringAnnotation(clazz);  
                if(flag){  
                    // 2.2交给spring管理  
                    BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(clazz);  
                    AbstractBeanDefinition beanDefinition = builder.getBeanDefinition();  
                    // 此处beanName使用全路径名是为了防止beanName重复  
                    String packageName = className.substring(0, className.lastIndexOf(".") + 1);  
                    String beanName = className.substring(className.lastIndexOf(".") + 1);  
                    beanName = packageName + beanName.substring(0, 1).toLowerCase() + beanName.substring(1);  
                    // 2.3注册到spring的beanFactory中  
                    beanFactory.registerBeanDefinition(beanName, beanDefinition);  
                    // 2.4允许注入和反向注入  
                    beanFactory.autowireBean(clazz);  
                    beanFactory.initializeBean(clazz, beanName);  
                    /*if(Arrays.stream(clazz.getInterfaces()).collect(Collectors.toSet()).contains(InitializingBean.class)){  
                        initBeanClass.add(clazz);  
                    }*/  
                    initBeanClass.add(clazz);  
                }  
  
                // 3. 带有XxlJob注解的方法注册任务  
                // 3.1 过滤方法  
                Map<Method, XxlJob> annotatedMethods = null;  
                try {  
                    annotatedMethods = MethodIntrospector.selectMethods(clazz,  
                            new MethodIntrospector.MetadataLookup<XxlJob>() {  
                                @Override  
                                public XxlJob inspect(Method method) {  
                                    return AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class);  
                                }  
                            });  
                } catch (Throwable ex) {  
                }  
                // 3.2 生成并注册方法的JobHander  
                for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {  
                    Method executeMethod = methodXxlJobEntry.getKey();  
                    // 获取jobHander和Cron  
                    XxlJobCron xxlJobCron = executeMethod.getAnnotation(XxlJobCron.class);  
                    if(xxlJobCron == null){  
                        throw new CustomException("500", executeMethod.getName() + "(),没有添加@XxlJobCron注解配置定时策略");  
                    }  
                    if (!CronExpression.isValidExpression(xxlJobCron.value())) {  
                        throw new CustomException("500", executeMethod.getName() + "(),@XxlJobCron参数内容错误");  
                    }  
                    XxlJob xxlJob = methodXxlJobEntry.getValue();  
                    jobPar.put(xxlJob.value(), xxlJobCron.value());  
                    if (isRegistXxlJob) {  
                        executeMethod.setAccessible(true);  
                        // regist  
                        Method initMethod = null;  
                        Method destroyMethod = null;  
                        xxlJobExecutor.registJobHandler(xxlJob.value(), new CustomerMethodJobHandler(clazz, executeMethod, initMethod, destroyMethod));  
                    }  
                }  
  
            }  
            // spring bean实际注册  
            initBeanClass.forEach(beanFactory::getBean);  
        } catch (IOException e) {  
            logger.error("读取{} 文件异常", fileName);  
            e.printStackTrace();  
            throw new RuntimeException("读取jar文件异常: " + fileName);  
        }  
    }  
}

以下是判断该类是否有spring注解的工具类

apublic class SpringAnnotationUtils {  
  
    private static Logger logger = LoggerFactory.getLogger(SpringAnnotationUtils.class);  
    /**  
     * 判断一个类是否有 Spring 核心注解  
     *  
     * @param clazz 要检查的类  
     * @return true 如果该类上添加了相应的 Spring 注解;否则返回 false  
     */  
    public static boolean hasSpringAnnotation(Class<?> clazz) {  
        if (clazz == null) {  
            return false;  
        }  
        //是否是接口  
        if (clazz.isInterface()) {  
            return false;  
        }  
        //是否是抽象类  
        if (Modifier.isAbstract(clazz.getModifiers())) {  
            return false;  
        }  
  
        try {  
            if (clazz.getAnnotation(Component.class) != null ||  
            clazz.getAnnotation(Repository.class) != null ||  
            clazz.getAnnotation(Service.class) != null ||  
            clazz.getAnnotation(Controller.class) != null ||  
            clazz.getAnnotation(Configuration.class) != null) {  
                return true;  
            }  
        }catch (Exception e){  
            logger.error("出现异常:{}",e.getMessage());  
        }  
        return false;  
    }  
}

注册xxljob执行器的操作是仿照的xxljob中的XxlJobSpringExecutor的注册方法。

3、动态卸载

动态卸载的过程,就是将动态加载的代码,从内存,spring以及xxljob中移除。

代码如下:

/**  
 * 动态卸载指定路径下指定jar包  
 * @param fileName  
 * @return map<jobHander, Cron> 创建xxljob任务时需要的参数配置  
 */  
public void unloadJar(String fileName) throws IllegalAccessException, NoSuchFieldException {  
    // 获取加载当前jar的类加载器  
    MyClassLoader myClassLoader = myClassLoaderCenter.get(fileName);  
  
    // 获取jobHandlerRepository私有属性,为了卸载xxljob任务  
    Field privateField = XxlJobExecutor.class.getDeclaredField("jobHandlerRepository");  
    // 设置私有属性可访问  
    privateField.setAccessible(true);  
    // 获取私有属性的值jobHandlerRepository  
    XxlJobExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();  
    Map<String, IJobHandler> jobHandlerRepository = (ConcurrentHashMap<String, IJobHandler>) privateField.get(xxlJobSpringExecutor);  
    // 获取beanFactory,准备从spring中卸载  
    DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();  
    Map<String, Class<?>> loadedClasses = myClassLoader.getLoadedClasses();  
  
    Set<String> beanNames = new HashSet<>();  
    for (Map.Entry<String, Class<?>> entry: loadedClasses.entrySet()) {  
        // 1. 将xxljob任务从xxljob执行器中移除  
        // 1.1 截取beanName  
        String key = entry.getKey();  
        String packageName = key.substring(0, key.lastIndexOf(".") + 1);  
        String beanName = key.substring(key.lastIndexOf(".") + 1);  
        beanName = packageName + beanName.substring(0, 1).toLowerCase() + beanName.substring(1);  
  
        // 获取bean,如果获取失败,表名这个类没有加到spring容器中,则跳出本次循环  
        Object bean = null;  
        try{  
            bean = applicationContext.getBean(beanName);  
        }catch (Exception e){  
            // 异常说明spring中没有这个bean  
            continue;  
        }  
  
        // 1.2 过滤方法  
        Map<Method, XxlJob> annotatedMethods = null;  
        try {  
            annotatedMethods = MethodIntrospector.selectMethods(bean.getClass(),  
                    new MethodIntrospector.MetadataLookup<XxlJob>() {  
                        @Override  
                        public XxlJob inspect(Method method) {  
                            return AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class);  
                        }  
                    });  
        } catch (Throwable ex) {  
        }  
        // 1.3 将job从执行器中移除  
        for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {  
            XxlJob xxlJob = methodXxlJobEntry.getValue();  
            jobHandlerRepository.remove(xxlJob.value());  
        }  
        // 2.0从spring中移除,这里的移除是仅仅移除的bean,并未移除bean定义  
        beanNames.add(beanName);  
        beanFactory.destroyBean(beanName, bean);  
    }  
    // 移除bean定义  
    Field mergedBeanDefinitions = beanFactory.getClass()  
            .getSuperclass()  
            .getSuperclass().getDeclaredField("mergedBeanDefinitions");  
    mergedBeanDefinitions.setAccessible(true);  
    Map<String, RootBeanDefinition> rootBeanDefinitionMap = ((Map<String, RootBeanDefinition>) mergedBeanDefinitions.get(beanFactory));  
    for (String beanName : beanNames) {  
        beanFactory.removeBeanDefinition(beanName);  
        // 父类bean定义去除  
        rootBeanDefinitionMap.remove(beanName);  
    }  
  
    // 卸载父任务,子任务已经在循环中卸载  
    jobHandlerRepository.remove(fileName);  
    // 3.2 从类加载中移除  
    try {  
        // 从类加载器底层的classes中移除连接  
        Field field = ClassLoader.class.getDeclaredField("classes");  
        field.setAccessible(true);  
        Vector<Class<?>> classes = (Vector<Class<?>>) field.get(myClassLoader);  
        classes.removeAllElements();  
        // 移除类加载器的引用  
        myClassLoaderCenter.remove(fileName);  
        // 卸载类加载器  
        myClassLoader.unload();  
    } catch (NoSuchFieldException e) {  
        logger.error("动态卸载的类,从类加载器中卸载失败");  
        e.printStackTrace();  
    } catch (IllegalAccessException e) {  
        logger.error("动态卸载的类,从类加载器中卸载失败");  
        e.printStackTrace();  
    }  
    logger.error("{} 动态卸载成功", fileName);  
  
}
4、动态配置

使用动态加载时,为了避免服务重新启动后丢失已加载的任务包,使用动态配置的方式,加载后动态更新初始化加载配置。

以下提供了两种自己实际操作过的配置方式。

4.1 动态修改本地yml

动态修改本地yml配置文件,需要添加snakeyaml的依赖

4.1.1 依赖引入

<dependency>  
 <groupId>org.yaml</groupId>  
    <artifactId>snakeyaml</artifactId>  
    <version>1.29</version>  
</dependency>

4.1.2 工具类

读取指定路径下的配置文件,并进行修改。

package com.jy.util;  
  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.core.env.Environment;  
import org.springframework.stereotype.Component;  
import org.yaml.snakeyaml.DumperOptions;  
import org.yaml.snakeyaml.Yaml;  
  
import java.io.*;  
import java.util.ArrayList;  
import java.util.List;  
import java.util.Map;  
import java.util.stream.Collectors;  
  
/**  
 * 用于动态修改bootstrap.yml配置文件  
 * @author lijianyu  
 * @date 2023/04/18 17:57  
 **/  
@Component  
public class ConfigUpdater {  
  
    public void updateLoadJars(List<String> jarNames) throws IOException {  
        // 读取bootstrap.yml  
        Yaml yaml = new Yaml();  
        InputStream inputStream = new FileInputStream(new File("src/main/resources/bootstrap.yml"));  
        Map<String, Object> obj = yaml.load(inputStream);  
        inputStream.close();  
  
        obj.put("loadjars", jarNames);  
  
        // 修改  
        FileWriter writer = new FileWriter(new File("src/main/resources/bootstrap.yml"));  
        DumperOptions options = new DumperOptions();  
        options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);  
        options.setPrettyFlow(true);  
        Yaml yamlWriter = new Yaml(options);  
        yamlWriter.dump(obj, writer);  
    }  
}
4.2 动态修改nacos配置

Spring Cloud Alibaba Nacos组件完全支持在运行时通过代码动态修改配置,还提供了一些API供开发者在代码里面实现动态修改配置。在每次动态加载或卸载数据治理任务jar包时,执行成功后都会进行动态更新nacos配置。

package cn.jy.sjzl.config;  
  
import com.alibaba.nacos.api.NacosFactory;  
import com.alibaba.nacos.api.config.ConfigService;  
import com.alibaba.nacos.api.exception.NacosException;  
import org.springframework.beans.factory.annotation.Value;  
import org.springframework.context.annotation.Configuration;  
  
import java.util.Properties;  
  
@Configuration  
public class NacosConfig {  
    @Value("${spring.cloud.nacos.server-addr}")  
    private String serverAddr;  
  
    @Value("${spring.cloud.nacos.config.namespace}")  
    private String namespace;  
  
    public ConfigService configService() throws NacosException {  
        Properties properties = new Properties();  
        properties.put("serverAddr", serverAddr);  
        properties.put("namespace", namespace);  
        return NacosFactory.createConfigService(properties);  
    }  
}
package cn.jy.sjzl.util;  
  
import cn.jy.sjzl.config.NacosConfig;  
import com.alibaba.fastjson.JSONObject;  
import com.alibaba.nacos.api.config.ConfigService;  
import com.fasterxml.jackson.databind.ObjectMapper;  
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;  
import org.slf4j.Logger;  
import org.slf4j.LoggerFactory;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.beans.factory.annotation.Value;  
import org.springframework.stereotype.Component;  
  
import java.util.ArrayList;  
import java.util.List;  
import java.util.stream.Collectors;  
  
/**  
 * nacos配置中,修改sjzl-loadjars.yml  
 *  
 * @author lijianyu  
 * @date 2023/04/19 17:59  
 **/  
@Component  
public class NacosConfigUtil {  
  
    private static Logger logger = LoggerFactory.getLogger(NacosConfigUtil.class);  
  
    @Autowired  
    private NacosConfig nacosConfig;  
  
    private String dataId = "sjzl-loadjars.yml";  
  
    @Value("${spring.cloud.nacos.config.group}")  
    private String group;  
  
    /**  
     * 从nacos配置文件中,添加初始化jar包配置  
     * @param jarName 要移除的jar包名  
     * @throws Exception  
     */  
    public void addJarName(String jarName) throws Exception {  
        ConfigService configService = nacosConfig.configService();  
        String content = configService.getConfig(dataId, group, 5000);  
        // 修改配置文件内容  
        YAMLMapper yamlMapper = new YAMLMapper();  
        ObjectMapper jsonMapper = new ObjectMapper();  
        Object yamlObject = yamlMapper.readValue(content, Object.class);  
  
        String jsonString = jsonMapper.writeValueAsString(yamlObject);  
        JSONObject jsonObject = JSONObject.parseObject(jsonString);  
        List<String> loadjars;  
        if (jsonObject.containsKey("loadjars")) {  
            loadjars = (List<String>) jsonObject.get("loadjars");  
        }else{  
            loadjars = new ArrayList<>();  
        }  
        if (!loadjars.contains(jarName)) {  
            loadjars.add(jarName);  
        }  
        jsonObject.put("loadjars" , loadjars);  
  
        Object yaml = yamlMapper.readValue(jsonMapper.writeValueAsString(jsonObject), Object.class);  
        String newYamlString = yamlMapper.writeValueAsString(yaml);  
        boolean b = configService.publishConfig(dataId, group, newYamlString);  
  
        if(b){  
            logger.info("nacos配置更新成功");  
        }else{  
            logger.info("nacos配置更新失败");  
        }  
    }  
}

三、分离打包

分离打包时,根据实际情况在pom.xml中修改以下配置

<build>  
    <plugins>  
        <plugin>  
            <groupId>org.apache.maven.plugins</groupId>  
            <artifactId>maven-shade-plugin</artifactId>  
            <version>3.2.4</version>  
            <executions>  
                <execution>  
                    <phase>package</phase>  
                    <goals>  
                        <goal>shade</goal>  
                    </goals>  
                    <configuration>  
                        <filters>  
                            <filter>  
                                <artifact>*:*</artifact>  
                                <includes>  
                                    <include>com/jy/job/demo/**</include>  
                                </includes>  
                            </filter>  
                        </filters>  
                        <finalName>demoJob</finalName>  
                    </configuration>  
                </execution>  
            </executions>  
        </plugin>  
    </plugins>  
</build>

来源|https://blog.csdn.net/qq_45584746

 
 
推荐全新学习项目
全新基于springboot+vue+vant的前后端分离的微商城项目,包括手机端微商城项目和后台管理系统,整个电商购物流程已经能流畅支持,涵盖商品浏览、搜索、商品评论、商品规格选择、加入购物车、立即购买、下单、订单支付、后台发货、退货等。功能强大,主流技术栈,非常值得学习。
线上演示:https://www.markerhub.com/vueshop

从文档到视频、接口调试、学习看板等方面,让项目学习更加容易,内容更加沉淀。全套视频教程约35小时,共222期,讲解非常详细细腻。下面详细为大家介绍:
架构与业务使用主流的技术架构,真正手把手教你从0到1如何搭建项目手脚架、项目架构分析、建表逻辑、业务分析、实现等。涵盖SpringBoot、Mybatis Plus、Jwt、Redis、Lombok、Hutool、Shiro、Elasticsearch、RabbitMq、Docker、Jenkins等技术。
vueshop微商城的整个购物流程已经完善,各个模块的业务都是已经实现,涵盖商品模块、搜索模块、购物车模块、订单模块、退款模块、后台权限模块、业务数据管理模块等

更多详情请查看:
手把手教学,从0开发前后端微商城项目,主流Java技术一网打尽!

手把手教学,从0开发前后端微商城项目,主流Java技术一网打尽!

手把手教学,从0开发前后端微商城项目,主流Java技术一网打尽!
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值