拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot实战入门:自动装配、配置与事务失效全解析

我2013年前后开始做Java后端那时候SSHStruts2 Spring Hibernate还满地都是后来换到SSMXML配置十几二十个文件属于常态。一个新成员入职光捋明白配置就够喝一壶。等到SpringBoot出来我第一次感觉原来Java后端可以这么轻它不是简单地把配置简化了而是把整个项目的构建逻辑、部署方式和协作习惯都重排了一遍。这篇文章不打算写成官方文档的复读机。我按自己带新人的思路从为什么SpringBoot能火讲起一直讲到项目怎么搭、自动装配怎么回事、配置怎么写才对、数据库怎么整合、事务为什么莫名其妙失效最后落到处署和面试高频考点。中间会穿插大量我实际踩过的坑和验证过靠谱的写法内容主要围绕SpringBoot实战入门展开适合刚接触SpringBoot的同学也适合那些会用但没搞懂底层原理的兄弟。1. 为什么SpringBoot能一发入魂它的核心价值不是快1.1 回到SSM时代XML配置是怎么把人逼疯的现在很多新人对SpringBoot启动后就能用习以为常很难理解当年搭一个SSM项目有多麻烦。还记得那时候的标准流程先写web.xml再配spring-mvc.xml、spring-datasource.xml、mybatis-config.xml还要把事务管理器、视图解析器、扫描包路径、连接池一个个写进去。AOP切面要配定时任务要配跨域要配很多东西只要漏掉一行系统跑不起来是小事更麻烦的是你根本不知道漏了哪一行。而且这些配置大多是重复劳动。不同项目之间90%的Spring配置几乎一模一样只是换了数据源地址和包名。那时候每次新建项目最靠谱的办法是找一个老项目的配置文件复制过来再改网上抄来的还经常遇到依赖版本冲突。这种状态真正击中了开发效率的要害大量时间花在让项目能启动这件事上而不是实现业务功能。SpringBoot改变的核心就是把这些重复劳动全部收编。你引入一个starter它自动帮你把该配的东西配好你启动应用它自己判断类路径里有什么、应该装配什么Bean。于是搭环境这个环节几乎被压缩到了极致。1.2 SpringBoot的三个核心命题我认为理解SpringBoot要从三个关键词入手。第一个是起步依赖。以前你要自己管理Spring、SpringMVC、Jackson、Tomcat的版本还得分清楚它们之间哪个版本相互兼容。SpringBoot通过spring-boot-starter-*这类依赖把一组常用组件打包在一起版本也锁好了。你只负责引入一个starter剩下交给它。比如spring-boot-starter-web里面含SpringMVC、内嵌Tomcat、Jackson、日志等全都做了版本管理这就是为什么SpringBoot项目的pom.xml看起来极其清爽。第二个是自动配置。SpringBoot启动时会扫描classpath下的配置类和条件注解自动创建项目中需要的Bean。比如你引入了spring-boot-starter-web它发现类路径里有Tomcat和SpringMVC就自动帮你配好DispatcherServlet你引入了spring-boot-starter-data-redis它发现RedisTemplate相关类存在就自动帮你创建连接工厂和模板对象。这个过程对开发者是透明的但它的威力巨大——你几乎不需要再写什么注册组件的配置。第三个是内嵌容器。SpringBoot应用自带Tomcat也可以换成Jetty、Undertow或Netty你不需要单独装一个Tomcat再把war包丢进去。打出来的jar包可以直接java -jar执行部署方式从装环境-拷war包-配容器-启动简化为拷jar包-启动。对于运维和交付来讲这是实实在在的效率提升。用一个生活化的类比Spring是一套厨具好是好但你想做一顿饭得先把锅碗瓢盆洗出来、摆好位置、确认火源没问题然后才动手做菜。SpringBoot是直接给你一份套餐——食材、调料、步骤都配好了你拿到手只需要开火就行。至于套餐里用的餐具是不是你能替换的那是后续进阶要考虑的事对入门来说先吃到饭比什么都重要。2. 从零到第一个接口环境选择与项目骨架搭建2.1 版本选择是入门第一道坎JDK与SpringBoot版本的对应关系很多新手上来就栽在版本上。打开官网一看SpringBoot 3.x要求JDK17但公司项目还是JDK8一脸懵。这个事其实不复杂记住下面这个对应关系就好SpringBoot版本最低JDK版本推荐JDK说明2.7.xJDK8JDK8/11目前生产环境最稳的一代资料多3.0.x - 3.2.xJDK17JDK17包和API有较大调整需要适配3.3.x及以上JDK17JDK17/21当前活跃迭代版本如果你的操作系统或者云服务器装的是JDK8老老实实用SpringBoot 2.7.x。别看到新版本就往上冲2.7能跑通绝大多数业务场景网上遇到的问题和解决方案也最多。反过来如果是个全新项目并且团队愿意用JDK17那直接上SpringBoot 3.x也没问题毕竟它有后续长期维护。这里多提一句热词里有springboot版本太高这个说法。我遇到过不止一个同事在SpringBoot 3.x项目里照抄2.x的配置比如javax.servlet改成jakarta.servlet没跟上比如spring.factories自动配置文件格式被AutoConfiguration.imports取代结果启动报错找了一下午。所以选版本时一定要连带确认配套的技术栈版本MyBatis、Redis客户端等它们也得是支持对应SpringBoot版本的。2.2 用Spring Initializr快速创建项目骨架新手创建SpringBoot项目我建议就用IDEA自带的Spring Initializr。步骤很简单新建项目 - 选择Spring Initializr - 填写Group、Artifact - 选择SpringBoot版本 - 勾选依赖 - 完成。但有几个细节值得注意第一Server URL选哪个。IDEA默认是start.spring.io国内网络环境下有时候会很慢甚至连不上。我一般改成阿里云的镜像地址。在IDEA里也能用就在创建向导的Server URL那里填上对应地址。用国内镜像的好处是下载速度和依赖解析速度都很明显。第二依赖不用一次勾齐。初学阶段只勾Spring Web就够了其他依赖后面在pom里加其实更直观。因为你勾得越多自动装配的东西越多出了问题目录越乱入门期反而不利于理解。第三Maven仓库要配阿里云镜像。这步不做后面依赖下载会等到怀疑人生。改一下Maven的settings.xml把mirror指向阿里云就好。这不算SpringBoot特有的内容但没配好它直接影响SpringBoot体验。创建完成后项目结构长这样src/main/java └── com.example.demo ├── DemoApplication.java └── ... src/main/resources ├── static ├── templates └── application.properties pom.xmlDemoApplication就是这个应用的入口它上面只有三个注解的合成注解。下一篇会在自动装配部分拆开讲。2.3 第一个接口背后的启动流程创建完成后先别急着写代码我习惯先在DemoApplication同包下写一个ControllerRestController public class HelloController { GetMapping(/hello) public String hello() { return Hello SpringBoot; } }然后直接运行DemoApplication的main方法控制台出现Spring Boot的ASCII Banner和Tomcat started on port(s): 8080之后浏览器访问http://localhost:8080/hello就能看到输出。这里有个关键点为什么这个Controller能被扫到因为SpringBootApplication的默认扫描规则是扫描它所在包及子包。你的Controller如果放在com.example.demo包里的任意子包下都会被扫到但如果你图省事把Controller扔到com.example.other包下项目启动不会报错但请求会404。这个问题在入门期太常见了面试我也经常问。记住一句话启动类的位置决定了组件扫描的根路径。如果你有特殊需求要扫描其他包可以用scanBasePackages属性指定比如SpringBootApplication(scanBasePackages com.example) public class DemoApplication { // ... }端口默认是8080想改就在application.yml里写server: port: 8081看到没一个端口配置在SpringBoot里就两行放在以前你得去Tomcat的server.xml里找Connector改。这就是配置体系带来的体验差异。3. 自动装配与核心注解入门必须搞懂的两个底层机制3.1 拆解SpringBootApplication很多同学用了很久SpringBoot但不知道项目入口那个SpringBootApplication到底做了什么。我把它的源码拆开Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) Documented Inherited SpringBootConfiguration EnableAutoConfiguration ComponentScan(excludeFilters { Filter(type FilterType.CUSTOM, classes TypeExcludeFilter.class), Filter(type FilterType.CUSTOM, classes AutoConfigurationExcludeFilter.class) }) public interface SpringBootApplication { }三个核心注解分别负责三件事SpringBootConfiguration本质是Configuration表明当前类是一个配置类。EnableAutoConfiguration开启自动配置这是SpringBoot最核心的机制。ComponentScan扫描并注册Bean默认扫当前包及子包。这三个加在一起的替代写法是Configuration EnableAutoConfiguration ComponentScan public class DemoApplication { }只是为了方便使用SpringBoot把它们合并成了一个合成注解。理解了这个合成关系后面遇到一些变体比如测试类上的SpringBootTest、配置类上的Configuration就不容易混。3.2 自动装配是如何自动起来的自动装配的底层逻辑用一句话概括就是SpringBoot启动时读取所有jar包里的自动配置类用条件注解判断是否生效生效就装配成Bean放进容器。具体链路如下EnableAutoConfiguration通过AutoConfigurationImportSelector引入一批自动配置类。SpringFactoriesLoader或SpringBoot 3.x的AutoConfiguration.imports文件从classpath下所有jar包中加载候选自动配置类的名字。这些自动配置类上通常带有ConditionalOnClass、ConditionalOnMissingBean、ConditionalOnProperty等条件注解Spring会根据当前项目的情况决定哪些配置真正生效。生效的配置类负责创建Bean。为了让大家更直观理解我给个简化的例子。假设有这样一个自动配置类Configuration ConditionalOnClass(RedisTemplate.class) public class RedisAutoConfiguration { Bean ConditionalOnMissingBean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); return template; } }它的逻辑是如果classpath里有RedisTemplate这个类说明你引入了Redis相关依赖且容器里还没有redisTemplate这个Bean就自动创建并配到容器里。这样你在项目里直接Autowired RedisTemplate就能用完全不用关心它怎么来的。自动配置的魔法其实是一种被动触发你引什么依赖它就有什么Bean你没引它就不装配。这也解释了一个常见现象——引入一个starter之前项目运行得好好的引入之后项目启动方式可能悄悄变了。比如引入了spring-boot-starter-webflux你的应用就不再按SpringMVC的方式启动这算WebFlux带来的坑后面高频考点部分我会再提。3.3 这些注解入门期必须分清注解总是SpringBoot入门的一道坎。我不建议一下子背几十个先把最常用的搞清楚就够了。注解用途高频使用场景RestController标记类是一个RESTful控制器返回JSON写接口Service标记业务层组件Service实现类Repository标记数据访问层组件Mapper/DAO实现类Configuration标记一个类是配置类里面Bean方法会被处理自定义配置类Bean声明一个方法返回值交给Spring容器管理配置类里创建组件Autowired按类型注入依赖字段注入、构造器注入Value读取配置项的值注入单个配置ConfigurationProperties将配置项绑定到一个JavaBean优雅读取配置ConditionalOnProperty根据配置项决定是否创建Bean开关控制Transactional声明事务边界数据库操作这里面最容易搞混的是Component和Configuration。简单说Configuration一般用于定义配置类它内部的方法会被CGLIB代理保证Bean方法返回的单例Bean不会重复创建。而Component只是一个普通的Spring Bean语义更轻。业务组件用Component也行但为了代码清晰我建议还是严格区分Service用ServiceDAO用Repository配置类用Configuration工具类可以用Component这样别人看代码能快速定位。还有一个细节IDEA里Autowired字段上会显示一个不那么友好的警告很多新同学以为写错了。其实那是Spring推荐的构造器注入提示不是报错。Autowired字段注入能用构造器注入更利于测试和不可变设计但业务代码里大量用构造器注入会显得笨重。个人经验是自己写新代码用构造器注入维护老代码保持字段注入别折腾。4. 配置体系全解application.yml的正确姿势与IDEA提示恢复4.1 优先级与格式YAML、多环境与配置覆盖SpringBoot的配置文件支持两种格式application.properties和application.yml。我个人强烈建议用application.yml因为它的层级结构清晰读起来像一棵树而且和Lombok、OpenAPI这些周边工具的配置风格一致。YAML缩进敏感这是新手最容易翻车的地方。比如server: port: 8080 servlet: context-path: /api如果port和servlet的缩进不对齐启动会直接报错。这种错有时候很不明显所以我会在关键配置前先确认IDEA的YAML插件正常因为IDEA在YAML有语法错误时会有红色波浪线提示不会等到启动才暴露。多环境配置是日常工作必用技能。做法是建多个配置文件application.yml application-dev.yml application-prod.yml主配置文件里指定当前环境spring: profiles: active: dev这样启动时会加载application.yml application-dev.yml。不同环境的数据库地址、日志级别、开关都放各自的文件里逻辑清晰又不互相污染。SpringBoot 2.4还支持在同一个YAML文件里用---分隔多个文档块不过我还是推荐分开文件多人协作时冲突会少很多。配置优先级这页也很重要再强调一次外部化的配置优先级高于内部配置比如命令行参数优先于application.ymljava -jar demo.jar --server.port8081启动时如果不方便改文件或者临时想换个端口、换个环境用命令行参数是最快的。4.2 读取配置的三种姿势第一用Value读取单个配置项app: name: demo-serviceValue(${app.name}) private String appName;这种方式简单直接适合读取少量配置但配置项一多字段写得就很累。第二用ConfigurationProperties绑定一组配置到对象上Component ConfigurationProperties(prefix app) public class AppProperties { private String name; private String version; // getter和setter }配置还是写在application.yml的app节点下。这种方式适合一类配置绑定一个对象的场景比如数据源配置、第三方接口配置、消息队列配置等。第三注入Environment对象动态读取。这在写一些通用代码时用得多比如在工具类里Autowired private Environment env; public String getConfig(String key) { return env.getProperty(key); }这种方式适合运行时才知道key的场景。还有一个我想专门拎出来的点yml里配置Map和List。这个问题在热词里反复出现其实不难。Map的写法thread-pool: sizes: core: 8 max: 16ConfigurationProperties(prefix thread-pool) Component public class ThreadPoolProperties { private MapString, Integer sizes new HashMap(); // getter和setter }List的写法whitelist: urls: - /api/login - /api/registerConfigurationProperties(prefix whitelist) Component public class WhitelistProperties { private ListString urls new ArrayList(); // getter和setter }要注意在ConfigurationProperties类的Map、List字段上最好给一个默认空集合初始值避免配置缺失时出现NullPointerException。4.3 IDEA里application.yml不提示的排查链路热词里有不少人在搜idea中springboot项目的application.yml不提示怎么办我这边把排查链路完整地梳理一遍。这个问题本质上是IDEA识别该项目配置文件失效了。第一步确认项目里的application.yml没有变成非配置文件的普通文件图标。如果图标不是Spring Leaf说明IDEA没有把它识别成Spring配置文件。检查File - Project Structure - Facets看有没有Spring模块以及它是否关联了当前模块。如果没有点加号添加一个Spring Facet再把application.yml加入到Spring配置里。第二步检查IDEA的Spring Assistant/Spring Boot插件是否启用。在Settings - Plugins里确认相关插件没被禁用。第三步如果图标正常但还是不提示看一下pom.xml里有没有引入spring-boot-configuration-processordependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-configuration-processor/artifactId optionaltrue/optional /dependency这个依赖能让IDE读取ConfigurationProperties的元数据从而在yml里提供自动补全。很多项目没引入它所以自定义配置项没有任何提示。第四步做完以上操作后执行一次Build - Rebuild Project还不行就File - Invalidate Caches清缓存。IDEA对这种静态资源的识别偶尔会卡缓存清一下就好。说句实话YAML报错和提示问题有百分之八十可以通过打开IDEA的对应插件和引入configuration-processor解决。如果你配完上面几步还有问题大概率是缩进缩写出问题仔细看一下。5. 整合数据库与事务从MyBatis到事务失效的五个典型场景5.1 整合MyBatis的完整过程SpringBoot项目里用MyBatis做数据访问是我个人觉得最顺手的一套组合。先加依赖dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency注意版本匹配mybatis-spring-boot-starter 2.3.x对应SpringBoot 2.xSpringBoot 3.x要用mybatis-spring-boot-starter 3.0.x以上版本。数据库连接池我一般用HikariCP它已经内置在SpringBoot里了不用额外引入。application.yml的数据源配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 10 minimum-idle: 5Mapper接口加Mapper注解或者在启动类上加MapperScan指定包路径。我习惯用MapperScanSpringBootApplication MapperScan(com.example.demo.mapper) public class DemoApplication { // ... }XML映射文件放src/main/resources/mapper目录下然后在application.yml里指定mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity这一步容易忘。漏配mapper-locations的直接后果是项目启动正常一执行查询报Invalid bound statement (not found)。出现这个错优先级最高的排查项就是它。整合完成后写一个UserMapper一般流程是entity类 - Mapper接口 - XML里写SQL - Service调用。SpringBoot的数据库部分并没有太多玄学真正的坑往往出在事务上。5.2 事务失效的五个典型场景Transactional用起来很简单但失效场景是真多。每次面试问事务什么时候会失效能说全的人很少这里我逐个拆一下。前四个是自己项目里真实踩过的第五个是看别人代码时发现的。场景一同类内部方法调用。这是最常见的失效场景。Service public class OrderService { public void createOrder() { // 做一些校验 this.deductStock(); } Transactional public void deductStock() { // 操作库存表 } }deductStock方法虽然加了Transactional但它被this调用spring的事务代理根本没有经过。Transactional要生效必须经过代理对象调用而this不是代理。解决方法是把deductStock拆到一个单独的Bean里或者注入自身配合Lazy调用或者用AopContext.currentProxy()强行获得代理对象。日常项目里我推荐拆Bean最直白也好测试。场景二非public方法。Spring官方文档明确Transactional只能标注public方法CGLIB代理下protected在某些配置也能生效但这是未定义行为别赌。方法改成private或protected时事务静默失效但代码不会报错。我的习惯是事务方法一律public内部逻辑拆给private方法。场景三异常被catch吞掉。下面这段代码看起来没毛病但事务不会回滚Transactional public void createOrder() { try { // 业务逻辑 } catch (Exception e) { // 记录日志 // 没有重新抛出 } }事务的回滚依赖异常向上传播你把它吞掉了Spring就认为方法正常执行完毕直接提交。如果你确实需要catch异常并做一些清理记得catch之后throw new RuntimeException(e)或者标记手动回滚。场景四抛出的异常类型没有被rollbackFor覆盖。默认情况下Spring只回滚RuntimeException和Errorchecked异常比如IOException默认不会触发回滚。代码里如果用到了这类异常事务方法上要说明Transactional(rollbackFor Exception.class) public void importFile() throws IOException { // 业务逻辑 }我自己的习惯是事务上的rollbackFor一律写Exception.class省得半路冒出个checked异常导致数据不一致。场景五传播行为设置不当。比如在某处把传播行为配成了Propagation.NOT_SUPPORTED或REQUIRES_NEW前者不在事务里执行后者会开启新事务和原事务就不是一体的了。这种问题比较隐蔽排查时要仔细看每个调用链路上方法的事务传播配置。验证方法也简单把事务方法扔一个测试用例先正常跑一次再手动抛异常跑一次看数据是否回滚。还有一类和数据库有关有些SQL是DDL比如建表、TRUNCATEDDL本身隐式提交事务也管不住它。这不是Spring的锅但很多人以为事务在手就万事大吉其实不是。6. 部署与常见进阶Docker Desktop打包、Banner定制与高频考点扫盲6.1 用Docker Desktop把项目跑起来现在很多同学本机用的就是Windows或Mac上的Docker DesktopSpringBoot项目要打进容器其实不复杂。以JDK8 SpringBoot 2.x项目为例项目根目录建一个DockerfileFROM openjdk:8-jdk-alpine LABEL maintaineryour-name COPY target/demo-0.0.1-SNAPSHOT.jar app.jar ENTRYPOINT [java, -jar, /app.jar]先执行mvn clean package打成jar包然后构建镜像docker build -t demo:v1 .启动容器docker run -d -p 8080:8080 --name demo-container demo:v1看到这可能有人问为什么用openjdk:8-jdk-alpine这个镜像。因为SpringBoot 2.x的jar是fat jar它要求基础镜像里得有完整的JRE/JDK环境alpine版本镜像小传统上是CI/CD里的常用选择。不过后来遇到过alpine镜像在某些场景下DNS解析和时区有问题后来又切到了基础镜像里带时区的镜像。经验是本地开发随便拉镜像生产环境镜像尽量选带准确时区的标准镜像并显式设置时区ENV TZAsia/Shanghai启动之后用docker ps看容器状态docker logs -f demo-container看日志。如果反复重启多半是端口占用或application.yml里的配置不对先看日志再猜原因。还有一点值得提醒容器里的端口和宿主机端口不是一回事。-p 8080:8080左边是宿主机右边是容器内应用监听的端口。如果你在application.yml里把server.port改成8081那命令要相应调整成-p 8080:8081。6.2 Banner定制让启动过程有点仪式感项目启动时那个大字符Banner是可以换的。网上很多springboot banner在线生成工具可以直接用把生成的ASCII艺术字复制到src/main/resources/banner.txt重启就能看到定制效果。Banner能放什么可以是团队名称、项目代号、接口文档地址甚至一个警告该服务只允许内网访问。放警告在安全场景下其实挺有用一登录服务器看启动日志就知道自己没进错环境。如果嫌Banner碍事也可以关掉spring: main: banner-mode: off这个技能听起来像小把戏但在团队内部做多服务管理时挺实用的。给不同环境的服务配上不同颜色和文字运维同学一眼就能看出当前连接的是开发环境还是生产环境。6.3 高频考点快速扫盲循环依赖与WebFlux陷阱循环依赖。这是面试问烂了的题但确实值得弄明白。Spring解决单例Bean的循环依赖靠三级缓存一级缓存singletonObjects存完整Bean二级缓存earlySingletonObjects存半成品Bean三级缓存singletonFactories存Bean的工厂方法A和B互相依赖时创建A会先把A的工厂方法放入三级缓存再填充A的属性时发现需要B就先去创建BB填充属性时发现需要A从三级缓存里找到A的半成品完成注入。这就是为什么Spring能处理大多数循环依赖。但SpringBoot 2.6版本开始默认禁止循环依赖了启动时直接抛异常于是很多人升级后一脸懵。如果项目里确实存在循环依赖一种临时处理是加配置spring: main: allow-circular-references: true但我强烈不建议依赖这个开关。正确做法是重构代码把循环依赖拆掉——通常是抽出中间层或者用事件发布来解耦。循环依赖本身是设计坏味道的信号靠开关掩盖等于埋雷。WebFlux陷阱。有些人只是想用WebClient发起HTTP请求就在pom里引入了spring-boot-starter-webflux依赖结果启动应用时发现端口起了但访问原来的Controller直接404。这是因为引入webflux后应用自动切换成了响应式WebFlux的启动方式SpringMVC的DispatcherServlet没有生效。如果你只是需要WebClient正确的做法是引入webflux的依赖但不让它接管整个Web层要么单独用一个模块做请求调用要么确认自己确实需要WebFlux响应式框架。这个坑我见过不止一次。再加一个自动化装配的面试高频题。SpringBoot 2.x时代自动配置的候选类写在spring.factories文件里SpringBoot 3.x改成写在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports里。很多人升级后自定义starter失效十有八九是文件路径没跟着改。记住这个差异自定义starter和启动排查都能少走弯路。我个人带新人的习惯是SpringBoot入门不要求背源码先把项目跑起来再追着为什么能跑把自动装配、配置体系、事务边界这三件事搞清楚后面遇到问题就有方向了。等技术栈越用越熟自然会碰到starter开发和驱动SpringBoot框架调优这类进阶需求那时候再回头看今天这篇里的基础内容会发现很多坑其实就是当初没理解透自动配置和事务代理的机制。写这篇也当给自己留个教学底稿哪块没说透评论区见。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门