# Java 高级 ## 0. 目录 - Spring 常见错误笔记 - 业务编写之坑 - 代码编写常见问题 - 代码设计常见问题 - 安全常见问题 ## 2. Spring 常见错误笔记 ### 2.1 Spring Core - SpringBean 定义常见错误 #### A. 案例 1:隐式扫描不到 Bean 的定义 - 因为包结构的定义, 造成相关的定义类扫描出现错误 - **案例解析** - SpringBootApplication 开启了很多功能,其中一个关键功能就是 ComponentScan 注解的 basePackages 属性指定的扫描包的路径 - **问题修复** - 方法1: 检查是否可以手动调节至默认的扫包路径下, ComponentScanAnnotationParser#parse 方法, declaringClass(XxxApplication) 所在的包的路径 - 方法2: 显式配置 @ComponentScans 来修复问题, 确保所有的需要被扫描到的包都被扫描到 - PS: 注意仅仅使用 @ComponentScan 一旦显式指定其它包,原来地默认扫描包就被忽略了. #### B. 案例 2:定义的 Bean 缺少隐式依赖 - 定义 Spring Bean ```java @Service // ServiceImpl 因为标记为 @Service 而成为一个 Bean public class ServiceImpl { private String serviceName; public ServiceImpl(String serviceName){ // ServiceImpl 显式定义了一个构造器 this.serviceName = serviceName; } } ``` > 代码报错: > Parameter 0 of constructor in com.spring.puzzle.class1.example2.ServiceImpl required a bean of type 'java.lang.String' that could not be found. - **案例解析** - 当创建一个 Bean 时,调用的方法是 AbstractAutowireCapableBeanFactory#createBeanInstance - 它主要包含两大基本步骤:寻找构造器和通过反射调用构造器创建实例 ```java // Candidate constructors for autowiring? // Spring 会先执行 determineConstructorsFromBeanPostProcessors 方法来获取构造器 Constructor[] ctors = determineConstructorsFromBeanPostProcessors(beanClass, beanName); if (ctors != null || mbd.getResolvedAutowireMode() == AUTOWIRE_CONSTRUCTOR || mbd.hasConstructorArgumentValues() || !ObjectUtils.isEmpty(args)) { // 然后通过 autowireConstructor 方法带着构造器去创建实例 // autowireConstructor 方法要创建实例,不仅需要知道是哪个构造器,还需要知道构造器对应的参数 return autowireConstructor(beanName, mbd, ctors, args); } } ``` - 参考如下(即 ConstructorResolver#instantiate): ```java private Object instantiate( String beanName, RootBeanDefinition mbd, Constructor constructorToUse, Object[] argsToUse) { ``` - 上述方法中存储构造参数的 argsToUse 如何获取呢? - 当我们已经知道构造器 ServiceImpl(String serviceName),要创建出 ServiceImpl 实例,如何确定 serviceName 的值是多少? - 不能直接显式使用 new 关键字来创建实例 - Spring 只能是去寻找依赖来作为构造器调用参数 - 参数如何获取? ConstructorResolver#autowireConstructor ```java argsHolder = createArgumentArray(beanName, mbd, resolvedValues, bw, paramTypes, paramNames, getUserDeclaredConstructor(candidate), autowiring, candidates.length == 1); ``` - 调用 createArgumentArray 方法来构建调用构造器的参数数组,而这个方法的最终实现是从 BeanFactory 中获取 Bean, org.springframework.beans.factory.support.ConstructorResolver#resolveAutowiredArgument ```java try { return this.beanFactory.resolveDependency( new DependencyDescriptor(param, true), beanName, autowiredBeanNames, typeConverter); } ``` - 上述的调用即是根据参数来寻找对应的 Bean - **问题修正** - 因为不了解很多隐式的规则:我们定义一个类为 Bean,如果再显式定义了构造器,那么这个 Bean 在构建时,会自动根据构造器参数定义寻找对应的 Bean,然后反射创建出这个 Bean. - 了解了这个隐式规则后,解决这个问题就简单多了。我们可以直接定义一个能让 Spring 装配给 ServiceImpl 构造器参数的 Bean ```java //这个bean装配给ServiceImpl的构造器参数“serviceName” @Bean public String serviceName(){ return "MyServiceName"; } ``` - 再次运行程序,发现一切正常了。 - 我们在使用 Spring 时,不要总想着定义的 Bean 也可以在非 Spring 场合直接用 new 关键字显式使用,这种思路是不可取的 - 类似的,假设我们不了解 Spring 的隐式规则,在修正问题后,我们可能写出更多看似可以运行的程序 ```java @Service public class ServiceImpl { private String serviceName; public ServiceImpl(String serviceName){ this.serviceName = serviceName; } public ServiceImpl(String serviceName, String otherStringParameter){ this.serviceName = serviceName; } } ``` - 如果我们仍用非 Spring 的思维去审阅这段代码,可能不会觉得有什么问题,毕竟 String 类型可以自动装配了,无非就是增加了一个 String 类型的参数而已。 - 但是如果你了解 Spring 内部是用反射来构建 Bean 的话,就不难发现问题所在:存在两个构造器,都可以调用时,到底应该调用哪个呢?最终 Spring 无从选择,只能尝试去调用默 认构造器,而这个默认构造器又不存在,所以测试这个程序它会出错。 - PS: 构造器的参数添加@Autowired(required = false) - PS: @Scope(value=ConfigurableBeanFactory.SCOPE_PROTOTYPE)这个是说在每次注入的时候回自动创建一个新的bean实例 @Scope(value=ConfigurableBeanFactory.SCOPE_SINGLETON)单例模式,在整个应用中只能创建一个实例。 - PS: @Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS) --- #### C. 案例 3:原型 Bean 被固定 - 在定义 Bean 时,有时候我们会使用原型 Bean,例如定义如下: ```java @Service @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class ServiceImpl { } ``` - 然后我们按照下面的方式去使用它: ```java @RestController public class HelloWorldController { @Autowired private ServiceImpl serviceImpl; @RequestMapping(path = "hi", method = RequestMethod.GET) public String hi(){ return "helloworld, service is : " + serviceImpl; } } ``` - 结果,我们会发现,不管我们访问多少次http://localhost:8080/hi,访问的结果都是不变的 > helloworld, service is : com.spring.puzzle.class1.example3.error.ServiceImpl@4908af - 很明显,这很可能和我们定义 ServiceImpl 为原型 Bean 的初衷背道而驰 - **案例解析** - 当一个属性成员 serviceImpl 声明为 @Autowired 后,那么在创建 HelloWorldController 这个 Bean 时,会先使用构造器反射出实例,然后来装配各个标记 为 @Autowired 的属性成员(装配方法参考 AbstractAutowireCapableBeanFactory#populateBean) - 具体到执行过程,它会使用很多 BeanPostProcessor 来做完成工作,其中一种是 AutowiredAnnotationBeanPostProcessor, 它会通过 DefaultListableBeanFactory#findAutowireCandidates 寻找到 ServiceImpl 类型的 Bean,然后设置给对应的属性(即 serviceImpl 成员) - 关键执行步骤可参考 AutowiredAnnotationBeanPostProcessor.AutowiredFieldElement#inject: ```java @Override protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable { Field field = (Field) this.member; Object value; //寻找 “bean” if (this.cached) { value = resolvedCachedArgument(beanName, this.cachedFieldValue); } else { // ... try { value = beanFactory.resolveDependency(desc, beanName, autowiredBeanNames, typeConverter); } catch (BeansException ex) { throw new UnsatisfiedDependencyException(null, beanName, new InjectionPoint(field), ex); } // ... } if (value != null) { //将bean设置给成员字段 ReflectionUtils.makeAccessible(field); field.set(bean, value); } } } ``` - 待我们寻找到要自动注入的 Bean 后,即可通过反射设置给对应的 field.这个 field 的执行只发生了一次,所以后续就固定起来了,这个 field 的执 行只发生了一次,所以后续就固定起来了 - 所以,当一个单例的 Bean,使用 autowired 注解标记其属性时,你一定要注意这个属性值会被固定下来。 - **问题修正** - 我们可以知道要修正这个问题,肯定是不能将 ServiceImpl 的 Bean 固定到属性上的,而应该是每次使用时都会重新获取一次。 - **修复方法1**: 自动注入 Context - 即自动注入 ApplicationContext,然后定义 getServiceImpl() 方法,在方法中获取一个新的 ServiceImpl 类型实例。修正代码如下: ```java @RestController public class HelloWorldController { @Autowired private ApplicationContext applicationContext; @RequestMapping(path = "hi", method = RequestMethod.GET) public String hi(){ return "helloworld, service is : " + getServiceImpl(); } public ServiceImpl getServiceImpl(){ return applicationContext.getBean(ServiceImpl.class); } ``` - **修复方法2**: 使用 Lookup 注解 - 类似修正方法 1,也添加一个 getServiceImpl 方法,不过这个方法是被 Lookup 标记的。 - 修正代码如下: ```java @RestController public class HelloWorldController { @RequestMapping(path = "hi", method = RequestMethod.GET) public String hi(){ return "helloworld, service is : " + getServiceImpl(); } @Lookup public ServiceImpl getServiceImpl(){ return null; } } ``` - 通过这两种修正方式,再次测试程序,我们会发现结果已经符合预期(每次访问这个接口,都会创建新的 Bean)。 - 讨论下 Lookup 是如何生效的。毕竟在修正代码中,我们看到 getServiceImpl 方法的实现返回值是 null,这或许很难说服自己。 - 通过 Debug 我们最终的执行因为标记了 Lookup 而走入了 CglibSubclassingInstantiationStrategy.LookupOverrideMethodInterceptor,这个方 法的关键实现参考 LookupOverrideMethodInterceptor#intercept: ```java @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy mp) throws Throwable { // Cast is safe, as CallbackFilter filters are used selectively. LookupOverride lo = (LookupOverride) getBeanDefinition().getMethodOverrides().getOverride(method); Assert.state(lo != null, "LookupOverride not found"); Object[] argsToUse = (args.length > 0 ? args : null); // if no-arg, don't insist on args at all if (StringUtils.hasText(lo.getBeanName())) { return (argsToUse != null ? this.owner.getBean(lo.getBeanName(), argsToUse) : this.owner.getBean(lo.getBeanName())); } else { return (argsToUse != null ? this.owner.getBean(method.getReturnType(), argsToUse) : this.owner.getBean(method.getReturnType())); } } } ``` - 我们的方法调用最终并没有走入案例代码实现的 return null 语句,而是通过 BeanFactory 来获取 Bean。 - 其实在我们的 getServiceImpl 方法实现中,随便怎么写都行,这不太重要。 - 为什么我们走入了 CGLIB 搞出的类,这是因为我们有方法标记了Lookup。我们可以从下面的这段代码得到验证,参考 SimpleInstantiationStrategy#instantiate: ```java @Override public Object instantiate(RootBeanDefinition bd, @Nullable String beanName, BeanFactory owner) { // Don't override the class with CGLIB if no overrides. // 当 hasMethodOverrides 为 true 时,则使用 CGLIB if (!bd.hasMethodOverrides()) { // ... return BeanUtils.instantiateClass(constructorToUse); } else { // Must generate CGLIB subclass. return instantiateWithMethodInjection(bd, beanName, owner); } } ``` - 条件的成立在于解析 HelloWorldController 这个 Bean 时,我们会发现有方法标记了 Lookup,此时就会添加相应方法到属性 methodOverrides 里面去 (此过程由AutowiredAnnotationBeanPostProcessor#determineCandidateConstructors 完成) ### 2.2 Spring Core - Spring Bean 依赖注入常见错误 - Spring @Autowired “控制反转、依赖注入” #### A. 案例 1:过多赠予,无所适从 - @Autowired 时,不管你是菜鸟级还是专家级的 Spring 使用者,都应该制造或者遭遇过类似的错误: > required a single bean, but 2 were found - 我们仅需要一个 Bean,但实际却提供了 2 个(这里的“2”在实际错误中可能是其它大于 1 的任何数字) - **案例解析** - 要找到这个问题的根源,我们就需要对 @Autowired 实现的依赖注入的原理有一定地了解。首先,我们先来了解下 @Autowired 发生的位置和核心过程。 - 首先,我们先来了解下 @Autowired 发生的位置和核心过程。 - 当一个 Bean 被构建时,核心包括两个基本步骤: - 执行 AbstractAutowireCapableBeanFactory#createBeanInstance 方法:通过构造器反射构造出这个 Bean,在相当于构建出 业务Controller 的实例; - 执行 AbstractAutowireCapableBeanFactory#populate 方法:填充(即设置)这个Bean,相当于设置 Controller 实例中被 @Autowired 标记的 Service 属性成员 - 在步骤 2 中,“填充”过程的关键就是执行各种 BeanPostProcessor 处理器,关键代码: ```java @SuppressWarnings("deprecation") // for postProcessPropertyValues protected void populateBean(String beanName, RootBeanDefinition mbd, @Nullable BeanWrapper bw) { // ... if (hasInstAwareBpps) { if (pvs == null) { pvs = mbd.getPropertyValues(); } for (BeanPostProcessor bp : getBeanPostProcessors()) { if (bp instanceof InstantiationAwareBeanPostProcessor) { InstantiationAwareBeanPostProcessor ibp = (InstantiationAwareBeanPostProcessor) bp; PropertyValues pvsToUse = ibp.postProcessProperties(pvs, bw.getWrappedInstance(), beanName); // ... } } } // ... } ``` - 因为 Controller 含有标记为 Autowired 的成员属性 Service,所以会使用到 AutowiredAnnotationBeanPostProcessor(BeanPostProcessor 中的一种)来完 成“装配”过程:找出合适的 Service 的 bean 并设置给 Controller#Service。如果深究这个装配过程,又可以细分为两个步骤: - 寻找出所有需要依赖注入的字段和方法,参考 AutowiredAnnotationBeanPostProcessor#postProcessProperties 中的代码行: - ```java InjectionMetadata metadata = findAutowiringMetadata(beanName, bean.getClass(), pvs); ``` - 根据依赖信息寻找出依赖并完成注入,以字段注入为例,参考 AutowiredFieldElement#inject 方法: - ```java @Override protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable { Field field = (Field) this.member; Object value; if (this.cached) { value = resolvedCachedArgument(beanName, this.cachedFieldValue); } else { // ... try { // 寻找“依赖”,desc为"自定义Service"的DependencyDescriptor value = beanFactory.resolveDependency(desc, beanName, autowiredBeanNames, typeConverter); } // ... if (value != null) { ReflectionUtils.makeAccessible(field); // //装配“依赖” field.set(bean, value); } } } ``` - DefaultListableBeanFactory#doResolveDependency 中代码片段可以查看实际装配了什么依赖 - 如果同时满足以下两个条件则会抛出本案例的错误: - 调用 determineAutowireCandidate 方法来选出优先级最高的依赖,但是发现并没有优先级可依据。具体选择过程可参考 DefaultListableBeanFactory#determineAutowireCandidate: ```java @Nullable protected String determineAutowireCandidate(Map candidates, DependencyDescriptor descriptor) { Class requiredType = descriptor.getDependencyType(); // @Primary String primaryCandidate = determinePrimaryCandidate(candidates, requiredType); if (primaryCandidate != null) { return primaryCandidate; } String priorityCandidate = determineHighestPriorityCandidate(candidates, requiredType); if (priorityCandidate != null) { return priorityCandidate; } // Fallback for (Map.Entry entry : candidates.entrySet()) { String candidateName = entry.getKey(); Object beanInstance = entry.getValue(); if ((beanInstance != null && this.resolvableDependencies.containsValue(beanInstance)) || matchesBeanName(candidateName, descriptor.getDependencyName())) { return candidateName; } } return null; } ``` - 如代码所示,优先级的决策是先根据 @Primary 来决策,其次是 @Priority 决策, 最后是根据 Bean 名字的严格匹配来决策。 - 如果这些帮助决策优先级的注解都没有被使用,名字也不精确匹配,则返回 null,告知无法决策出哪种最合适。 - @Autowired 要求是必须注入的(即 required 保持默认值为 true),或者注解的属性类型并不是可以接受多个 Bean 的类型,例如数组、Map、集合。这点可以参考 DefaultListableBeanFactory#indicatesMultipleBeans 的实现: ```java private boolean indicatesMultipleBeans(Class type) { return (type.isArray() || (type.isInterface() && (Collection.class.isAssignableFrom(type) || Map.class.isAssignableFro } ``` - 如果我们把这些条件想得简单点,或许更容易帮助我们去理解这个设计.就像我们遭遇多个无法比较优劣的选择,却必须选择其一时,与其偷偷地随便选择一种,还不如直接报错,起码可以避免更严重的问题发生。 - **问题修正** - 找到解决问题的方法:打破上述两个条件中的任何一个即可,即让候选项具有优先级或压根可以不去选择 - 不过需要你注意的是,不是每一种条件的打破都满足实际需求,例如我们可以通过使用标记 @Primary 的方式来让被标记的候选者有更高优先级,从而避免报错,但是它并不一定符合业务需求,这就好 比我们本身需要两种数据库都能使用,而不是顾此失彼。 ```java @Repository @Primary @Slf4j public class OracleDataService implements DataService{ //省略非关键代码 } ``` - 现在,请你仔细研读上述的两个条件,要同时支持多种 DataService,且能在不同业务情景下精确匹配到要选择到的 DataService,我们可以使用下面的方式去修改: ```java @Autowired DataService oracleDataService; ```` - 如代码所示,修改方式的精髓在于将属性名和 Bean 名字精确匹配,这样就可以让注入选择不犯难:需要 Oracle 时指定属性名为 oracleDataService,需要 Cassandra 时则指定属性名为 cassandraDataService - PS 这里可以使用注解进行统一使用什么数据库的配置, 也就是自动进行注入使用什么依赖 #### B. 显式引用 Bean 时首字母忽略大小写 - 针对案例 1 的问题修正,实际上还存在另外一种常用的解决办法,即采用 @Qualifier 来显式指定引用的是那种服务,例如采用下面的方式: ```java @Autowired() @Qualifier("cassandraDataService") DataService dataService; ``` 这种方式之所以能解决问题,在于它能让寻找出的 Bean 只有一个(即精确匹配),所以压根不会出现后面的决策过程,可以参考 DefaultListableBeanFactory#doResolveDependency: ```java @Nullable public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName, @Nullable Set autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException { // ... // 寻找 Bean 的过程 Map matchingBeans = findAutowireCandidates(beanName, type, descriptor); if (matchingBeans.isEmpty()) { if (isRequired(descriptor)) { raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor); } return null; } String autowiredBeanName; Object instanceCandidate; if (matchingBeans.size() > 1) { // 省略多个bean的决策过程 } else { // We have exactly one match. Map.Entry entry = matchingBeans.entrySet().iterator().next(); autowiredBeanName = entry.getKey(); instanceCandidate = entry.getValue(); } // ... } ``` - 我们会使用 @Qualifier 指定的名称去匹配,最终只找到了唯一一个。[这个可以作为定义Bean的一种规范写在程序中, 最好是定义在一个类文件中,这样就避免了装配问题的发生] - 不过在使用 @Qualifier 时,我们有时候会犯另一个经典的小错误,就是我们可能会忽略 Bean 的名称首字母大小写。这里我们把校正后的案例稍稍变形如下: ```java @Autowired @Qualifier("CassandraDataService") DataService dataService; ``` - 这样会继续报错 - 因为: 对于 Bean 的名字,如果没有显式指明,就应该是类名,不过首字母应该小写。 - PS: 可以配合 Bean的 value和name配置别名,但是两个属性不能共存,也不能使用特殊符号,也不能同时配置多个别名 - **案例解析** - 当因为名称问题(例如引用 Bean 首字母搞错了)找不到 Bean 时,会直接抛出 NoSuchBeanDefinitionException。 - 在这里,我们真正需要关心的问题是:不显式设置名字的 Bean,其默认名称首字母到底是大写还是小写呢? - 当我们启动基于 Spring Boot 的应用程序时,会自动扫描我们的Package,以找出直接或间接标记了 @Component 的 Bean 的定义(即BeanDefinition)。 例如 CassandraDataService、SQLiteDataService 都被标记了 @Repository,而 Repository 本身被 @Component 标记,所以它们都是间接标记了 @Component。 - 一旦找出这些 Bean 的信息,就可以生成这些 Bean 的名字,然后组合成一个个BeanDefinitionHolder 返回给上层。这个过程关键步骤可以查看下图的代码片段 (ClassPathBeanDefinitionScanner#doScan): - ![显式引用Bean时首字母忽略大小写](pic/显式引用Bean时首字母忽略大小写.png) - 基本匹配我们前面描述的过程,其中方法调用BeanNameGenerator#generateBeanName 即用来产生 Bean 的名字,它有两种实现方式。因为 DataService 的实现都是使用注解标记的, 式。因为 DataService 的实现都是使用注解标记的,的其实是 AnnotationBeanNameGenerator#generateBeanName 这种实现方式,我们可以看下它的具体实现,代码如下: ```java @Override public String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry) { if (definition instanceof AnnotatedBeanDefinition) { String beanName = determineBeanNameFromAnnotation((AnnotatedBeanDefinition) definition); if (StringUtils.hasText(beanName)) { // Explicit bean name found. return beanName; } } // Fallback: generate a unique default bean name. return buildDefaultBeanName(definition, registry); } ``` - 大体流程只有两步:看 Bean 有没有显式指明名称,如果有则用显式名称,如果没有则产生一个默认名称。 - 很明显,在我们的案例中,是没有给 Bean 指定名字的,所以产生的 Bean 的名称就是生成的默认名称,查看默认名的产生方法 buildDefaultBeanName,其 实现如下: ```java protected String buildDefaultBeanName(BeanDefinition definition) { String beanClassName = definition.getBeanClassName(); Assert.state(beanClassName != null, "No bean class name set"); String shortClassName = ClassUtils.getShortName(beanClassName); return Introspector.decapitalize(shortClassName); } ``` - 首先,获取一个简短的 ClassName,然后调用 Introspector#decapitalize 方法,设置首字母大写或小写,具体参考下面的代码实现: ```java public static String decapitalize(String name) { if (name == null || name.length() == 0) { return name; } if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) && Character.isUpperCase(name.charAt(0))){ return name; } char chars[] = name.toCharArray(); chars[0] = Character.toLowerCase(chars[0]); return new String(chars); } ``` - 到这,我们很轻松地明白了前面两个问题出现的原因:**如果一个类名是以两个大写字母开头的,则首字母不变,其它情况下默认首字母变成小写。** - 结合我们之前的案例,SQLiteDataService 的 Bean,其名称应该就是类名本身,而 CassandraDataService 的Bean 名称则变成了首字母小写(cassandraDataService) - **问题修正** - 方法1 : 引用处纠正首字母大小写问题: ```java @Autowired @Qualifier("cassandraDataService") DataService dataService; ``` - 方法2 : 定义处显式指定 Bean 名字,我们可以保持引用代码不变,而通过显式指明CassandraDataService 的 Bean 名称为 CassandraDataService 来纠正这个问题 ```java @Repository("CassandraDataService") @Slf4j public class CassandraDataService implements DataService { //省略实现 } ``` - 我们的程序就可以精确匹配到要找的 Bean 了。比较一下这两种修改方法的话,如果你不太了解源码,不想纠结于首字母到底是大写还是小写,建议你用第二种方法去避免困扰。 #### C. 引用内部类的 Bean 遗忘类名 - 我们需要定义一个内部类来实现一种新的 DataService,代码如下: ```java public class StudentController { @Repository public static class InnerClassDataService implements DataService{ @Override public void deleteStudent(int id) { //空实现 } } //省略其他非关键代码 } ``` - 遇到这种情况,我们一般都会很自然地用下面的方式直接去显式引用这个 Bean: ```java @Autowired @Qualifier("innerClassDataService") DataService innerClassDataService; ``` - 很明显,有了案例 2 的经验,我们上来就直接采用了首字母小写以避免案例 2 中的错误 但这样的代码是不是就没问题了呢?实际上,仍然会报错“找不到 Bean”,这是为什么? - **案例解析** - 实际上,我们遭遇的情况是“如何引用内部类的 Bean”。解析案例 2 的时候,我曾经贴出了如何产生默认 Bean 名的方法(即AnnotationBeanNameGenerator#buildDefaultBeanName) 当时我们只关注了首字母是否小写的代码片段,而在最后变换首字母之前,有一行语句是对 class 名字的处理,代码如下: ```java String shortClassName = ClassUtils.getShortName(beanClassName); ``` - 我们可以看下它的实现,参考 ClassUtils#getShortName 方法: ```java public static String getShortName(String className) { Assert.hasLength(className, "Class name must not be empty"); int lastDotIndex = className.lastIndexOf(PACKAGE_SEPARATOR); int nameEndIndex = className.indexOf(CGLIB_CLASS_SEPARATOR); if (nameEndIndex == -1) { nameEndIndex = className.length(); } String shortName = className.substring(lastDotIndex + 1, nameEndIndex); shortName = shortName.replace(INNER_CLASS_SEPARATOR, PACKAGE_SEPARATOR); return shortName; } ``` - 很明显,假设我们是一个内部类,例如下面的类名: - com.spring.puzzle.class2.example3.StudentController.InnerClassDataService - 在经过这个方法的处理后,我们得到的其实是下面这个名称: - StudentController.InnerClassDataService - 最后经过 Introspector.decapitalize 的首字母变换,最终获取的 Bean 名称如下: - studentController.InnerClassDataService - 所以我们在案例程序中,直接使用 innerClassDataService 自然找不到想要的 Bean - **问题修正** - 上面源码的跟踪结果显示, 通过下面的方法进行修复 ```java @Autowired @Qualifier("studentController.InnerClassDataService") DataService innerClassDataService; ``` - 这个引用看起来有些许奇怪,但实际上是可以工作的,反而直接使用innerClassDataService 来引用倒是真的不可行。 - **对源码的学习是否全面决定了我们以后犯错的可能性大小** #### D. @Value 没有注入预期的值 - @Value 必须指定一个字符串值,因为其定义做了要求,定义代码如下: ```java public @interface Value { /** * The actual value expression — for example, #{systemPropertie */ String value(); } ``` - 我们一般都会因为 @Value 常用于 String 类型的装配而误以为 @Value 不能用于非内置对象的装配,实际上这是一个常见的误区 - 例如,我们可以使用下面这种方式来 Autowired 一个属性成员: ```java @Value("#{student}") private Student student; ``` - 其中 student 这个 Bean 定义如下: ```java @Bean public Student student(){ Student student = createStudent(1, "xie"); return student; } ``` - 使用 @Value 更多是用来装配 String,而且它支持多种强大的装配方式,典型的方式参考下面的示例: ```java //注册正常字符串 @Value("我是字符串") private String text; //注入系统参数、环境变量或者配置文件中的值 @Value("${ip}") private String ip //注入其他Bean属性,其中student为bean的ID,name为其属性 @Value("#{student.name}") private String name; ``` - **异常案例** - application.properties ```properties username=admin password=pass ``` ```java @RestController @Slf4j public class ValueTestController { @Value("${username}") private String username; @Value("${password}") private String password; @RequestMapping(path = "user", method = RequestMethod.GET) public String getUser(){ return username + ":" + password; } } ``` - 我们去打印上述代码中的 username 和 password 时,我们会发现 password 正确返回了,但是 username 返回的并不是配置文件中指明的 admin - **案例解析** - Spring 是如何根据 @Value 来查询“值”的 - 通过方法 DefaultListableBeanFactory#doResolveDependency 来了解 @Value 的核心工作流程,代码如下: ```java @Nullable public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName, @Nullable Set autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException { // ... Class type = descriptor.getDependencyType(); // 寻找 @Value Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor); if (value != null) { if (value instanceof String) { // 解析 @Value String strVal = resolveEmbeddedValue((String) value); BeanDefinition bd = (beanName != null && containsBean(beanName) ? getMergedBeanDefinition(beanName) : null); value = evaluateBeanDefinitionString(strVal, bd); } // 转化 Value 解析的结果到装配的类型 TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter()); try { return converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor()); } catch (UnsupportedOperationException ex) { // 异常处理 } } // ... } ``` - @Value 的工作大体分为以下三个核心步骤。 - 寻找 @Value,在这步中,主要是判断这个属性字段是否标记为 @Value - 依据的方法参考 QualifierAnnotationAutowireCandidateResolver#findValue: ```java @Nullable protected Object findValue(Annotation[] annotationsToSearch) { if (annotationsToSearch.length > 0) { // qualifier annotations have to be local AnnotationAttributes attr = AnnotatedElementUtils.getMergedAnnotationAttributes( AnnotatedElementUtils.forAnnotations(annotationsToSearch), this.valueAnnotationType); //valueAnnotationType即为@Value if (attr != null) { return extractValue(attr); } } return null; } ``` - 解析 @Value 的字符串值 - 如果一个字段标记了 @Value,则可以拿到对应的字符串值,然后就可以根据字符串值去做解析,最终解析的结果可能是一个字符串,也可能是一个对象,这取决于字符串怎么写。 - 将解析结果转化为要装配的对象的类型 - 当拿到第二步生成的结果后,我们会发现可能和我们要装配的类型不匹配。假设我们定义的是 UUID,而我们获取的结果是一个字符串,那么这个时候就会根据目标类型来寻找转 化器执行转化,字符串到 UUID 的转化实际上发生在 UUIDEditor 中: ```java public class UUIDEditor extends PropertyEditorSupport { @Override public void setAsText(String text) throws IllegalArgumentException if (StringUtils.hasText(text)) { //转化操作 setValue(UUID.fromString(text.trim())); }else { setValue(null); } } //... } ``` - 解析 Value 指定字符串过程 ```java String strVal = resolveEmbeddedValue((String) value); ``` - 这里其实是在解析嵌入的值,实际上就是“替换占位符”工作。具体而言,它采用的是PropertySourcesPlaceholderConfigurer 根据 PropertySources 来替换。 - 不过当使用${username} 来获取替换值时,其最终执行的查找并不是局限在 application.property 文件中的。 - 而具体的查找执行,我们可以通过下面的代码 (PropertySourcesPropertyResolver#getProperty)来获取它的执行方式: ```java @Nullable protected T getProperty(String key, Class targetValueType, boolean resolveNestedPlaceholders) { if (this.propertySources != null) { for (PropertySource propertySource : this.propertySources) { if (logger.isTraceEnabled()) { logger.trace("Searching for key '" + key + "' in PropertySource '" + propertySource.getName() + "'"); } Object value = propertySource.getProperty(key); if (value != null) { if (resolveNestedPlaceholders && value instanceof String) { value = resolveNestedPlaceholders((String) value); } logKeyFound(key, propertySource, value); // 查到 Value 就退出 return convertValueIfNecessary(value, targetValueType); } } } if (logger.isTraceEnabled()) { logger.trace("Could not find key '" + key + "' in any property source"); } return null; } ``` - 在解析 Value 字符串时,其实是有顺序的(查找的源是存在CopyOnWriteArrayList 中,在启动时就被有序固定下来),一个一个“源”执行查找。在其中一个源找到后,就可以直接返回了。 - 查看 systemEnvironment 这个源,会发现刚好有一个 username 和我们是重合的,且值不是 pass。 - 刚好系统环境变量(systemEnvironment)中含有同名的配置。实际上,对于系统参数(systemProperties)也是一样的,这些参数或者变量都有很多,如果我们没有意识到它 的存在,起了一个同名的字符串作为 @Value 的值,则很容易引发这类问题。 - **问题修正** - 一定要注意**不仅要避免和环境变量冲突,也要注意避免和系统变量等其他变量冲突** #### E. 错乱的注入集合 - 集合类型的自动注入是 Spring 提供的另外一个强大功能。 - 存在多个学生 Bean,我们需要找出来,并存储到一个 List里面去。多个学生 Bean 的定义如下: ```java @Bean public Student student1(){ return createStudent(1, "xie"); } @Bean public Student student2(){ return createStudent(2, "fang"); } private Student createStudent(int id, String name) { Student student = new Student(); student.setId(id); student.setName(name); return student; } ``` - 有了集合类型的自动注入后,我们就可以把零散的学生 Bean 收集起来了,代码示例如 ```java @RestController @Slf4j public class StudentController { private List students; public StudentController(List students){ this.students = students; } @RequestMapping(path = "students", method = RequestMethod.GET) public String listStudents(){ return students.toString(); } } ``` - 通过上述代码,我们就可以完成集合类型的注入工作,输出结果如下: > [Student(id=1, name=xie), Student(id=2, name=fang)] - 当我们持续增加一些 student 时,可能就不喜欢用这种方式来注入集合类型了,而是倾向于用下面的方式去完成注入工作: ```java @Bean public List students(){ Student student3 = createStudent(3, "liu"); Student student4 = createStudent(4, "fu"); return Arrays.asList(student3, student4); } ``` - 我们不妨将上面这种方式命名为“直接装配方式”,而将之前的那种命名为“收集方式”。 - 如果我们不小心让这 2 种方式同时存在了,结果会怎样? - 然而,当我们运行起程序,就会发现后面的注入方式根本没有生效。即依然返回的是前面定义的 2 个学生。为什么会出现这样的错误呢? - **案例解析** - 收集装配风格,Spring 使用的是 DefaultListableBeanFactory#resolveMultipleBeans ```java @Nullable private Object resolveMultipleBeans(DependencyDescriptor descriptor, @Nullable String beanName, @Nullable Set autowiredBeanNames, @Nullable TypeConverter typeConverter) { Class type = descriptor.getDependencyType(); if (descriptor instanceof StreamDependencyDescriptor) { // 装配 stream Map matchingBeans = findAutowireCandidates(beanName, type, descriptor); if (autowiredBeanNames != null) { autowiredBeanNames.addAll(matchingBeans.keySet()); } Stream stream = matchingBeans.keySet().stream() .map(name -> descriptor.resolveCandidate(name, type, this)) .filter(bean -> !(bean instanceof NullBean)); if (((StreamDependencyDescriptor) descriptor).isOrdered()) { stream = stream.sorted(adaptOrderComparator(matchingBeans)); } return stream; } else if (type.isArray()) { // 装配 数组 Class componentType = type.getComponentType(); ResolvableType resolvableType = descriptor.getResolvableType(); Class resolvedArrayType = resolvableType.resolve(type); if (resolvedArrayType != type) { componentType = resolvableType.getComponentType().resolve(); } if (componentType == null) { return null; } Map matchingBeans = findAutowireCandidates(beanName, componentType, new MultiElementDescriptor(descriptor)); if (matchingBeans.isEmpty()) { return null; } if (autowiredBeanNames != null) { autowiredBeanNames.addAll(matchingBeans.keySet()); } TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter()); Object result = converter.convertIfNecessary(matchingBeans.values(), resolvedArrayType); if (result instanceof Object[]) { Comparator comparator = adaptDependencyComparator(matchingBeans); if (comparator != null) { Arrays.sort((Object[]) result, comparator); } } return result; } else if (Collection.class.isAssignableFrom(type) && type.isInterface()) { //装配集合 //获取集合的元素类型 Class elementType = descriptor.getResolvableType().asCollection().resolveGeneric(); if (elementType == null) { return null; } // 根据元素类型查找所有的bean Map matchingBeans = findAutowireCandidates(beanName, elementType, new MultiElementDescriptor(descriptor)); if (matchingBeans.isEmpty()) { return null; } if (autowiredBeanNames != null) { autowiredBeanNames.addAll(matchingBeans.keySet()); } // 转化查到的所有bean放置到集合并返回 TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter()); Object result = converter.convertIfNecessary(matchingBeans.values(), type); // ... return result; } else if (Map.class == type) { // 解析map // ... return matchingBeans; } else { return null; } } ``` - 到这,我们就不难概括出这种收集式集合装配方式的大体过程了 - 1- 获取集合类型的元素类型 - 目标类型定义为 List students,所以元素类型为 Student,获取的具体方法参考代码行: - Class elementType = descriptor.getResolvableType().asCollection().resolveGeneric(); - 2- 根据元素类型,找出所有的 Bean - 有了上面的元素类型,即可根据元素类型来找出所有的 Bean,关键代码行如下: - Map matchingBeans = findAutowireCandidates(beanName,elementType, new MultiElementDescriptor(descriptor)); - 3- 将匹配的所有的 Bean 按目标类型进行转化 - 经过步骤 2,我们获取的所有的 Bean 都是以 java.util.LinkedHashMap.LinkedValues 形式存储的,和我们的目标类型大概率不同,所以最后一步需要做的是按需转化。 - 中,我们就需要把它转化为 List,转化的关键代码如下: - Object result = converter.convertIfNecessary(matchingBeans.values(), type); - 两种装配集合的方式是不能同存的,结合本案例,当使用收集装配方式来装配时,能找到任何一个对应的 Bean,则返回,如果一个都没有找到,才会采用直接装配的方式。 - **问题修复** - **在对于同一个集合对象的注入上,混合多种注入方式是不可取的,这样除了错乱,别无所得。** ### 2.3 Spring Core - Spring Bean生命周期常见错误 #### A. 构造器内抛空指针异常 - 在构建宿舍管理系统时,有 LightMgrService 来管理 LightService,从而控制宿舍灯的开启和关闭。我们希望在 LightMgrService 初始化时能够自动调用 LightService 的 check 方法来检查所有宿舍灯的电路是否正常,代码如下: ```java import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class LightMgrService { @Autowired private LightService lightService; public LightMgrService() { lightService.check(); } } ``` - 我们在 LightMgrService 的默认构造器中调用了通过 @Autoware 注入的成员变量 - LightService 的 check 方法: ```java @Service public class LightService { public void start() { System.out.println("turn on all lights"); } public void shutdown() { System.out.println("turn off all lights"); } public void check() { System.out.println("check all lights"); } } ``` - 以上代码定义了 LightService 对象的原始类。 - 从整个案例代码实现来看,我们的期待是在 LightMgrService 初始化过程中,LightService 因为标记为 @Autowired,所以能被自动装配好;然后在 LightMgrService 的构造器执行中,LightService 的 shutdown() 方法能被自动调用;最终打印出 check all lights。 - 然而事与愿违,我们得到的只会是 NullPointerException - **案例解析** - Spring 类初始化过程没有足够的了解 - ![Spring类初始化](pic/Spring类初始化.png) - 这个图初看起来复杂,我们不妨将其分为三部分: - 第一部分,将一些必要的系统类,比如 Bean 的后置处理器类,注册到 Spring 容器,其中就包括我们这节课关注的 CommonAnnotationBeanPostProcessor 类; - 第二部分,将这些后置处理器实例化,并注册到 Spring 的容器中; - 第三部分,实例化所有用户定制类,调用后置处理器进行辅助装配、类初始化等等。 - 第一部分和第二部分并非是我们今天要讨论的重点,这里仅仅是为了让你知道CommonAnnotationBeanPostProcessor 这个后置处理类是何时被 Spring 加载和实例化的。 - 拓展两个知识点: - 很多必要的系统类,尤其是 Bean 后置处理器(比如CommonAnnotationBeanPostProcessor、AutowiredAnnotationBeanPostProcessor 等),都是被 Spring 统一加载和管理的, 并在 Spring 中扮演了非常重要的角色; - 通过 Bean 后置处理器,Spring 能够非常灵活地在不同的场景调用不同的后置处理器,比如接下来我会讲到示例问题如何修正,修正方案中提到的 PostConstruct 注解,它的处理逻辑就需要用到 CommonAnnotationBeanPostProcessor(继承自InitDestroyAnnotationBeanPostProcessor)这个后置处理器。 - 我们重点看下第三部分,即 Spring 初始化单例类的一般过程,基本都是 getBean()->doGetBean()->getSingleton(),如果发现 Bean 不存在,则调用 createBean()- doCreateBean() 进行实例化 - 查看 doCreateBean() 的源代码如下: - Bean 初始化的三个关键步骤 - createBeanInstance 实例化 Bean - populateBean 注入 Bean 依赖 - initializeBean 初始化 Bean - 执行 @PostConstruct 标记的方法 这三个功能,这也和上述时序图的流程相符。 - 而用来实例化 Bean 的 createBeanInstance 方法通过依次调用 DefaultListableBeanFactory.instantiateBean() > SimpleInstantiationStrategy.instantiate(),最终执行到 BeanUtils.instantiateClass() - 这里因为当前的语言并非 Kotlin,所以最终将调用 ctor.newInstance() 方法实例化用户定制类 LightMgrService,而默认构造器显然是在类实例化的时候被自动调用的,Spring 也 无法控制。而此时负责自动装配的 populateBean 方法还没有被执行,LightMgrService 的属性 LightService 还是 null,因而得到空指针异常也在情理之中。 - **问题修正** - 问题根源: **使用 @Autowired 直接标记在成员属性上而引发的装配行为是发生在构造器执行之后的** - **修正代码1** ```java @Component public class LightMgrService { private LightService lightService; public LightMgrService(LightService lightService) { this.lightService = lightService; lightService.check(); } } ``` - 当使用上面的代码时,构造器参数 LightService 会被自动注入 LightService 的 Bean,从而在构造器执行时,不会出现空指针。可以说,**使用构造器参数来隐式注入**是一种 Spring 最佳实践 - **修正代码2** - 添加 init 方法,并且使用 PostConstruct 注解进行修饰: ```java import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class LightMgrService { @Autowired private LightService lightService; @PostConstruct public void init() { lightService.check(); } } ``` - - **修正代码3** - 实现 InitializingBean 接口,在其 afterPropertiesSet() 方法中执行初始化代码: ```java import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class LightMgrService implements InitializingBean { @Autowired private LightService lightService; @Override public void afterPropertiesSet() throws Exception { lightService.check(); } } ``` - 后续的两种方案并不是最优的,但是在一些场景下,这两种方案各有所长 #### B. 意外触发 shutdown 方法 - 在类销毁时,也会有一些相对隐蔽的约定,导致一些难以察觉的错误 - 我们可能会去掉 @Service 注解,而是使用另外一种产生 Bean 的方式:创建一个配置类 BeanConfiguration(标记 @Configuration)来创建一堆 Bean,其中就 包含了创建 LightService 类型的 Bean,并将其注册到 Spring 容器: ```java import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class BeanConfiguration { @Bean public LightService getTransmission(){ return new LightService(); } } ``` - 复用案例 1 的启动程序,稍作修改,让 Spring 启动完成后立马关闭当前 Spring 上下文这样等同于模拟宿舍管理系统的启停: ```java @SpringBootApplication public class Application { public static void main(String[] args) { ConfigurableApplicationContext context = SpringApplication.run(Applica context.close(); } } ``` - 以上代码没有其他任何方法的调用,仅仅是将所有符合约定的类初始化并加载到 Spring 容器,完成后再关闭当前的 Spring 容器。 - 按照预期,这段代码运行后不会有任何的 log 输出,毕竟我们只是改变了 Bean 的产生方式。 - 但实际运行这段代码后,我们可以看到控制台上打印了 shutting down all lights。显然shutdown 方法未按照预期被执行了,这导致一个很有意思的 bug:在使用新的 Bean 生 成方式之前,每一次宿舍管理服务被重启时,宿舍里所有的灯都不会被关闭。但是修改后,只有服务重启,灯都被意外关闭了。如何理解这个 bug? - **问题解析** - TODO 后续补充源码跟进 - **问题修正** - 我们可以通过避免在 Java 类中定义一些带有特殊意义动词的方法来解决,当然如果一定要定义名为 close 或者 shutdown 方法,也可以通过将 Bean 注解内 destroyMethod 属性 设置为空的方式来解决这个问题。 - 第一种修改方式比较简单,所以这里只展示第二种修改方式,代码如下: ```java import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class BeanConfiguration { @Bean(destroyMethod="") public LightService getTransmission(){ return new LightService(); } } ``` ## 3. 业务编写之坑 ### 3.1 ### 3.21 设计 - 代码重复:搞定代码重复的三个绝招 - 涉及知识: 设计模式、Java 高级特性、OOP #### A. 利用工厂模式 + 模板方法模式,消除 if…else 和重复代码 - 业务模拟背景 - 假设要开发一个购物车下单的功能,针对不同用户进行不同处理: - 普通用户需要收取运费,运费是商品价格的 10%,无商品折扣; - VIP 用户同样需要收取商品价格 10% 的快递费,但购买两件以上相同商品时,第三件开始享受一定折扣; - 内部用户可以免运费,无商品折扣 - 目标 - 实现三种类型的购物车业务逻辑,把入参 Map 对象(Key 是商品 ID,Value是商品数量),转换为出参购物车类型 Cart。 ```java //购物车 @Data public class Cart { //商品清单 private List items = new ArrayList<>(); //总优惠 private BigDecimal totalDiscount; //商品总价 private BigDecimal totalItemPrice; //总运费 private BigDecimal totalDeliveryPrice; //应付总价 private BigDecimal payPrice; } //购物车中的商品 @Data public class Item { //商品ID private long id; //商品数量 private int quantity; //商品单价 private BigDecimal price; //商品优惠 private BigDecimal couponPrice; //商品运费 private BigDecimal deliveryPrice; } //普通用户购物车处理 public class NormalUserCart { public Cart process(long userId, Map items) { Cart cart = new Cart(); //把Map的购物车转换为Item列表 List itemList = new ArrayList<>(); items.entrySet().stream().forEach(entry -> { Item item = new Item(); item.setId(entry.getKey()); item.setPrice(Db.getItemPrice(entry.getKey())); item.setQuantity(entry.getValue()); itemList.add(item); }); cart.setItems(itemList); //处理运费和商品优惠 itemList.stream().forEach(item -> { //运费为商品总价的10% item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(i //无优惠 item.setCouponPrice(BigDecimal.ZERO); }); //计算商品总价 cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add)); //计算总运费 cart.setTotalDeliveryPrice(cart.getItems().stream().map(Item::getDeliveryPrice).reduce(BigDecimal.ZERO, BigDecimal::add)); //计算总折扣 cart.setTotalDiscount(cart.getItems().stream().map(Item::getCouponPrice).reduce(BigDecimal.ZERO, BigDecimal::add)); // 计算应付价格 cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount())); return cart; ``` - 然后实现针对 VIP 用户的购物车逻辑。与普通用户购物车逻辑的不同在于,VIP 用户能享受同类商品多买的折扣。所以,这部分代码只需要额外处理多买折扣部分 ```java public class VipUserCart { public Cart process(long userId, Map items) { ... itemList.stream().forEach(item -> { //运费为商品总价的10% item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(i)) //购买两件以上相同商品,第三件开始享受一定折扣 if (item.getQuantity() > 2) { item.setCouponPrice(item.getPrice() .multiply(BigDecimal.valueOf(100 - Db.getUserCouponPerce)) .multiply(BigDecimal.valueOf(item.getQuantity() - 2))); } else { item.setCouponPrice(BigDecimal.ZERO); } }); ... return cart; } } ``` - 最后是免运费、无折扣的内部用户,同样只是处理商品折扣和运费时的逻辑差异: ```java public class InternalUserCart { public Cart process(long userId, Map items) { ... itemList.stream().forEach(item -> { //免运费 item.setDeliveryPrice(BigDecimal.ZERO); //无优惠 item.setCouponPrice(BigDecimal.ZERO); }); ... return cart; } } ``` - **分析上述坏代码**: - 对比一下代码量可以发现,三种购物车 70% 的代码是重复的。原因很简单,虽然不同类型用户计算运费和优惠的方式不同,但整个购物车的初始化、统计总价、总运费、总优惠和支 付价格的逻辑都是一样的。 - 代码重复本身不可怕,可怕的是漏改或改错。比如,写 VIP 用户购物车的同学发现商品总价计算有 Bug,不应该是把所有 Item 的 price 加在一起,而是应该把所有 Item 的 price*quantity 加在一起。这时,他可能会只修改 VIP 用户购物车的代码,而忽略了普通用户、内部用户的购物车中,重复的逻辑实现也有相同的 Bug - 有了三个购物车后,我们就需要根据不同的用户类型使用不同的购物车了。如下代码所示,使用三个 if 实现不同类型用户调用不同购物车的 process 方法: ```java @GetMapping("wrong") public Cart wrong(@RequestParam("userId") int userId) { //根据用户ID获得用户类型 String userCategory = Db.getUserCategory(userId); //普通用户处理逻辑 if (userCategory.equals("Normal")) { NormalUserCart normalUserCart = new NormalUserCart(); return normalUserCart.process(userId, items); } //VIP用户处理逻辑 if (userCategory.equals("Vip")) { VipUserCart vipUserCart = new VipUserCart(); return vipUserCart.process(userId, items); } //内部用户处理逻辑 if (userCategory.equals("Internal")) { InternalUserCart internalUserCart = new InternalUserCart(); return internalUserCart.process(userId, items); } return null; } ``` - 电商的营销玩法是多样的,以后势必还会有更多用户类型,需要更多的购物车。我们就只能不断增加更多的购物车类,一遍一遍地写重复的购物车逻辑、写更多的 if 逻辑 - **改进措施** - 抽象类和抽象方法的定义的话,这时或许就会想到,是否可以把重复的逻辑定义在抽象类中,三个购物车只要分别实现不同的那份逻辑 - 这个模式就是**模板方法模式** - 在父类中实现了购物车处理的流程模板,然后把需要特殊处理的地方留空白也就是留抽象方法定义,让子类去实现其中的逻辑。由于父类的辑不完整无法单独工作,因此需要定义为抽象类。 - 如下代码所示,AbstractCart 抽象类实现了购物车通用的逻辑,额外定义了两个抽象方法让子类去实现。其中,processCouponPrice 方法用于计算商品折扣,processDeliveryPrice 方法用于计算运费。 ```java public abstract class AbstractCart { //处理购物车的大量重复逻辑在父类实现 public Cart process(long userId, Map items) { Cart cart = new Cart(); List itemList = new ArrayList<>(); items.entrySet().stream().forEach(entry -> { Item item = new Item(); item.setId(entry.getKey()); item.setPrice(Db.getItemPrice(entry.getKey())); item.setQuantity(entry.getValue()); itemList.add(item); }); cart.setItems(itemList); //让子类处理每一个商品的优惠 itemList.stream().forEach(item -> { processCouponPrice(userId, item); processDeliveryPrice(userId, item); }); //计算商品总价 cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add)); //计算总运费 cart.setTotalDeliveryPrice(cart.getItems().stream().map(Item::getDeliveryPrice).reduce(BigDecimal.ZERO, BigDecimal::add)); //计算总折扣 cart.setTotalDiscount(cart.getItems().stream().map(Item::getCouponPrice).reduce(BigDecimal.ZERO, BigDecimal::add)); // 计算应付价格 cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount())); return cart; } //处理商品优惠的逻辑留给子类实现 protected abstract void processCouponPrice(long userId, Item item); //处理配送费的逻辑留给子类实现 protected abstract void processDeliveryPrice(long userId, Item item); } ``` - 有了这个抽象类,三个子类的实现就非常简单了。普通用户的购物车 NormalUserCart,实现的是 0 优惠和 10% 运费的逻辑: ```java @Service(value = "NormalUserCart") public class NormalUserCart extends AbstractCart { @Override protected void processCouponPrice(long userId, Item item) { item.setCouponPrice(BigDecimal.ZERO); } @Override protected void processDeliveryPrice(long userId, Item item) { item.setDeliveryPrice(item.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity())) .multiply(new BigDecimal("0.1"))); } } ``` - VIP 用户的购物车 VipUserCart,直接继承了 NormalUserCart,只需要修改多买优惠策略: ```java @Service(value = "VipUserCart") public class VipUserCart extends NormalUserCart { @Override protected void processCouponPrice(long userId, Item item) { if (item.getQuantity() > 2) { item.setCouponPrice(item.getPrice() .multiply(BigDecimal.valueOf(100 - Db.getUserCouponPercent(userId)).divide(new BigDecimal("100"))) .multiply(BigDecimal.valueOf(item.getQuantity() - 2))); }else { item.setCouponPrice(BigDecimal.ZERO); } } } ``` - 内部用户购物车 InternalUserCart 是最简单的,直接设置 0 运费和 0 折扣即可: ```java @Service(value = "InternalUserCart") public class InternalUserCart extends AbstractCart { @Override protected void processCouponPrice(long userId, Item item) { item.setCouponPrice(BigDecimal.ZERO); } @Override protected void processDeliveryPrice(long userId, Item item) { item.setDeliveryPrice(BigDecimal.ZERO); } } ``` - 接下来,我们再看看如何能避免三个 if 逻辑 - 定义三个购物车子类时,我们在 @Service 注解中对 Bean 进行了命名。既然三个购物车都叫 XXXUserCart,那我们就可以把用户类型字符串拼接 UserCart 构成购物车 Bean 的名称,然后利用 Spring 的 IoC 容器,通过 Bean 的名称直接获取到AbstractCart,调用其 process 方法即可实现通用。 这就是**工厂模式**,只不过是借助 Spring 容器实现罢了: ```java @GetMapping("right") public Cart right(@RequestParam("userId") int userId) { String userCategory = Db.getUserCategory(userId); AbstractCart cart = (AbstractCart) applicationContext.getBean(userCategory + "UserCart"); return cart.process(userId, items); } ``` - 之后如果有了新的用户类型、新的用户逻辑,完全不用对代码做任何修改,只要新增一个 XXXUserCart 类继承 AbstractCart,实现特殊的优惠和运费处理逻辑就可以了 - 我们就利用**工厂模式 + 模板方法模式**,不仅消除了重复代码,还避免了修改既有代码的风险 - 这就是设计模式中的开闭原则:对修改关闭,对扩展开放 #### B. 利用注解 + 反射消除重复代码 - 再看一个三方接口的调用案例,同样也是一个普通的业务逻辑 - 假设银行提供了一些 API 接口,对参数的序列化有点特殊,不使用 JSON,而是需要我们把参数依次拼在一起构成一个大字符串 - 按照银行提供的 API 文档的顺序,把所有参数构成定长的数据,然后拼接在一起作为整个字符串。 - 因为每一种参数都有固定长度,未达到长度时需要做填充处理: - 字符串类型的参数不满长度部分需要以下划线右填充,也就是字符串内容靠左; - 数字类型的参数不满长度部分以 0 左填充,也就是实际数字靠右; - 货币类型的表示需要把金额向下舍入 2 位到分,以分为单位,作为数字类型同样进行左填充 - 对所有参数做 MD5 操作作为签名(为了方便理解,Demo 中不涉及加盐处理)。 - 代码很容易实现,直接根据接口定义实现填充操作、加签名、请求调用操作即可: ```java public class BankService { //创建用户方法 public static String createUser(String name, String identity, String mobile, int age) throws IOException { StringBuilder stringBuilder = new StringBuilder(); //字符串靠左,多余的地方填充_ stringBuilder.append(String.format("%-10s", name).replace(' ', '_')); //字符串靠左,多余的地方填充_ stringBuilder.append(String.format("%-18s", identity).replace(' ', '_')); //数字靠右,多余的地方用0填充 stringBuilder.append(String.format("%05d", age)); // 字符串靠左,多余的地方用_填充 stringBuilder.append(String.format("%-11s", mobile).replace(' ', '_')); // 最后加上MD5作为签名 stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString())); return Request.Post("http://localhost:45678/reflection/bank/createUser") .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON) .execute().returnContent().asString(); } //支付方法 public static String pay(long userId, BigDecimal amount) throws IOException{ StringBuilder stringBuilder = new StringBuilder(); //数字靠右,多余的地方用0填充 stringBuilder.append(String.format("%020d", userId)); //金额向下舍入2位到分,以分为单位,作为数字靠右,多余的地方用0填充 stringBuilder.append(String.format("%010d", amount.setScale(2, , RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue())); //最后加上MD5作为签名 stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString())); return Request.Post("http://localhost:45678/reflection/bank/pay") .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON) .execute().returnContent().asString(); } } ``` - 可以看到,这段代码的重复粒度更细: - 三种标准数据类型的处理逻辑有重复,稍有不慎就会出现 Bug; - 处理流程中字符串拼接、加签和发请求的逻辑,在所有方法重复; - 实际方法的入参的参数类型和顺序,不一定和接口要求一致,容易出错; - 代码层面针对每一个参数硬编码,无法清晰地进行核对,如果参数达到几十个、上百个,出错的概率极大。 - **改造方法** - 要用注解和反射 - 使用注解和反射这两个武器,就可以针对银行请求的所有逻辑均使用一套代码实现,不会出 现任何重复。 - 要实现接口逻辑和逻辑实现的剥离,首先需要以 POJO 类(只有属性没有任何业务逻辑的数据类)的方式定义所有的接口参数。比如,下面这个创建用户 API 的参数 ```java @Data public class CreateUserAPI { private String name; private String identity; private String mobile; private int age; } ``` - 有了接口参数定义,我们就能通过自定义注解为接口和所有参数增加一些元数据。如下所示,我们定义一个接口 API 的注解 BankAPI,包含接口 URL 地址和接口说明: ```java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Documented @Inherited public @interface BankAPI { String desc() default ""; String url() default ""; } ``` - 然后,我们再定义一个自定义注解 @BankAPIField,用于描述接口的每一个字段规范,包含参数的次序、类型和长度三个属性: ```java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @Documented @Inherited public @interface BankAPIField { int order() default -1; int length() default -1; String type() default ""; } ``` - 接下来,注解就可以发挥威力了。 - 如下所示,我们定义了 CreateUserAPI 类描述创建用户接口的信息,通过为接口增加 @BankAPI 注解,来补充接口的 URL 和描述等元数据;通过为每一个字段增加 @BankAPIField 注解,来补充参数的顺序、类型和长度等元数据: ````java @BankAPI(url = "/bank/createUser", desc = "创建用户接口") @Data public class CreateUserAPI extends AbstractAPI { @BankAPIField(order = 1, type = "S", length = 10) private String name; @BankAPIField(order = 2, type = "S", length = 18) private String identity; @BankAPIField(order = 4, type = "S", length = 11) // 注意这里的order需要按照API private String mobile; @BankAPIField(order = 3, type = "N", length = 5) private int age; } ```` - 另一个 PayAPI 类也是类似的实现: ```java @BankAPI(url = "/bank/pay", desc = "支付接口") @Data public class PayAPI extends AbstractAPI { @BankAPIField(order = 1, type = "N", length = 20) private long userId; @BankAPIField(order = 2, type = "M", length = 10) private BigDecimal amount; } ``` - 这 2 个类继承的 AbstractAPI 类是一个空实现,因为这个案例中的接口并没有公共数据可以抽象放到基类。 - 通过这 2 个类,我们可以在几秒钟内完成和 API 清单表格的核对。理论上,如果我们的核心翻译过程(也就是把注解和接口 API 序列化为请求需要的字符串的过程)没问题,只要 注解和表格一致,API 请求的翻译就不会有任何问题。 - 以上,我们通过注解实现了对 API 参数的描述。接下来,我们再看看反射如何配合注解实现动态的接口参数组装: ```java private static String remoteCall(AbstractAPI api) throws IOException { //从BankAPI注解获取请求地址, 我们从类上获得了 BankAPI 注解,然后拿到其 URL 属性,后续进行远程调用。 BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class); bankAPI.url(); StringBuilder stringBuilder = new StringBuilder(); // 使用 stream 快速实现了获取类中所有带 BankAPIField 注解的字段,并把字段按 order 属性排序,然后设置私有字段反射可访问。 Arrays.stream(api.getClass().getDeclaredFields()) //获得所有字段 .filter(field -> field.isAnnotationPresent(BankAPIField.class)) //查找标记了注解的字段 .sorted(Comparator.comparingInt(a -> a.getAnnotation(BankAPIField.class).order()))//根据注解中的order对字段排序 .peek(field -> field.setAccessible(true))//设置可以访问私有字段 // 实现了反射获取注解的值,然后根据 BankAPIField 拿到的参数类型,按照三种标准进行格式化,将所有参数的格式化逻辑集中在了这一处。 .forEach(field -> { //获得注解 BankAPIField bankAPIField = field.getAnnotation(BankAPIField.class); Object value = ""; try { //反射获取字段值 value = field.get(api); } catch (IllegalAccessException e) { e.printStackTrace(); } // 根据字段类型以正确的填充方式格式化字符串 switch (bankAPIField.type()) { case "S": { stringBuilder.append(String.format("%-" + bankAPIField.length() + "s", value.toString()).replace(' ','_')); break; } case "N":{ stringBuilder.append(String.format("%" + bankAPIField.length() + "s", value.toString()).replace(' ','0')); } case "M":{ if (!(value instanceof BigDecimal)) throw new RuntimeException(String.format("{} 的 {} 必须是BigDecimal", api, field)); stringBuilder.append(String.format("%0" + bankAPIField.length() + "d", ((BigDecimal) value).setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue())); break; } default: break; } }); //签名逻辑 stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString())); String param = stringBuilder.toString(); long begin = System.currentTimeMillis(); //发请求 String result = Request.Post("http://localhost:45678/reflection" + bankAPI.url()) .bodyString(param, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); log.info("调用银行API {} url:{} 参数:{} 耗时:{}ms", bankAPI.desc(), bankAPI.url(), param, System.currentTimeMillis() - begin); return result; } ``` - 所有处理参数排序、填充、加签、请求调用的核心逻辑,都汇聚在了 remoteCall 方法中。有了这个核心方法,BankService 中每一个接口的实现就非常简单了,只是参数的组装,然后调用 remoteCall 即可。 ```java //创建用户方法 public static String createUser(String name, String identity, String mobile, int age)throws IOException { CreateUserAPI createUserAPI = new CreateUserAPI(); createUserAPI.setName(name); createUserAPI.setIdentity(identity); createUserAPI.setAge(age); createUserAPI.setMobile(mobile); return remoteCall(createUserAPI); } //支付方法 public static String pay(long userId, BigDecimal amount) throws IOException { PayAPI payAPI = new PayAPI(); payAPI.setUserId(userId); payAPI.setAmount(amount); return remoteCall(payAPI); } ``` - 其实,许多**涉及类结构性的通用处理,都可以按照这个模式来减少重复代码**。反射给予了我们在不知晓类结构的时候,按照固定的逻辑处理类的成员;而注解给了我们为这些成员补充 元数据的能力,使得我们利用反射实现通用逻辑的时候,可以从外部获得更多我们关心的数据。 #### C. 利用属性拷贝工具消除重复代码 - 业务代码中经常出现的代码逻辑,实体之间的转换复制 - 对于三层架构的系统,考虑到层之间的解耦隔离以及每一层对数据的不同需求,通常每一层都会有自己的 POJO 作为数据实体。比如,数据访问层的实体一般叫作 DataObject 或 DO,业务逻辑层的实体一般叫作 Domain,表现层的实体一般叫作 Data Transfer Object 或 DTO - 对于复杂的业务系统,实体有几十甚至几百个属性也很正常。就比如 ComplicatedOrderDTO 这个数据传输对象,描述的是一个订单中的几十个属性。如果我们要把这个 DTO 转换为 一个类似的 DO,复制其中大部分的字段,然后把数据入库,势必需要进行很多属性映射赋值操作。 ```java ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO(); ComplicatedOrderDO orderDO = new ComplicatedOrderDO(); orderDO.setAcceptDate(orderDTO.getAcceptDate()); orderDO.setAddress(orderDTO.getAddress()); orderDO.setAddressId(orderDTO.getAddressId()); orderDO.setCancelable(orderDTO.isCancelable()); orderDO.setCommentable(orderDTO.isComplainable()); //属性错误 orderDO.setComplainable(orderDTO.isCommentable()); //属性错误 orderDO.setCancelable(orderDTO.isCancelable()); orderDO.setCouponAmount(orderDTO.getCouponAmount()); orderDO.setCouponId(orderDTO.getCouponId()); orderDO.setCreateDate(orderDTO.getCreateDate()); orderDO.setDirectCancelable(orderDTO.isDirectCancelable()); orderDO.setDeliverDate(orderDTO.getDeliverDate()); orderDO.setDeliverGroup(orderDTO.getDeliverGroup()); orderDO.setDeliverGroupOrderStatus(orderDTO.getDeliverGroupOrderStatus()); orderDO.setDeliverMethod(orderDTO.getDeliverMethod()); orderDO.setDeliverPrice(orderDTO.getDeliverPrice()); orderDO.setDeliveryManId(orderDTO.getDeliveryManId()); orderDO.setDeliveryManMobile(orderDO.getDeliveryManMobile()); //对象错误 orderDO.setDeliveryManName(orderDTO.getDeliveryManName()); orderDO.setDistance(orderDTO.getDistance()); orderDO.setExpectDate(orderDTO.getExpectDate()); orderDO.setFirstDeal(orderDTO.isFirstDeal()); orderDO.setHasPaid(orderDTO.isHasPaid()); orderDO.setHeadPic(orderDTO.getHeadPic()); orderDO.setLongitude(orderDTO.getLongitude()); orderDO.setLatitude(orderDTO.getLongitude()); //属性赋值错误 orderDO.setMerchantAddress(orderDTO.getMerchantAddress()); orderDO.setMerchantHeadPic(orderDTO.getMerchantHeadPic()); orderDO.setMerchantId(orderDTO.getMerchantId()); orderDO.setMerchantAddress(orderDTO.getMerchantAddress()); orderDO.setMerchantName(orderDTO.getMerchantName()); orderDO.setMerchantPhone(orderDTO.getMerchantPhone()); orderDO.setOrderNo(orderDTO.getOrderNo()); orderDO.setOutDate(orderDTO.getOutDate()); orderDO.setPayable(orderDTO.isPayable()); orderDO.setPaymentAmount(orderDTO.getPaymentAmount()); orderDO.setPaymentDate(orderDTO.getPaymentDate()); orderDO.setPaymentMethod(orderDTO.getPaymentMethod()); orderDO.setPaymentTimeLimit(orderDTO.getPaymentTimeLimit()); orderDO.setPhone(orderDTO.getPhone()); orderDO.setRefundable(orderDTO.isRefundable()); orderDO.setRemark(orderDTO.getRemark()); orderDO.setStatus(orderDTO.getStatus()); orderDO.setTotalQuantity(orderDTO.getTotalQuantity()); orderDO.setUpdateTime(orderDTO.getUpdateTime()); orderDO.setName(orderDTO.getName()); orderDO.setUid(orderDTO.getUid()); ``` - 修改方法很简单,可以使用类似 BeanUtils 这种 Mapping 工具来做 Bean 的转换,copyProperties 方法还允许我们提供需要忽略的属性: ```java ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO(); ComplicatedOrderDO orderDO = new ComplicatedOrderDO(); BeanUtils.copyProperties(orderDTO, orderDO, "id"); return orderDO; ``` #### D. 总结 - 观察者模式适合所有发布-订阅类型的场景,可以是同步阻塞,也可以是异步非阻塞的,可以是进程内的,也可以是系统间的解耦。工作中用的多的是Guava的EventBus。 - cglib中BeanCopier也提供了mapping功能,基于动态代理实现 - 属性拷贝工具相较于直接写get和set,会有性能上的差异? - 会有 不过一般不会存在性能瓶颈 - 策略 状态机 职责链模式在开发中的使用也比较常见 - TODO 借助这个话题来讨论设计模式在实际开发中的使用, 来优化我们的代码设计 ### 3.22 设计 - 接口设计:系统间对话的语言,一定要统一 - 开发一个服务的第一步就是设计接口。接口的设计需要考虑的点非常多,比如 - 接口的命名、参数列表、包装结构体、接口粒度、版本策略、幂等性实现、同步异步处理方式等。 - 和接口设计相关比较重要的点有三个 - 分别是包装结构体、版本策略、同步异步处理方式。 #### A. 接口的响应要明确表示接口的处理结果 - 我曾遇到过一个处理收单的收单中心项目,下单接口返回的响应体中,包含了 success、code、info、message 等属性,以及二级嵌套对象 data 结构体。在对项目进行重构的时候,我们发现真的是无从入手,接口缺少文档,代码一有改动就出错。 - 有时候,下单操作的响应结果是这样的:success 是 true、message 是 OK,貌似代表下单成功了;但 info 里却提示订单存在风险,code 是一个 5001 的错误码,data 中能看到订单状态是 Cancelled,订单 ID 是 -1,好像又说明没有下单成功。 ```json { "success": true, "code": 5001, "info": "Risk order detected", "message": "OK", "data": { "orderStatus": "Cancelled", "orderId": -1 } } ``` - 有些时候,这个下单接口又会返回这样的结果:success 是 false,message 提示非法用户ID,看上去下单失败;但 data 里的 orderStatus 是 Created、info 是空、code 是 0。那么,这次下单到底是成功还是失败呢? ```json { "success": false, "code": 0, "info": "", "message": "Illegal userId", "data": { "orderStatus": "Created", "orderId": 0 } } ``` - 这样的结果,让我们非常疑惑: - 结构体的 code 和 HTTP 响应状态码,是什么关系? - success 到底代表下单成功还是失败? - info 和 message 的区别是什么? - data 中永远都有数据吗?什么时候应该去查询 data? - 造成如此混乱的原因是:这个收单服务本身并不真正处理下单操作,只是做一些预校验和预处理;真正的下单操作,需要在收单服务内部调用另一个订单服务来处理;订单服务处理完成后,会返回订单状态和 ID - 为了将接口设计得更合理,我们需要考虑如下两个原则: - 对外隐藏内部实现。虽然说收单服务调用订单服务进行真正的下单操作,但是直接接口其实是收单服务提供的,收单服务不应该“直接”暴露其背后订单服务的状态码、错误描述。 - 设计接口结构时,明确每个字段的含义,以及客户端的处理方式。 - ### 3.23 设计 - 缓存设计:缓存可以锦上添花也可以落井下石 ### 3.24 设计 - 业务代码写完,就意味着生产就绪了? - 上线前的准备 - 提供健康检测接口 - 暴露应用内部信息 - 建立应用指标 Metrics 监控 #### A. 准备工作:配置 Spring Boot Actuator - Spring Boot 有一个 Actuator 模块,封装了诸如健康检测、应用内部信息、Metrics 指标等生产就绪的功能 - 在 pom 中通过添加依赖的方式引入 Actuator: ```xml org.springframework.boot spring-boot-starter-actuator ``` - Actuator 注意一些重要的配置: - 不希望 Web 应用的 Actuator 管理端口和应用端口重合的话,可以使用 management.server.port 设置独立的端口。 - Actuator 自带了很多开箱即用提供信息的端点(Endpoint),可以通过 JMX 或 Web 两种方式进行暴露。考虑到有些信息比较敏感,这些内置的端点默认不是完全开启的 - 默认情况下,Actuator 的 Web 访问方式的根地址为 /actuator,可以通过 management.endpoints.web.base-path 参数进行修改。我来演示下,如何将其修改为 /admin ```properties management.server.port=45679 management.endpoints.web.exposure.include=* management.endpoints.web.base-path=/admin ``` - 可以访问 http://localhost:45679/admin ,来查看 Actuator 的所有功能URL 了: - 其中,大部分端点提供的是只读信息,比如查询 Spring 的 Bean、ConfigurableEnvironment、定时任务、SpringBoot 自动配置、Spring MVC 映射等;少部分端点还提供了修改功能, 比如优雅关闭程序、下载线程 Dump、下载堆 Dump、修改日志级别等 - Spring Boot 管理工具Spring Boot Admin,它把大部分 Actuator 端点提供的功能封装为了 Web UI #### B. 健康检测需要触达关键组件 - Spring Boot Actuator 帮我们预先实现了诸如数据库、InfluxDB、Elasticsearch、Redis、RabbitMQ 等三方系统的健康检测指示器 HealthIndicator。 - 通过 Spring Boot 的自动配置,这些指示器会自动生效。当这些组件有问题的时候,HealthIndicator 会返回 DOWN 或 OUT_OF_SERVICE 状态,health 端点 HTTP 响应状态码也会变为 503,我们可以以此来配置程序健康状态监控报警。 - 为了演示,我们可以修改配置文件,把 management.endpoint.health.show-details 参数设置为 always,让所有用户都可以直接查看各个组件的健康情况 (如果配置为 whenauthorized,那么可以结合 management.endpoint.health.roles 配置授权的角色): ```properties management.endpoint.health.show-details=always ``` - 访问 health 端点可以看到,数据库、磁盘、RabbitMQ、Redis 等组件健康状态是 UP,整个应用的状态也是 UP: - 在了解了基本配置之后,我们考虑一下,如果程序依赖一个很重要的三方服务,我们希望这个服务无法访问的时候,应用本身的健康状态也是 DOWN。 - 比如三方服务有一个 user 接口,出现异常的概率是 50%: - com.baiye.demo.case24.health.UserServiceController - 要实现这个 user 接口是否正确响应和程序整体的健康状态挂钩的话,很简单,只需定义个 UserServiceHealthIndicator 实现 HealthIndicator 接口即可。 - com.baiye.demo.case24.health.UserServiceHealthIndicator - 我们再来看一个聚合多个 HealthIndicator 的案例,也就是定义一个 CompositeHealthContributor 来聚合多个 HealthContributor,实现一组线程池的监控。 - 首先,在 ThreadPoolProvider 中定义两个线程池,其中 demoThreadPool 是包含一个工作线程的线程池,类型是 ArrayBlockingQueue,阻塞队列的长度为 10;还有一个ioThreadPool 模拟 IO 操作线程池,核心线程数 10,最大线程数 50: - com.baiye.demo.case24.health.ThreadPoolProvider - 然后,我们定义一个接口,来把耗时很长的任务提交到这个 demoThreadPool 线程池,以模拟线程池队列满的情况: - com.baiye.demo.case24.health.UserServiceController.slowTask - 做了这些准备工作后,让我们来真正实现自定义的 HealthIndicator 类,用于单一线程池的健康状态。 - 可以传入一个 ThreadPoolExecutor,通过判断队列剩余容量来确定这个组件的健康状态,有剩余量则返回 UP,否则返回 DOWN,并把线程池队列的两个重要数据,也就是当前队列元素个数和剩余量,作为补充信息加入 Health: - com.baiye.demo.case24.health.ThreadPoolHealthIndicator - 再定义一个 CompositeHealthContributor,来聚合两个 ThreadPoolHealthIndicator 的实例,分别对应 ThreadPoolProvider 中定义的两个线程池: - com.baiye.demo.case24.health.ThreadPoolsHealthContributor - 程序启动后可以看到,health 接口展现了线程池和外部服务 userService 的健康状态 - Spring Boot 2.3.0增强了健康检测的功能,细化了 Liveness 和 Readiness 两个端点,便于 Spring Boot 应用程序和 Kubernetes 整合。 #### C. 对外暴露应用内部重要组件的状态 - 除了可以把线程池的状态作为整个应用程序是否健康的依据外,我们还可以通过 Actuator的 InfoContributor 功能,对外暴露程序内部重要组件的状态数据。这里,我会用一个例子演示使用 info 的 HTTP 端点、JMX MBean 这两种方式,如何查看状态数据。 - 实现一个 ThreadPoolInfoContributor 来展现线程池的信息。 - com.baiye.demo.case24.info.ThreadPoolInfoContributor - 访问 /admin/info 接口,可以看到这些数据: - ![组件的状态](pic/组件的状态监控.png) - 此外,如果设置开启 JMX 的话: ```properties spring.jmx.enabled=true ``` - 可以通过 jconsole 工具,在 org.springframework.boot.Endpoint 中找到 Info 这个MBean,然后执行 info 操作可以看到,我们刚才自定义的 InfoContributor 输出的有关两个线程池的信息: - ![组件的状态](pic/组件的状态监控1.png) - 对于查看和操作 MBean,除了使用 jconsole 之外,你可以使用 jolokia 把 JMX 转换为 HTTP 协议,引入依赖: ```xml org.jolokia jolokia-core ``` - 然后,你就可以通过 jolokia,来执行org.springframework.boot:type=Endpoint,name=Info 这个 MBean 的 info 操作: - ![组件的状态](pic/组件的状态监控2.png) #### D. 指标 Metrics 是快速定位问题的“金钥匙” - 通过一个实际的案例,来看看如何通过图表快速定位问题。 - 有一个外卖订单的下单和配送流程,如下图所示。OrderController 进行下单操作,下单操作前先判断参数,如果参数正确调用另一个服务查询商户状态,如果商户在营业的话继续下单,下单成功后发一条消息到 RabbitMQ 进行异步配送流程;然后另一个 DeliverOrderHandler 监听这条消息进行配送操作。 - ![组件的状态](pic/组件的状态监控3.png) - 对于这样一个涉及同步调用和异步调用的业务流程,如果用户反馈下单失败,那我们如何才能快速知道是哪个环节出了问题呢? - 这时,指标体系就可以发挥作用了。我们可以分别为下单和配送这两个重要操作,建立一些指标进行监控。 - 对于下单操作,可以建立 4 个指标: - 下单总数量指标,监控整个系统当前累计的下单量; - 下单请求指标,对于每次收到下单请求,在处理之前 +1; - 下单成功指标,每次下单成功完成 +1; - 下单失败指标,下单操作处理出现异常 +1,并且把异常原因附加到指标上。 - 对于配送操作,也是建立类似的 4 个指标。我们可以使用 Micrometer 框架实现指标的收集,它也是 Spring Boot Actuator 选用的指标框架。它实现了各种指标的抽象,常用的有三种: - gauge(红色),它反映的是指标当前的值,是多少就是多少,不能累计,比如本例中的下单总数量指标,又比如游戏的在线人数、JVM 当前线程数都可以认为是 gauge。 - counter(绿色),每次调用一次方法值增加 1,是可以累计的,比如本例中的下单请求指标。举一个例子,如果 5 秒内我们调用了 10 次方法,Micrometer 也是每隔 5 秒把指标发送给后端存储系统一次,那么它可以只发送一次值,其值为 10。 - timer(蓝色),类似 counter,只不过除了记录次数,还记录耗时,比如本例中的下单成功和下单失败两个指标。 - 所有的指标还可以附加一些 tags 标签,作为补充数据。比如,当操作执行失败的时候,我们就会附加一个 reason 标签到指标上。 - Micrometer 除了抽象了指标外,还抽象了存储。你可以把 Micrometer 理解为类似 SLF4J 这样的框架,只不过后者针对日志抽象,而 Micrometer 是针对指标进行抽象。Micrometer 通过引入各种 registry,可以实现无缝对接各种监控系统或时间序列数据库。 - 在这个案例中,我们引入了 micrometer-registry-influx 依赖,目的是引入 Micrometer的核心依赖,以及通过 Micrometer 对于InfluxDB(InfluxDB 是一个时间序列数据库,其专长是存储指标数据)的绑定,以实现指标数据可以保存到 InfluxDB: ```xml io.micrometer micrometer-registry-influx ``` - 然后,修改配置文件,启用指标输出到 InfluxDB 的开关、配置 InfluxDB 的地址,以及设置指标每秒在客户端聚合一次,然后发送到 InfluxDB: ```properties management.metrics.export.influx.enabled=true management.metrics.export.influx.uri=http://localhost:8086 management.metrics.export.influx.step=1S ``` - 接下来,我们在业务逻辑中增加相关的代码来记录指标。 - OrderController 的实现通过 Micrometer 框架,来实现下单总数量、下单请求、下单成功和下单失败这四个指标 - com.baiye.demo.case24.metrics.OrderController.createOrder - 当用户 ID<10 的时候,我们模拟用户数据无效的情况,当商户 ID 不为 2 的时候我们模拟商户不营业的情况。 - 接下来是 DeliverOrderHandler 配送服务的实现。 - 其中,deliverOrder 方法监听 OrderController 发出的 MQ 消息模拟配送。如下代码 - com.baiye.demo.case24.metrics.DeliverOrderHandler - 同时,我们模拟了一个配送服务整体状态的开关,调用 status 接口可以修改其状态。至此,我们完成了场景准备,接下来开始配置指标监控。 - 我们来安装 Grafana。然后进入 Grafana 配置一个 InfluxDB 数据源: -![组件的状态监控](pic/组件的状态监控4.png) - 配置好数据源之后,就可以添加一个监控面板,然后在面板中添加各种监控图表。比如,我们在一个下单次数图表中添加了下单收到、成功和失败三个指标 - ![组件的状态监控](pic/组件的状态监控5.png) - 关于这张图中的配置: - 红色框数据源配置,选择刚才配置的数据源 - 蓝色框 FROM 配置,选择我们的指标名。 - 绿色框 SELECT 配置,选择我们要查询的指标字段,也可以应用一些聚合函数。在这里,我们取 count 字段的值,然后使用 sum 函数进行求和。 - 紫色框 GROUP BY 配置,我们配置了按 1 分钟时间粒度和 reason 字段进行分组,这样指标的 Y 轴代表 QPM(每分钟请求数),且每种失败的情况都会绘制单独的曲线。 - 黄色框 ALIAS BY 配置中设置了每一个指标的别名,在别名中引用了 reason 这个 tag。 - 使用 Grafana 配置 InfluxDB 指标的详细方式。其中的 FROM、 SELECT、GROUP BY 的含义和 SQL 类似,理解起来应该不困难。 - 类似地, 我们配置出一个完整的业务监控面板,包含之前实现的 8 个指标: - 配置 2 个 Gauge 图表分别呈现总订单完成次数、总配送完成次数。 - 配置 4 个 Graph 图表分别呈现下单操作的次数和性能,以及配送操作的次数和性能。 - 配置 4 个 Graph 图表分别呈现下单操作的次数和性能,以及配送操作的次数和性能。 - 第一种情况是,使用合法的用户 ID 和营业的商户 ID 运行一段时间: - wrk -t 1 -c 1 -d 3600s http://localhost:45678/order/createOrder\?userId=20&merchantId=2 - 从监控面板可以一目了然地看到整个系统的运作情况。可以看到,目前系统运行良好,不管是下单还是配送操作都是成功的,且下单操作平均处理时间 400ms、配送操作则是在500ms 左右,符合预期(注意,下单次数曲线中的绿色和黄色两条曲线其实是重叠在一起 的,表示所有下单都成功了): - ![组件的状态监控](pic/组件的状态监控6.png) - 第二种情况是,模拟无效用户 ID 运行一段时间: - wrk -t 1 -c 1 -d 3600s http://localhost:45678/order/createOrder?userId=2&merchantId=2 - 使用无效用户下单,显然会导致下单全部失败。接下来,我们就看看从监控图中是否能看到这个现象。 - ![组件的状态监控](pic/组件的状态监控7.png) - 绿色框可以看到,下单现在出现了 invalid user 这条蓝色的曲线,并和绿色收到下单请求的曲线是吻合的,表示所有下单都失败了,原因是无效用户错误,说明源头并没有问题。 - 红色框可以看到,虽然下单都是失败的,但是下单操作时间从 400ms 减少为 200ms了,说明下单失败之前也消耗了 200ms(和代码符合)。而因为下单失败操作的响应时间减半了,反而导致吞吐翻倍了。 - 观察两个配送监控可以发现,配送曲线出现掉 0 现象,是因为下单失败导致的,下单失败 MQ 消息压根就不会发出。再注意下蓝色那条线,可以看到配送曲线掉 0 延后于下单成功曲线的掉 0, 原因是配送走的是异步流程,虽然从某个时刻开始下单全部失败了,但是 MQ 队列中还有一些之前未处理的消息。 - 第三种情况是,尝试一下因为商户不营业导致的下单失败: - wrk -t 1 -c 1 -d 3600s http://localhost:45678/order/createOrder\?userId\=20\&merchantId\=1 - ![组件的状态监控](pic/组件的状态监控8.png) - 第四种情况是,配送停止。我们通过 curl 调用接口,来设置配送停止开关: - curl -X POST 'http://localhost:45678/deliver/status?status=false' - ![组件的状态监控](pic/组件的状态监控9.png) - 从监控可以看到,从开关关闭那刻开始,所有的配送消息全部处理失败了,原因是 deliveroutofservice,配送操作性能从 500ms 左右到了 0ms,说明配送失败是一个本地快速失败,并不是因为服务超时等导致的失败。而且虽然配送失败,但下单操作都是正常的: - 最后希望说的是,除了手动添加业务监控指标外,Micrometer 框架还帮我们自动做了很多有关 JVM 内部各种数据的指标。进入 InfluxDB 命令行客户端,你可以看到下面的这些表(指标),其中前 8 个是我们自己建的业务指标,后面都是框架帮我们建的 JVM、各种组 件状态的指标: - 我们可以按照自己的需求,选取其中的一些指标,在 Grafana 中配置应用监控面板: - ![组件的状态监控](pic/组件的状态监控10.png) #### E. 完善的监控体系 - ![组件的状态监控](pic/组件的状态监控11.png) ### 3.25 设计 - 异步处理好用,但非常容易用错 - 异步处理是互联网应用不可或缺的一种架构模式,大多数业务项目都是由同步处理、异步处理和定时任务处理三种模式相辅相成实现的。 - 区别于同步处理,异步处理无需同步等待流程处理完毕,因此适用场景主要包括: - 服务于主流程的分支流程。 - 比如,在注册流程中,把数据写入数据库的操作是主流程但注册后给用户发优惠券或欢迎短信的操作是分支流程,时效性不那么强,可以进行异步处理。 - 用户不需要实时看到结果的流程。比如,下单后的配货、送货流程完全可以进行异步处理,每个阶段处理完成后,再给用户发推送或短信让用户知晓即可 - 异步处理因为可以有 MQ 中间件的介入用于任务的缓冲的分发,所以相比于同步处理,在应对流量洪峰、实现模块解耦和消息广播方面有功能优势。 - 不过,异步处理虽然好用,但在实现的时候却有三个最容易犯的错 - 异步处理流程的可靠性问题、消息发送模式的区分问题,以及大量死信消息堵塞队列的问题 #### A. 异步处理需要消息补偿闭环 - 使用类似 RabbitMQ、RocketMQ 等 MQ 系统来做消息队列实现异步处理,虽然说消息可以落地到磁盘保存,即使 MQ 出现问题消息数据也不会丢失,但是异步流程在消息发送、 传输、处理等环节,都可能发生消息丢失。此外,任何 MQ 中间件都无法确保 100% 可用,需要考虑不可用时异步流程如何继续进行。 - **对于异步处理流程,必须考虑补偿或者说建立主备双活流程** - 我们来看一个用户注册后异步发送欢迎消息的场景。用户注册落数据库的流程为同步流程,会员服务收到消息后发送欢迎消息的流程为异步流程。 - ![异步消息补偿闭环](pic/异步消息补偿闭环.png) - 分析一下: - 蓝色的线,使用 MQ 进行的异步处理,我们称作主线,可能存在消息丢失的情况(虚线代表异步调用); - 绿色的线,使用补偿 Job 定期进行消息补偿,我们称作备线,用来补偿主线丢失的消息; - 考虑到极端的 MQ 中间件失效的情况,我们要求备线的处理吞吐能力达到主线的能力水平 - 相关的代码实现 - **代码示例**: - 首先,定义 UserController 用于注册 + 发送异步消息。对于注册方法,我们一次性注册 10 个用户,用户注册消息不能发送出去的概率为 50%。 - **参考代码**: com.baiye.demo.case25.compensation.UserController - 然后,定义 MemberService 类用于模拟会员服务。会员服务监听用户注册成功的消息,并发送欢迎短信。我们使用 ConcurrentHashMap 来存放那些发过短信的用户 ID 实现幂等,避免相同的用户进行补偿时重复发送短信: - **参考代码**: com.baiye.demo.case25.compensation.MemberService - 对于 MQ 消费程序,处理逻辑务必考虑去重(支持幂等),原因有几个: - MQ 消息可能会因为中间件本身配置错误、稳定性等原因出现重复。 - 自动补偿重复,比如本例,同一条消息可能既走 MQ 也走补偿,肯定会出现重复,而且考虑到高内聚,补偿 Job 本身不会做去重处理。 - 人工补偿重复。出现消息堆积时,异步处理流程必然会延迟。如果我们提供了通过后台进行补偿的功能,那么在处理遇到延迟的时候,很可能会先进行人工补偿,过了一段时间后处理程序又收到消息了,重复处理。我之前就遇到过一次由 MQ 故障引发的事故, MQ 中堆积了几十万条发放资金的消息,导致业务无法及时处理,运营以为程序出错了就先通过后台进行了人工处理,结果 MQ 系统恢复后消息又被重复处理了一次,造成大量资金重复发放。 - 接下来,定义补偿 Job 也就是备线操作。 - 我们在 CompensationJob 中定义一个 @Scheduled 定时任务,5 秒做一次补偿操作,因为 Job 并不知道哪些用户注册的消息可能丢失,所以是全量补偿,补偿逻辑是:每 5 秒补偿一次,按顺序一次补偿 5 个用户,下一次补偿操作从上一次补偿的最后一个用户 ID 开 始;对于补偿任务我们提交到线程池进行“异步”处理,提高处理能力。 - **参考代码**: com.baiye.demo.case25.compensation.CompensationJob - 为了实现高内聚,主线和备线处理消息,最好使用同一个方法。比如,本例中MemberService 监听到 MQ 消息和 CompensationJob 补偿,调用的都是 welcome 方法。 - 此外值得一说的是,Demo 中的补偿逻辑比较简单,生产级的代码应该在以下几个方面进行加强: - 考虑配置补偿的频次、每次处理数量,以及补偿线程池大小等参数为合适的值,以满足补偿的吞吐量。 - 考虑备线补偿数据进行适当延迟。比如,对注册时间在 30 秒之前的用户再进行补偿,以方便和主线 MQ 实时流程错开,避免冲突。 - 诸如当前补偿到哪个用户的 offset 数据,需要落地数据库。 - 补偿 Job 本身需要高可用,可以使用类似 XXLJob 或 ElasticJob 等任务系统。 - 执行注册方法注册 10 个用户,输出如下: - 总共 10 个用户,MQ 发送成功的用户有四个,分别是用户 1、5、7、8。 - 补偿任务第一次运行,补偿了用户 2、3、4,第二次运行补偿了用户 6、9,第三次运行补充了用户 10。 - 最后提一下,针对消息的补偿闭环处理的最高标准是,能够达到补偿全量数据的吞吐量。也就是说,如果补偿备线足够完善,即使直接把 MQ 停机,虽然会略微影响处理的及时性,但至少确保流程都能正常执行。 #### B. 注意消息模式是广播还是工作队列 - 异步处理的一个重要优势,是实现消息广播。 - 消息广播,和我们平时说的“广播”意思差不多,就是希望同一条消息,不同消费者都能分别消费;而队列模式,就是不同消费者共享消费同一个队列的数据,相同消息只能被某一个消费者消费一次。 - 比如,同一个用户的注册消息,会员服务需要监听以发送欢迎短信,营销服务同样需要监听以发送新用户小礼物。但是,会员服务、营销服务都可能有多个实例,我们期望的是同一个用户的消息, 可以同时广播给不同的服务(广播模式),但对于同一个服务的不同实例(比如会员服务 1 和会员服务 2),不管哪个实例来处理,处理一次即可(工作队列模式): - ![消息模式广播](pic/消息模式广播.png) - 在实现代码的时候,我们务必确认 MQ 系统的机制,确保消息的路由按照我们的期望。 - 对于类似 RocketMQ 这样的 MQ 来说,实现类似功能比较简单直白:如果消费者属于一个组,那么消息只会由同一个组的一个消费者来消费;如果消费者属于不同组,那么每个组都能消费一遍消息。 - 而对于 RabbitMQ 来说,消息路由的模式采用的是队列 + 交换器,队列是消息的载体,交换器决定了消息路由到队列的方式,配置比较复杂,容易出错。 - 演示使用 RabbitMQ 实现广播模式和工作队列模式的坑。 - **第一步,实现会员服务监听用户服务发出的新用户注册消息的那部分逻辑**。 - 我们启动两个会员服务,那么同一个用户的注册消息应该只能被其中一个实例消费 - 分别实现 RabbitMQ 队列、交换器、绑定三件套。其中,队列用的是匿名队列,交换器用的是直接交换器 DirectExchange,交换器绑定到匿名队列的路由 Key 是空字符串。 在收到消息之后,我们会打印所在实例使用的端口: - **参考代码**: com.baiye.demo.case25.fanoutvswork.WorkQueueWrong - 使用 12345 和 45678 两个端口启动两个程序实例后,调用 sendMessage 接口发送一条消息,输出的日志,显示**同一个会员服务两个实例都收到了消息**: - **出现这个问题的原因是,我们没有理清楚 RabbitMQ 直接交换器和队列的绑定关系** - 如下图所示,RabbitMQ 的直接交换器根据 routingKey 对消息进行路由。由于我们的程序每次启动都会创建匿名(随机命名)的队列,所以相当于每一个会员服务实例都对应独立的队列, 以空 routingKey 绑定到直接交换器。用户服务发出消息的时候也设置了routingKey 为空,所以直接交换器收到消息之后,发现有两条队列匹配,于是都转发了消息: - ![消息模式广播1](pic/消息模式广播1.png) - 要修复这个问题其实很简单,对于会员服务不要使用匿名队列,而是使用同一个队列即可。 把上面代码中的匿名队列替换为一个普通队列: - **参考代码**: com.baiye.demo.case25.fanoutvswork.WorkQueueRight - 测试发现,对于同一条消息来说,两个实例中只有一个实例可以收到,不同的消息按照轮询分发给不同的实例。现在,交换器和队列的关系是这样的: - ![消息模式广播2](pic/消息模式广播2.png) - **第二步,进一步完整实现用户服务需要广播消息给会员服务和营销服务的逻辑。** - 我们希望会员服务和营销服务都可以收到广播消息,但会员服务或营销服务中的每个实例只需要收到一次消息。 - 我们声明了一个队列和一个广播交换器 FanoutExchange,然后模拟两个用户服务和两个营销服务: - **参考代码**: com.baiye.demo.case25.fanoutvswork.FanoutQueueWrong - 我们请求四次 sendMessage 接口,注册四个用户。通过日志可以发现,**一条用户注册的消息,要么被会员服务收到,要么被营销服务收到,显然这不是广播**。那,我们使用的FanoutExchange,看名字就应该是实现广播的交换器,为什么根本没有起作用呢? - 其实,广播交换器非常简单,它会忽略 routingKey,广播消息到所有绑定的队列。在这个案例中,两个会员服务和两个营销服务都绑定了同一个队列,所以这四个服务只能收到一次消息: - ![消息模式广播3](pic/消息模式广播3.png) - 修改方式很简单,我们把队列进行拆分,会员和营销两组服务分别使用一条独立队列绑定到广播交换器即可: - **参考代码**: com.baiye.demo.case25.fanoutvswork.FanoutQueueRight - 现在,交换器和队列的结构是这样的: - ![消息模式广播4](pic/消息模式广播4.png) - 从日志输出可以验证,对于每一条 MQ 消息,会员服务和营销服务分别都会收到一次,一条消息广播到两个服务的同时,在每一个服务的两个实例中通过轮询接收: - 理解了 RabbitMQ **直接交换器、广播交换器的工作方式**之后,我们对消息的路由方式了解得很清晰了,实现代码就不会出错 - 对于异步流程来说,消息路由模式一旦配置出错,轻则可能导致消息的重复处理,重则可能导致重要的服务无法接收到消息,最终造成业务逻辑错误。 - 每个 MQ 中间件对消息的路由处理的配置各不相同,我们一定要先了解原理再着手编码。 #### C. 别让死信堵塞了消息队列 - 使用消息队列处理异步流程的时候,我们要注意消息队列的任务堆积问题。对于突发流量引起的消息队列堆积,问题并不大,适当调整消费者的消费能力应该就可以解决。**但在很多时候,消息队列的堆积堵塞,是因为有大量始终无法处理的消息**。 - 比如,用户服务在用户注册后发出一条消息,会员服务监听到消息后给用户派发优惠券,但因为用户并没有保存成功,会员服务处理消息始终失败,消息重新进入队列,然后还是处理失败。这种在 MQ 中像幽灵一样回荡的同一条消息,就是死信。 - 随着 MQ 被越来越多的死信填满,消费者需要花费大量时间反复处理死信,导致正常消息的消费受阻,**最终 MQ 可能因为数据量过大而崩溃**。 - 我们来测试一下这个场景。首先,定义一个队列、一个直接交换器,然后把队列绑定到交换 - **参考代码**: com.baiye.demo.case25.deadletter.RabbitConfiguration.declarables - 然后,实现一个 sendMessage 方法来发送消息到 MQ,访问一次提交一条消息,使用自增标识作为消息内容: - **参考代码**: com.baiye.demo.case25.deadletter.DeadLetterController.sendMessage - 收到消息后,直接抛出空指针异常,模拟处理出错的情况: - **参考代码**: com.baiye.demo.case25.deadletter.MQListener.handler - 调用 sendMessage 接口发送两条消息,然后来到 RabbitMQ 管理台,可以看到这两条消息始终在队列中,不断被重新投递,导致重新投递 QPS 达到了 1063。 - ![消息模式广播5](pic/消息模式广播5.png) - 同时,在日志中可以看到大量异常信息 - 解决死信无限重复进入队列最简单的方式是,在程序处理出错的时候,直接抛出AmqpRejectAndDontRequeueException 异常,避免消息重新进入队列: - throw new AmqpRejectAndDontRequeueException("error"); - 但,我们更希望的逻辑是,对于同一条消息,能够先进行几次重试,解决因为网络问题导致的偶发消息处理失败,如果还是不行的话,再把消息投递到专门的一个死信队列。对于来自死信队列的数据,我们可能只是记录日志发送报警, 即使出现异常也不会再重复投递。整个逻辑如下图所示: - ![消息模式广播6](pic/消息模式广播6.png) - 针对这个问题,Spring AMQP 提供了非常方便的解决方案: - 首先,定义死信交换器和死信队列。其实,这些都是普通的交换器和队列,只不过被我们专门用于处理死信消息。 - 然后,通过 RetryInterceptorBuilder 构建一个 RetryOperationsInterceptor,用于处理失败时候的重试。这里的策略是,最多尝试 5 次(重试 4 次);并且采取指数退避重试,首次重试延迟 1 秒,第二次 2 秒,以此类推,最大延迟是 10 秒 ;如果第 4 次重试还是失败,则使用 RepublishMessageRecoverer 把消息重新投入一个“死信交换器”中。 - 最后,定义死信队列的处理程序。这个案例中,我们只是简单记录日志。 - **参考代码**: com.baiye.demo.case25.deadletter.RabbitConfiguration | com.baiye.demo.case25.deadletter.MQListener.deadHandler - 执行程序,发送两条消息: - msg1 的 4 次重试间隔分别是 1 秒、2 秒、4 秒、8 秒,再加上首次的失败,所以最大尝试次数是 5。 - 4 次重试后,RepublishMessageRecoverer 把消息发往了死信交换器。 - 死信处理程序输出了 got dead message 日志。 - 这里需要尤其注意的一点是,虽然我们几乎同时发送了两条消息,但是 msg2 是在 msg1的四次重试全部结束后才开始处理。原因是,默认情况下**SimpleMessageListenerContainer 只有一个消费线程**。可以通过增加消费线程来避免 性能问题,如下我们直接设置 concurrentConsumers 参数为 10,来增加到 10 个工作线程: - 当然,我们也可以设置 maxConcurrentConsumers 参数,来让SimpleMessageListenerContainer 自己动态地调整消费者线程数。不过,我们需要特别注意它的动态开启新线程的策略。你可以通过官方文档,来了解这个策略 #### D. 总结 - 在使用异步处理这种架构模式的时候,我们一般都会使用 MQ 中间件配合实现异步流程,需要重点考虑四个方面的问题。 - 第一,要考虑异步流程丢消息或处理中断的情况,异步流程需要有备线进行补偿。比如,我们今天介绍的全量补偿方式,即便异步流程彻底失效,通过补偿也能让业务继续进行。 - 第二,异步处理的时候需要考虑消息重复的可能性,处理逻辑需要实现幂等,防止重复处理。 - 第三,微服务场景下不同服务多个实例监听消息的情况,一般不同服务需要同时收到相同的消息,而相同服务的多个实例只需要轮询接收消息。我们需要确认 MQ 的消息路由配置是否满足需求,以避免消息重复或漏发问题。 - 第四,要注意始终无法处理的死信消息,可能会引发堵塞 MQ 的问题。一般在遇到消息处理失败的时候,我们可以设置一定的重试策略。如果重试还是不行,那可以把这个消息扔到专有的死信队列特别处理,不要让死信影响到正常消息的处理 #### E. 补充 - 基于canal做mysql数据同步,需要将解析好的数据发到kafka里 - 发现这么一个问题,就是kafka多partition消费时不能保证消息的顺序消费,进而导致mysql数据同步异常。 - 由于kafka可以保证在同一个partition内消息有序,于是我自定义了一个分区器,将数据的id取hashcode然后根据partition的数量取余作为分区号,保证同一条数据的binlog能投递有序 - 在用户注册后发送消息到 MQ,然后会员服务监听消息进行异步处理的场景下,有些时候我们会发现,虽然用户服务先保存数据再发送 MQ,但会员服务收到消息后去查询数据库,却发现数据库中还没有新用户的信息。 - 建立本地消息表来确保MQ消息可补偿,把业务处理和保存MQ消息到本地消息表操作在相同事务内处理,然后异步发送和补偿发送消息表中的消息到MQ ### 3.26 设计 - 数据存储:NoSQL与RDBMS如何取长补短、相辅相成 - NoSQL 一般可以分为缓存数据库、时间序列数据库、全文搜索数据库、文档数据库、图数据库等。 - 代表: 缓存数据库 Redis、时间序列数据库 InfluxDB、全文搜索数据库 ElasticSearch #### A. 取长补短之 Redis vs MySQL - Redis 是一款设计简洁的缓存数据库,数据都保存在内存中,所以读写单一 Key 的性能非常高 - 自定义一个测试案例进行对比: - MySQL 和 Redis 随机读取单条数据的性能 - **案例代码**: com.baiye.demo.case26.redisvsmysql - 使用 wrk 加 10 个线程 50 个并发连接做压测 ```shell wrk -t 10 -c 50 10s http://localhost:45678/redisvsmysql/mysql --latency ``` - 可以看到,MySQL 90% 的请求需要 61ms,QPS 为 1460;而 Redis 90% 的请求在 5ms 左右,QPS 达到了14008,几乎**是 MySQL 的十倍**: - ![MysqlVsRedis1](pic/MysqlVsRedis1.png) - 但 Redis 薄弱的地方是,不擅长做 Key 的搜索。对 MySQL,我们可以使用 LIKE 操作前匹配走 B+ 树索引实现快速搜索;但对 Redis,我们使用 Keys 命令对 Key 的搜索,其实相当于在 MySQL 里做全表扫描。 - **案例代码**: com.baiye.demo.case26.redisvsmysql.PerformanceController# redis2 | mysql2 - 可以看到,在 QPS 方面,MySQL 的 QPS 达到了 Redis 的 157 倍;在延迟方面MySQL 的延迟只有 Redis 的**十分之一**。 - ![MysqlVsRedis2](pic/MysqlVsRedis2.png) - Redis 慢的原因有两个: - Redis 的 Keys 命令是 O(n) 时间复杂度。如果数据库中 Key 的数量很多,就会非常慢。 - Redis 是单线程的,对于慢的命令如果有并发,串行执行就会非常耗时。 - 我们使用 Redis 都是针对某一个 Key 来使用,而不能在业务代码中使用 Key命令从 Redis 中“搜索数据”,因为这不是 Redis 的擅长。 - 对于 Key 的搜索,我们可以先通过关系型数据库进行,然后再从 Redis 存取数据(如果实在需要搜索 Key 可以使用SCAN 命令)。 - 在生产环境中,我们一般也会配置 Redis 禁用类似 Keys 这种比较危险的命令 - **总结** - 对于业务开发来说,大多数业务场景下Redis 是作为关系型数据库的辅助用于缓存的,我们一般不会把它当作数据库独立使用。 - Redis 提供了丰富的数据结构(Set、SortedSet、Hash、List),并围绕这些数据结构提供了丰富的 API。如果我们好好利用这个特点的话,可以直接在 Redis中完成一部分服务端计算,避免“读取缓存 -> 计算数据 -> 保存缓存”三部曲中的读取和 保存缓存的开销,进一步提高性能。 #### B. 取长补短之 InfluxDB vs MySQL - 时序数据库的优势,在于处理指标数据的聚合,并且读写效率非常高。 - 测试来对比下 InfluxDB 和 MySQL 的性能。 - 我们分别填充了 1000 万条数据到 MySQL 和 InfluxDB 中。其中,每条数据只有 ID、时间戳、10000 以内的随机值这 3 列信息,对于 MySQL 我们把时间戳列做了索引: - **案例代码** : com.baiye.demo.case26.influxdbvsmysql - InfluxDB 批量插入 1000 万条数据仅用了 54 秒,相当于每秒插入 18 万条数据,速度相当快;MySQL 的批量插入,速度也挺快达到了每秒 4.8 万。 - 对这 1000 万数据进行一个统计,查询最近 60 天的数据,按照 1 小时的时间粒度聚合,统计 value 列的最大值、最小值和平均值,并将统计结果绘制成曲线图: - **案例代码** :com.baiye.demo.case26.influxdbvsmysql.PerformanceController # mysql influxdb - 因为数据量非常大,单次查询就已经很慢了,所以这次我们不进行压测。分别调用两个接口,可以看到 **MySQL 查询一次耗时 29 秒左右,而 InfluxDB 耗时 980ms** - 按照时间区间聚合的案例上,我们看到了 InfluxDB 的性能优势。但,我们肯定**不能把InfluxDB 当作普通数据库**,原因是: - InfluxDB 不支持数据更新操作,毕竟时间数据只能随着时间产生新数据,肯定无法对过去的数据做修改; - 从数据结构上说,时间序列数据数据没有单一的主键标识,必须包含时间戳,数据只能和时间戳进行关联,不适合普通业务数据。 - 此外需要注意,**即便只是使用 InfluxDB 保存和时间相关的指标数据,我们也要注意不能滥用 tag**。 - InfluxDB 提供的 tag 功能,可以为每一个指标设置多个标签,并且 tag 有索引,可以对tag 进行条件搜索或分组。但是,tag 只能保存有限的、可枚举的标签,不能保存 URL 等 信息,否则可能会出现high series cardinality 问题,导致占用大量内存,甚至是OOM。可以查看 series 和内存占用的关系。 - 对于 InfluxDB,我们无法把URL 这种原始数据保存到数据库中,只能把数据进行归类,形成有限的 tag 进行保存。 - **总结** - MySQL 而言,针对大量的数据使用全表扫描的方式来聚合统计指标数据,性能非常差,一般只能作为临时方案来使用。 - 时间序列数据库可以作为特定场景(比如监控、统计)的主存储,也可以和关系型数据库搭配使用,作为一个辅助数据源,保存业务系统的指标数据。 #### C. 取长补短之 Elasticsearch vs MySQL - Elasticsearch(以下简称 ES),是目前非常流行的分布式搜索和分析数据库,独特地倒排索引结构尤其适合进行全文搜索。 - 简单来讲,倒排索引可以认为是一个 Map,其 Key 是分词之后的关键字,Value 是文档ID/ 片段 ID 的列表。我们只要输入需要搜索的单词,就可以直接在这个 Map 中得到所有包含这个单词的文档 ID/ 片段 ID 列表,然后再根据其中的文档 ID/ 片段 ID 查询出实际的 文档内容。 - 对比下使用 ES 进行关键字全文搜索、在 MySQL 中使用 LIKE 进行搜索的效率差距。 - 首先,定义一个实体 News,包含新闻分类、标题、内容等字段。这个实体同时会用作Spring Data JPA 和 Spring Data Elasticsearch 的实体: - **案例代码** :com.baiye.demo.case26.esvsmyql - News 实体, 同时会用作Spring Data JPA 和 Spring Data Elasticsearch 的实体: - CommonMistakesApplication 主程序, 在启动时,我们会从一个 csv 文件中加载 4000 条新闻数据,然后复制 100 份,拼成 40 万条数据,分别写入 MySQL 和 ES: - NewsESRepository | NewsMySQLRepository 使用了 Spring Data,直接定义两个 Repository,然后直接定义查询方法,无需实现任何逻辑即可实现查询,Spring Data 会根据方法名生成相应的 SQL 语句和 ES 查询 DSL - 在这里,我们定义一个 countByCateidAndContentContainingAndContentContaining方法,代表查询条件是:搜索分类等于 cateid 参数,且内容同时包含关键字 keyword1 和keyword2,计算符合条件的新闻总数量: - PerformanceController 我们使用相同的条件进行搜索,搜素分类是 1,关键字是社会和苹果,然后输出搜索结果和耗时: - 分别调用接口可以看到,**ES 耗时仅仅 48ms,MySQL 耗时 6 秒多是 ES 的 100 倍**。很遗憾,虽然新闻分类 ID 已经建了索引,但是这个索引只能起到加速过滤分类 ID 这一单一条件的作用,对于文本内容的全文搜索,B+ 树索引无能为力。 - ES 这种以索引为核心的数据库,也不是万能的,频繁更新就是一个大问题。 - MySQL 可以做到仅更新某行数据的某个字段,但 ES 里每次数据字段更新都相当于整个文档索引重建。即便 ES 提供了文档部分更新的功能,但本质上只是节省了提交文档的网络流量,以及减少了更新冲突,其内部实现还是文档删除后重新构建索引。 因此,如果要在 ES 中保存一个类似计数器的值,要实现不断更新,其执行效率会非常低。 - 分别使用 JdbcTemplate+SQL 语句、ElasticsearchTemplate+ 自定义 UpdateQuery,实现部分更新 MySQL 表和 ES 索引的一个字段,每个方法都是循环更新1000 次: - **案例代码** :com.baiye.demo.case26.esvsmyql.PerformanceController #mysql2 #es2 - 可以看到,MySQL 耗时仅仅 1.5 秒,而 ES 耗时 6.8 秒: - **总结** - ES 是一个分布式的全文搜索数据库,所以与 MySQL 相比的优势在于文本搜索,而且因为其分布式的特性,可以使用一个大 ES 集群处理大规模数据的内容搜索。但,由于 ES 的索引是文档维度的,所以不适用于频繁更新的 OLTP 业务。 - 一般而言,我们会把 ES 和 MySQL 结合使用,MySQL 直接承担业务系统的增删改操作, 而 ES 作为辅助数据库,直接扁平化保存一份业务数据,用于复杂查询、全文搜索和统计。 #### D. 结合 NoSQL 和 MySQL 应对高并发的复合数据库架构 - 每一个存储系统都有其独特的数据结构,数据结构的设计就决定了其擅长和不擅长的场景。 - MySQL InnoDB 引擎的 B+ 树对排序和范围查询友好,频繁数据更新的代价不是太大,因此适合 OLTP(On-Line Transaction Processing)。 - 又比如,ES 的 Lucene 采用了 FST(Finite State Transducer)索引 + 倒排索引,空间效率高,适合对变动不频繁的数据做索引,实现全文搜索。存储系统本身不可能对一份数据使用多种数据结构保存,因此不可能适用于所有场景。 - 虽然在大多数业务场景下,MySQL 的性能都不算太差,但对于数据量大、访问量大、业务复杂的互联网应用来说,MySQL 因为实现了 ACID(原子性、一致性、隔离性、持久性)会比较重,而且横向扩展能力较差、功能单一,无法扛下所有数据量和流量,无法应对所有 功能需求。因此,我们需要通过架构手段,来组合使用多种存储系统,取长补短,实现1+1>2 的效果 - 例子: - 我们设计了一个**包含多个数据库系统的、能应对各种高并发场景的一套数据服务的系统架构**,其中包含了同步写服务、异步写服务和查询服务三部分,分别实现主数据库写入、辅助数据库写入和查询路由。 - 按照服务来依次分析下这个架构。 - ![数据库架构](pic/数据库架构.png) - 首先要明确的是,重要的业务主数据只能保存在 MySQL 这样的关系型数据库中,原因有三点: - RDBMS 经过了几十年的验证,已经非常成熟; - RDBMS 的用户数量众多,Bug 修复快、版本稳定、可靠性很高; - RDBMS 强调 ACID,能确保数据完整 - 有两种类型的查询任务可以交给 MySQL 来做,性能会比较好,这也是 MySQL 擅长的地方: - 按照主键 ID 的查询。直接查询聚簇索引,其性能会很高。但是单表数据量超过亿级后,性能也会衰退,而且单个数据库无法承受超大的查询并发,因此我们可以把数据表进行Sharding 操作,均匀拆分到多个数据库实例中保存。我们把这套数据库集群称作Sharding 集群。 - 按照各种条件进行范围查询,查出主键 ID。对二级索引进行查询得到主键,只需要查询一棵 B+ 树,效率同样很高。但索引的值不宜过大,比如对 varchar(1000) 进行索引不太合适,而索引外键(一般是 int 或 bigint 类型)性能就会比较好。因此,我们可以在 MySQL 中建立一张“索引表”,除了保存主键外,主要是保存各种关联表的外键,以及尽可能少的 varchar 类型的字段。这张索引表的大部分列都可以建上二级索引,用于进行简单搜索,搜索的结果是主键的列表,而不是完整的数据。由于索引表字段轻量并且数量不多 (一般控制在 10 个以内),所以即便索引表没有进行 Sharding 拆分,问题也不会很大。 - 如图上蓝色线所示,写入两种 MySQL 数据表和发送 MQ 消息的这三步,我们用**一个同步写服务**完成了。在“异步处理”中提到,所有异步流程都需要补偿,这里的异步流程同样需要。只不过为了简洁,这里省略了补偿流程。 - 然后,如图中绿色线所示,有一个**异步写服务**,监听 MQ 的消息,继续完成辅助数据的更新操作。这里我们选用了 ES 和 InfluxDB 这两种辅助数据库,因此整个异步写数据操作有三步: - 1- MQ 消息不一定包含完整的数据,甚至可能只包含一个最新数据的主键 ID,我们需要根据 ID 从查询服务查询到完整的数据。 - 2- 写入 InfluxDB 的数据一般可以按时间间隔进行简单聚合,定时写入 InfluxDB。因此,这里会进行简单的客户端聚合,然后写入 InfluxDB。 - 3- ES 不适合在各索引之间做连接(Join)操作,适合保存扁平化的数据。比如,我们可以把订单下的用户、商户、商品列表等信息,作为内嵌对象嵌入整个订单 JSON,然后把整个扁平化的 JSON 直接存入 ES。 - 对于数据写入操作,我们认为操作返回的时候同步数据一定是写入成功的,但是由于各种原因,异步数据写入无法确保立即成功,会有一定延迟,比如: - 异步消息丢失的情况,需要补偿处理; - 写入 ES 的索引操作本身就会比较慢; - 写入 InfluxDB 的数据需要客户端定时聚合。 - 因此,对于查询服务,如图中红色线所示,我们需要根据一定的上下文条件(比如查询一致性要求、时效性要求、搜索的条件、需要返回的数据字段、搜索时间区间等)来把请求路由到合适的数据库,并且做一些聚合处理: - 需要根据主键查询单条数据,可以从 MySQL Sharding 集群或 Redis 查询,如果对实时性要求不高也可以从 ES 查询。 - 按照多个条件搜索订单的场景,可以从 MySQL 索引表查询出主键列表,然后再根据主键从 MySQL Sharding 集群或 Redis 获取数据详情。 - 各种后台系统需要使用比较复杂的搜索条件,甚至全文搜索来查询订单数据,或是定时分析任务需要一次查询大量数据,这些场景对数据实时性要求都不高,可以到 ES 进行搜索。此外,MySQL 中的数据可以归档,我们可以在 ES 中保留更久的数据,而且查询历 史数据一般并发不会很大,可以统一路由到 ES 查询。 - 监控系统或后台报表系统需要呈现业务监控图表或表格,可以把请求路由到 InfluxDB 查询。 - **case总结** - 对比 - Redis 对单条数据的读取性能远远高于 MySQL,但不适合进行范围搜索。 - InfluxDB 对于时间序列数据的聚合效率远远高于 MySQL,但因为没有主键,所以不是一个通用数据库。 - ES 对关键字的全文搜索能力远远高于 MySQL,但是字段的更新效率较低,不适合保存频繁更新的数据。 - 混合使用 MySQL + Redis + InfluxDB + ES 的架构方案 - 充分发挥了各种数据库的特长,相互配合构成了一个可以应对各种复杂查询,以及高并发读写的存储架构。 - 主数据由两种 MySQL 数据表构成,其中索引表承担简单条件的搜索来得到主键,Sharding 表承担大并发的主键查询。主数据由同步写服务写入,写入后发出 MQ 消息。 - 辅助数据可以根据需求选用合适的 NoSQL,由单独一个或多个异步写服务监听 MQ 后异步写入。 - 由统一的查询服务,对接所有查询需求,根据不同的查询需求路由查询到合适的存储,确保每一个存储系统可以根据场景发挥所长,并分散各数据库系统的查询压力。 - **case补充** - 对于一个高并发的系统,索引库保存多长时间, 清理方案? - 至少三个月 取决于业务上让用户查多久的订单 更长期地打到es - 多数据库系统例子里,redis写入是怎么实现的呢? - 取决于缓存的使用策略,比如Cache aside, Read through, Write through,可以根据你需要的方案把写入redis的操作落到读服务或同步写服务去实现。 - influxdb,和es 这些数据库做辅助数据库,需要保存全量数据吗?还是根据业务保存部分字段? - es一般是保存全量数据,甚至是超全量数据,意思就是比mysql保存的数据还要全。influxdb 作为时间序列数据库只能保存加工后的业务或系统指标数据,无法保存实际的业务数据。 - mysql索引库 是指表上建立了索引的库吗? - 是指这个表大多字段都建了索引,主要用于简单搜索,索引库对比Sharding库 - mongo数据库的使用建议 - 非重要数据,并且数据结构不固定的,插入量又很大的原始数据(比如爬虫原始数据)可以考虑Mongo。从个人喜好而言,综合性NOSQL,我更喜欢ES而不是Mongo,Mongo在数据量到TB级别我感觉不稳定,Sharding也不那么好用 ### 3.30 安全 - 如何正确保存和传输敏感数据 ### n. 总结 #### n.1 Java避坑建议 - 1- 遇到自己不熟悉的新类,在不了解之前不要随意使用 - CopyOnWriteArrayList。如果你仅仅认为CopyOnWriteArrayList 是 ArrayList 的线程安全版本,在不知晓原理之前把它用于大量写操作的场景,那么很可能会遇到性能问题。 - 越普适的工具类通常用起来越简单,越高级的类用起来越复杂,也更容易踩坑。代码加锁这一讲中提到的,锁工具类 StampedLock 就比 ReentrantLock 或者 synchronized 的用法复杂得多,很容易踩坑。 - 2- 尽量使用更高层次的框架 - 偏底层的框架趋向于提供更多细节的配置,尽可能让使用者根据自己的需求来进行不同的配置,而较少考虑最佳实践的问题;而高层次的框架,则会更多地考虑怎么方便开发者开箱即用。 - 谈到 Apache HttpClient 的并发数限制问题。如果你使用 Spring Cloud Feign 搭配 HttpClient,就不会遇到单域名默认 2 个并发连接的问题。因为,Spring Cloud Feign 已经把这个参数设置为了 50。 - 3- 关注各种框架和组件的安全补丁和版本更新 - 更新升级,以避免组件和框架本身的性能问题或安全问题带来的大坑。 - 4- 尽量少自己造轮子,使用流行的框架 - 如果我们自己去开发框架的话,很可能会踩一些别人已经踩过的坑。使用 Netty 开发 NIO 网络程序,不但简单而且可以少踩很多坑。 - 5- 开发的时候遇到错误,除了搜索解决方案外,更重要的是理解原理 - 只有知其所以然,才能从根本上避免踩坑。 - 6- 网络上的资料有很多,但不一定可靠,最可靠的还是官方文档 - 对于系统学习某个组件或框架,我最推荐的还是 JDK 或者三方库的官方文档。这些文档基本不会出现错误的示例,一般也会提到使用的最佳实践,以及最需要注意的点 - 7- 做好单元测试和性能测试 - 没有经过性能测试的代码,只能认为是完成了功能,还不能确保健壮性、可扩展性和可靠性。 - 8- 做好设计评审和代码审查工作 - 人都会犯错,而且任何一个人的知识都有盲区。每一段代码都能有至少三个人进行代码审核,就可以极大地减少犯错的可能性。 - 9- 借助工具帮我们避坑 - 使用工具来检测,就可以避免大量的低级错误 - 10- 做好完善的监控报警 - 基于合理阈值设置报警,那么可能就能在事故的婴儿阶段及时发现问题、解决问题。