Spring Boot Bean定义覆盖与@Primary:从启动报错到依赖注入歧义的彻底拆解

发布时间:2026/10/3 4:28:48
Spring Boot Bean定义覆盖与@Primary:从启动报错到依赖注入歧义的彻底拆解 我见过太多次这个启动失败了项目跑得好好的加了一个新的配置类或者顺手升级了一次 Spring Boot结果一启动就红屏核心报错就一句——The bean messageSender could not be registered. A bean with that name has already been defined ... and overriding is disabled。第一次踩的人通常很懵我明明没有重复定义 Bean 啊等他把spring.main.allow-bean-definition-overridingtrue一开启动倒是过了过几天又冒出来一个NoUniqueBeanDefinitionException或者更阴险的——某个注入点悄悄用上了错误的实现连异常都没有。这篇就把 Bean 定义覆盖Bean Definition Overriding和Primary之间那点纠缠不清的关系彻底拆一遍适合两类人一类是刚被启动报错和注入歧义折磨的 Spring Boot 使用者另一类是写公共配置、starter 的库作者想搞清楚怎么避免自己的定义被别人覆盖。1. 先从那个让人头疼的启动报错说起1.1 启动失败和那条容易被忽略的 Debug 日志Spring Boot 2.1 是一个分水岭。从它开始spring.main.allow-bean-definition-overriding的默认值变成了false。也就是说容器在注册一个新 Bean 时只要发现已经存在一个同名的 BeanDefinition就直接抛BeanDefinitionOverrideException整个应用启动失败。你看到的失败界面长这样*************************** APPLICATION FAILED TO START *************************** Description: The bean messageSender, defined in class path resource [com/example/config/EmailSenderConfiguration.class], could not be registered. A bean with that name has already been defined in class path resource [com/example/config/SmsSenderConfiguration.class] and overriding is disabled. Action: Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overridingtrue这段报错信息其实已经把答案写在脸上了有人在你之前注册了一个叫messageSender的定义你这次又注册一个同名定义默认行为不允许覆盖。很多人的直觉反应是那我把spring.main.allow-bean-definition-overriding改成true不就行了确实能启动但代价是把问题从启动期暴露推迟到了运行期埋雷。当你打开org.springframework.beans.factory.support.DefaultListableBeanFactory的 DEBUG 日志时会看到这样一条输出DEBUG ... Overriding bean definition for bean messageSender with a different definition: replacing [Generic bean: class [com.example.sender.SmsMessageSender]; primarytrue; ...] with [Generic bean: class [com.example.sender.EmailMessageSender]; primaryfalse; ...]注意看末尾的两个属性primarytrue变成了primaryfalse。这就是覆盖和Primary产生冲突的核心覆盖从头到尾只管名字这一个维度它根本不关心被换掉的那个定义是不是被标记成了 primary。环境配置同名 Bean 注册的结果Spring Boot 2.1底层 Framework 默认允许覆盖后注册的定义静默替换先注册的默认无日志Spring Boot 2.1allow-bean-definition-overridingfalse默认启动失败抛BeanDefinitionOverrideExceptionSpring Boot 2.1allow-bean-definition-overridingtrue启动通过按角色输出 INFO/DEBUG/TRACE 覆盖日志1.2 盲目开启覆盖后真正的坑是 Primary 失效我见过很多团队在启动失败后采用的第一个方案就是打开覆盖开关然后理直气壮地继续开发。结果没过多久项目里冒出NoUniqueBeanDefinitionExceptionNoUniqueBeanDefinitionException: No qualifying bean of type MessageSender available: expected single matching bean but found 2: messageSender, wechatMessageSender为什么会这样用一个很直白的比喻Bean 定义覆盖就像换宿舍。原来是 A 同学住在messageSender这个房间里还挂着寝室长Primary的牌子。覆盖动作是把整个房间的住客换成 B 同学而 B 同学身上没有戴牌子。于是当同类型的其他同学数量超过一个时大家就不知道默认该听谁的了。更麻烦的是覆盖这个过程没有任何提示迁移的机制。原来挂在旧定义上的Primary、qualifier、scope 等标记全部随旧定义一起被丢弃。新定义说了算。所以问题的本质不是要不要允许覆盖而是覆盖之后依赖注入该怎么仍然保持正确。这也是为什么这篇文章要把两者放在一起讲只谈覆盖不讲Primary你早晚会在运行期再踩一遍坑。2. 容器里到底发生了什么同名 Bean 注册与覆盖的底层逻辑2.1 bean 名是怎么来的方法名、类名、显式命名要理解覆盖先要理解名字从哪里来。Spring 内部用DefaultListableBeanFactory的beanDefinitionMap一个ConcurrentHashMapString, BeanDefinition保存 Bean 定义key 就是 Bean 的名字。名字的生成规则很多实践中最容易撞名的有这几类组件扫描Component没有指定名字时默认是类名的首字母小写SmsMessageSender变成smsMessageSenderBean方法没有指定名字时默认是方法名本身比如Bean public MessageSender messageSender()的 bean 名就是messageSender显式命名Bean(smsMessageSender)、Component(smsMessageSender)这种最不容易撞别名Bean({smsMessageSender, primarySender})会产生一个主名和多个别名但别名的判断逻辑和主名不完全一样。覆盖发生的条件只有一个两个不同来源的 Bean 定义最终注册了同一个主名。举例说明下面这两个定义一定是冲突的// 配置类 A Configuration public class SmsSenderConfiguration { Bean public MessageSender messageSender() { return new SmsMessageSender(); } } // 配置类 B Configuration public class EmailSenderConfiguration { Bean public MessageSender messageSender() { return new EmailMessageSender(); } }不管两个类在工程里摆放的位置多优雅、包名多清晰在容器眼里它们都往beanDefinitionMap里塞了同一个 keymessageSender。第二次 put 就是在覆盖第一次 put。2.2 registerBeanDefinition 的覆盖判定源码拆解覆盖的判定逻辑集中在DefaultListableBeanFactory.registerBeanDefinition。核心逻辑我简化后大概是这样的BeanDefinition existing beanDefinitionMap.get(beanName); if (existing ! null) { // 1. 不允许覆盖直接抛异常 if (!isAllowBeanDefinitionOverriding()) { throw new BeanDefinitionOverrideException(beanName, beanDefinition, existing); } // 2. 新定义的角色更“框架内部”打 INFO if (existing.getRole() beanDefinition.getRole()) { logger.info(Overriding user-defined bean definition for bean beanName with a framework-generated bean definition: replacing [ existing ] with [ beanDefinition ]); } // 3. 新旧定义内容不同打 DEBUG else if (!beanDefinition.equals(existing)) { logger.debug(Overriding bean definition for bean beanName with a different definition: replacing [ existing ] with [ beanDefinition ]); } // 4. 新旧定义完全等价打 TRACE else { logger.trace(Overriding bean definition for bean beanName with an equivalent definition: replacing [ existing ] with [ beanDefinition ]); } beanDefinitionMap.put(beanName, beanDefinition); } else { beanDefinitionMap.put(beanName, beanDefinition); }这段逻辑里有几个值得记住的细节第一BeanDefinitionOverrideException是 Spring Framework 5.1 才加入的。在老的 Framework 版本里即使你想关掉覆盖也没有专门的异常类型可用。第二日志级别由角色role和定义是否相等共同决定。ROLE_APPLICATION0是用户 BeanROLE_INFRASTRUCTURE2是框架内部 Bean。当一个框架内部定义替换掉你的用户定义时会打 INFO所以偶尔能在默认日志里看到但两个用户定义互相替换时默认只是 DEBUG。很多人开了覆盖开关后什么都没看到就是因为没开 DEBUG 日志。第三equals是逐字段比较的。你只是给后来的Bean方法加了一个Primary就会让新定义和旧定义不相等从而从 TRACE 升级成 DEBUG但又不会到 INFO。这也是为什么覆盖日志经常看得见又看不见。2.3 为什么 Spring Boot 2.1 把默认值改成了 false有人会问Framework 默认本来允许覆盖Boot 为什么要冒天下之大不韪改成 false因为覆盖这件事的默认行为太危险了。在允许覆盖且不输出日志的情况下一个第三方库的配置完全可以把你自己定义的RestTemplate、ObjectMapper、DataSource静默替换成它自己的实现。应用照样启动线上表现却突然不对劲排查成本极高。Spring Boot 2.1 做了一次吃力不讨好的安全加固默认禁止覆盖宁可让你在启动时看到红屏也不要让你在凌晨三点被一个不知道哪里来的 Bean背刺。需要明确一点这个默认值是 Boot 层面的不是 Spring Framework 的。如果你脱离 Boot纯用AnnotationConfigApplicationContext手动构建容器allowBeanDefinitionOverriding默认仍然是 true。这也是为什么很多 Spring 老教程里根本没有这个概念——他们用的是纯 Framework 的老行为。3. Primary 介入注入竞争的完整规则3.1 从 determineAutowireCandidate 看 Bean 的选择顺序Primary解决的是完全不同的问题当容器里有多个不同类型的同名 Bean 时不存在所谓选择困难因为同名只剩一个了Primary处理的是多个不同名字的同类型 Bean默认注入时该选谁。依赖注入的解析入口是DefaultListableBeanFactory.doResolveDependency大致流程是根据注入点的类型收集所有该类型的候选 Bean如果只有一个候选直接使用如果多个候选调用determineAutowireCandidate决出胜者决不出就抛NoUniqueBeanDefinitionException。determineAutowireCandidate的简化逻辑如下protected String determineAutowireCandidate(MapString, Object candidates, DependencyDescriptor descriptor) { // 第一优先找 Primary 标记的候选 String primaryCandidate determinePrimaryCandidate(candidates, requiredType); if (primaryCandidate ! null) { return primaryCandidate; } // 第二优先按 Qualifier、字段名/参数名匹配 String qualifierCandidate determineQualifierCandidate(candidates, descriptor); if (qualifierCandidate ! null) { return qualifierCandidate; } // 都没有返回 null由上层抛 NoUniqueBeanDefinitionException return null; }注意determinePrimaryCandidate内部的实现很关键它会遍历所有候选只要发现两个候选都带Primary直接抛 more than one primary bean found among candidates 的异常。也就是说Primary不是简单的优先标记它要求同一类型里有且只有一个事实上的默认项。3.2 Primary、Qualifier、字段名回退优先级到底谁高很多人的误区是有了 Primary 就万事大吉。实际上从上面的代码能看到Spring 的注入偏好顺序是Primary标记Qualifier显式指定的 qualifier或者字段名/参数名与某个 Bean 名恰好一致都不满足抛异常。用一张表概括场景结果多个候选恰好一个带Primary用这个 primary Bean多个候选两个以上带PrimaryNoUniqueBeanDefinitionException提示有多个 primary没有 primary字段名/参数名与某个 Bean 名相同按名字回退选中它没有 primary但注入点带Qualifier按 qualifier 匹配没有 primary字段名也对不上NoUniqueBeanDefinitionException另外一个容易混淆的是Resource(name xxx)。它不是走doResolveDependency这条链路的而是由CommonAnnotationBeanPostProcessor按名字直接找 Bean基本不参与Primary竞争。所以如果你在字段上用Resource把它当成按名字注入来理解就对了。还有一个经常被忽略的细节Primary不是挂在实例上的而是挂在 BeanDefinition 上。AbstractBeanDefinition里有个primary布尔字段配置解析阶段如果发现 Bean 方法或组件类上有Primary就把它设为 true。这解释了为什么覆盖会丢掉 primary——覆盖是整体替换 BeanDefinition旧的字段设置当然跟着没了。4. 三个真实冲突现场代码、报错与根因4.1 现场一覆盖开启后Primary 静默丢失场景是这样项目里有一个MessageSender接口原本有 Sms 和 WeChat 两个实现。Sms 通过Primary当默认实现WeChat 用独立名字。Configuration public class SmsSenderConfiguration { Bean Primary public MessageSender messageSender() { return new SmsMessageSender(); } } Configuration public class WeChatSenderConfiguration { Bean public MessageSender wechatMessageSender() { return new WeChatMessageSender(); } }这个阶段一切正常。某天同事加了一个邮件发送实现他图省事把 Bean 方法名也写成了messageSenderConfiguration public class EmailSenderConfiguration { Bean public MessageSender messageSender() { return new EmailMessageSender(); } }在 Spring Boot 2.1 默认配置下启动直接失败。同事为了快速解决问题往application.yml里加了spring: main: allow-bean-definition-overriding: true启动果然通过了。但此时容器里的messageSender已经变成了EmailMessageSender而且没有Primary。于是下面这个注入点在只有messageSender和wechatMessageSender两个候选且都没有 primary 的情况下开始看字段名回退Service public class NotificationService { private final MessageSender sender; public NotificationService(MessageSender sender) { this.sender sender; } }参数名sender既不匹配messageSender也不匹配wechatMessageSender直接抛NoUniqueBeanDefinitionException。更阴险的变体是如果同事把构造参数名正好写成messageSenderSpring 会通过名字回退把EmailMessageSender静默注入进去整个系统一声不吭。线上该发短信的地方全发了邮件这种事故比启动失败难查十倍。根因覆盖开关解决了启动失败但没有解决默认实现被换掉的事实。Primary标记丢失后所有依赖默认实现的注入点全部悬空。4.2 现场二两个 Primary 的朋友谁都不让谁这个场景和覆盖关系不大但经常被混在一起讨论。假设两个配置类分别声明了不同的 Bean 名却同时都标了PrimaryConfiguration public class SmsSenderConfiguration { Bean Primary public MessageSender smsSender() { return new SmsMessageSender(); } } Configuration public class EmailSenderConfiguration { Bean Primary public MessageSender emailSender() { return new EmailMessageSender(); } }两个 Bean 的名字不冲突覆盖从来不会发生。但当你Autowired MessageSender时determinePrimaryCandidate会发现两个 primary于是抛异常NoUniqueBeanDefinitionException: No qualifying bean of type MessageSender available: expected single matching bean but found 2: emailSender, smsSender注意这个异常信息里有一个关键提示more than one primary bean found among candidates。这就是 多个同类型 Bean 多个 Primary 的组合拳。Primary的设计前提是只有一个事实默认项当两个人都想当老大时容器直接罢工。根因Primary的约束不是可以用但不能多用而是同类型里只能有一个。这个约束由容器在注入时强制执行。4.3 现场三第三方库与业务代码的同名换尸还有一个高频场景来自公共库。假设你们维护了一个user-common-starter里面定义了Configuration public class UserCommonAutoConfiguration { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }业务工程里安全团队又写了Configuration public class AppSecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new CustomPasswordEncoder(); } }结果依然是启动失败。为什么自动配置里的ConditionalOnMissingBean没有生效因为 Spring Boot 的自动配置类是通过DeferredImportSelector加载的处理时机晚于普通用户配置类。如果你的业务配置先注册了passwordEncoder自动配置的条件可以感知到并退让。但如果你和第三库都是普通Configuration它们的处理顺序由配置类解析顺序决定没有先来后到的保障同名定义就撞上了。这个场景的典型诱惑是把库里的 Bean 名改掉。但如果库是第三方的你改不了如果库是自研的你又担心改了名字会影响历史调用方。根因自动配置有 back-off 机制普通库未必有。依赖注入只看类型和名字/qualifier不看这个 Bean 是我写的还是库写的。5. 合理的解决姿势从命名规范到 BeanFactoryPostProcessor5.1 根治一显式命名消灭同名所有覆盖问题的第一道防线是让 Bean 名从一开始就不一样。这是成本最低、效果最稳的方案。Configuration public class SmsSenderConfiguration { Bean(smsMessageSender) Primary public MessageSender smsMessageSender() { return new SmsMessageSender(); } } Configuration public class EmailSenderConfiguration { Bean(emailMessageSender) public MessageSender emailMessageSender() { return new EmailMessageSender(); } }这样两个实现各有独立名字类型相同也不影响共存。谁想当默认实现谁挂Primary谁想精确指定谁用Qualifier(emailMessageSender)。命名规范其实比大多数人想象的重要。我看到很多团队反复踩覆盖坑根源是Bean方法名随意取英文单词就那几个userService、dataSource、restTemplate到处撞。项目里强制统一规则后覆盖问题能减少七八成。5.2 根治二把用哪个的意图写进注入点Primary适合表达默认用哪个但它不是一个排他性的协议。如果你真的需要精确选择某个实现应该把意图明确写在注入点上而不是指望选择容器里的幸运儿。构造器注入里可以这样写Service public class NotificationService { private final MessageSender smsMessageSender; public NotificationService(Qualifier(smsMessageSender) MessageSender smsMessageSender) { this.smsMessageSender smsMessageSender; } }字段注入可以配合Resource(name smsMessageSender)或者干脆Service public class NotificationService { private final ObjectProviderMessageSender senders; public NotificationService(ObjectProviderMessageSender senders) { this.senders senders; } public void notify() { MessageSender primary senders.getIfAvailable(); // 或者遍历所有实现 senders.orderedStream().forEach(sender - sender.send(...)); } }ObjectProvider很适合默认选 primary同时不排除其他实现的场景。它把选择逻辑从容器内部搬到了你的代码里虽然多写几行但语义清楚得多。5.3 根治三顺着条件装配的规矩来让自动配置主动退让如果你是 starter 或者公共模块的作者问题不能只靠使用方改名解决。要让使用方覆盖你的 Bean 时不产生冲突最好把条件装配写完整。Spring Boot 自动配置的推荐做法是用ConditionalOnMissingBean表示使用方已经定义了我就不注册了用ConditionalOnProperty、ConditionalOnClass控制触发范围用AutoConfiguration(after ...)控制与其它自动配置的先后关系如果担心名字撞车Bean 名尽量带模块前缀比如userCommonPasswordEncoder。自动配置因为ConditionalOnMissingBean的存在天然逃避了大部分覆盖冲突。普通Configuration库没有这个保护所以公共库要么改成自动配置要么至少要确保自己的 Bean 名足够独特。看到一个第三方库用了dataSource、restTemplate这种烂大街的名字你就该意识到它迟早给你惹麻烦。5.4 兜底四BeanFactoryPostProcessor 精确手术有些场景是谁也不能改名谁也不能删但必须把 primary 调过来。这时候可以动用BeanFactoryPostProcessor对 BeanDefinition 做定点修改。Component public class PrimaryMarkerFixer implements BeanFactoryPostProcessor { Override public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { DefaultListableBeanFactory dlbf (DefaultListableBeanFactory) beanFactory; BeanDefinition bd dlbf.getBeanDefinition(smsMessageSender); if (bd instanceof AbstractBeanDefinition abd) { abd.setPrimary(true); } } }原理是BeanFactoryPostProcessor的执行时机在全部 BeanDefinition 注册完成之后、Bean 实例化之前而且普通BeanFactoryPostProcessor的执行顺序晚于ConfigurationClassPostProcessor所以能拿到最终的注册结果。但这个方法有几个使用前提你要准确知道目标 Bean 的名字和当前状态getBeanDefinition拿到的必须是同一个AbstractBeanDefinition不要在这里做任何实例化操作否则会打乱容器的初始化顺序。这是一把手术刀不是常规武器。能用命名解决的就用命名不要一上来就改定义。5.5 最后的手段allow-bean-definition-overriding 什么时候真的值得开讲了这么多必须承认有些场景确实必须开spring.main.allow-bean-definition-overridingtrue。典型的场景包括从 Spring Boot 1.x 升级到 2.x/3.x老项目里有大量裸奔的同名 Bean短期改不完接入了闭源第三方库对方的名字无法修改又没有条件装配测试环境需要快速替换某个 Bean 的实现不值得大动干戈。开启前请至少做三件事把logging.level.org.springframework.beans.factory.support.DefaultListableBeanFactory临时调到DEBUG看清到底有哪些覆盖发生在启动日志里确认每次覆盖的旧定义和新定义分别是谁记录覆盖清单给后续清理留一个待办。如果只是为了避免一次启动失败而草率开启那它就是在给运行期埋雷。开启后覆盖日志里出现的每一行都是一个潜在的事故点。我的习惯是能不开就不开开了之后就当自己欠了技术债必须尽快用命名和条件装配把覆盖点清掉。场景推荐做法自己的两个配置类同名显式命名给Bean(xxx)想给多个实现指定默认其中一个加Primary其它用Qualifier精确选择第三方库同名且无法改名用BeanFactoryPostProcessor定位调整必要时才开覆盖开关需要替换自动配置的 Bean遵循ConditionalOnMissingBean换一个不同的名字同类型出现多个 primary只保留一个Primary6. 排查清单按这个顺序找问题最快6.1 先判断你遇到的是哪一种覆盖问题遇到问题别急着改配置先按下面的分支判断启动直接失败报could not be registered ... overriding is disabled→ 禁用覆盖下的同名冲突启动成功但 DEBUG 日志里出现了Overriding bean definition for bean X ...→ 启用覆盖下正在发生替换你还没意识到NoUniqueBeanDefinitionException提示found N: ...→ 这是注入决策问题先看有没有 primary再看字段名NoUniqueBeanDefinitionException提示more than one primary bean found→Primary加多了。判断错误表格现象问题域解决方向启动失败 同名注册定义覆盖改名 / 条件装配调试日志出现覆盖输出定义覆盖审计新旧定义来源注入歧义 无 primaryPrimary缺失指定 primary 或 qualifier注入歧义 多个 primaryPrimary冲突只保留一个 primary6.2 用 Actuator 和临时 Runner 把 Bean 定义摊开来看建议直接把 Spring Boot Actuator 的 beans 端点打开这是排查 bean 问题的第一利器。management: endpoints: web: exposure: include: beans启动后访问/actuator/beans在返回的 JSON 里搜索你的 Bean 名。你会看到每个 Bean 的resource来自哪个配置类、哪个方法、dependencies、scope等关键信息。两个同名定义里只有一个最终存活从 JSON 里就能看出存活的是谁。如果需要更细的信息尤其是 primary 标记直接写一个临时的 RunnerComponent public class BeanDefinitionDumper implements ApplicationRunner { private final ConfigurableApplicationContext context; public BeanDefinitionDumper(ConfigurableApplicationContext context) { this.context context; } Override public void run(ApplicationArguments args) { DefaultListableBeanFactory factory (DefaultListableBeanFactory) context.getAutowireCapableBeanFactory(); for (String beanName : factory.getBeanDefinitionNames()) { BeanDefinition bd factory.getBeanDefinition(beanName); String primary (bd instanceof AbstractBeanDefinition abd) ? String.valueOf(abd.getPrimary()) : N/A; System.out.printf(%s | %s | primary%s%n, beanName, bd.getBeanClassName(), primary); } } }把输出的内容存成文件按类型分组看。两步就能确认三件事哪些 Bean 名重复、最终留的是哪个定义、primary 标记加在谁身上。6.3 别把另一条 BeanPostProcessor 警告混进来排查时还会遇到一条看起来长得差不多的日志Bean xxx of type [...] is not eligible for getting processed by all BeanPostProcessors ...这条和覆盖没有关系。它出现在某个 Bean 在BeanPostProcessor注册完成之前就被提前实例化的时候典型来源是BeanFactoryPostProcessor或BeanDefinitionRegistryPostProcessor里手贱调了getBean。看到它不要往覆盖方向查要查的是谁在容器早期阶段提前初始化了这个 Bean。这两条日志经常在同一次启动里一起出现导致很多人把它们当成同一个问题。分清楚之后排查会少走很多弯路。最后说个我自己的排查习惯处理这类问题我从来不先问Primary 加在哪而是先回答三个问题——这个 Bean 叫什么名字、谁最后写入了这个名字、注入点订阅的到底是类型还是名字。三个问题答完九成的覆盖冲突已经能用命名和Qualifier解决剩下的才轮到覆盖开关和BeanFactoryPostProcessor这种大杀器。Bean 定义覆盖是配置之间的打架Primary只是裁判但裁判判不了名字完全相同的两个人。先把名字理清楚比什么都管用。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询