# spring-all **Repository Path**: theskyzero/spring-all ## Basic Information - **Project Name**: spring-all - **Description**: spring全家桶常用技术栈示例及基本原理 - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 2 - **Created**: 2021-05-21 - **Last Updated**: 2023-05-24 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README [toc] # Java后端常用技术栈 --- 目前小伙伴有的对所使用的技术栈了解的还不够全面,这里我们大概从应用、中间件、数据存储等几个维度来列一下Java后端web项目开发常用的技术栈,并配以基本示例。 基本的核心就一个,**拥抱spring,面向spring开发**。 文档仓库链接:https://gitee.com/theskyzero/spring-all # 工具 1. 构建工具使用maven哈,因为大家对这个更熟悉点,gradle的推广还有一定的路要走啊~ 2. 代码管理使用git,别用svn,别用svn,别用svn,重要的事情说三遍!你能轻松的在本地随时管理分支嘛?!? 3. 开发工具使用IDEA ## maven maven主要了解: 1. 安装使用(略,IDEA自带) 2. 配置 3. maven功能 4. pom ### 配置 默认: ${user.home}/.m2/settings.xml 配置项: 本地仓库, 中央仓库 ```xml F:\Resource\Tools\maven\repository nexus-yunda power_service service123 central central aliyun maven http://maven.aliyun.com/nexus/content/groups/public/ ``` ### 命令 ```shell mvn clean install ``` 使用IDEA的`maven helper`插件可以更友好的使用maven命令功能。细心的小伙伴应该有注意到,maven执行命令调用的是其插件,通过插件来执行不同的功能。我们一般是不会在pom里指定插件呢? 常用的cmd和插件的对应关系如下: 1. clean: `maven-clean-plugin`: 清理maven构建生成的目录和文件,一般就是target目录 2. compiler: `maven-compiler-plugin`:编译源码成字节码;也会先执行如`lombok`等插件先生成源码,在编译成字节码[^一次异常]。 3. test: `maven-surefire-plugin`: 编译并运行测试。一般发布也可能会设置`maven.test.skip`来跳过单元测试的编译和运行 4. package: `maven-jar-plugin`/`spring-boot-maven-plugin`:构建jar,对于可执行jar,一般使用`spring-boot-maven-plugin`即可 5. install:`maven-install-plugin`:安装jar到本地maven仓库;也可以利用install来手动安装jar包(离线的环境) 6. deploy:`maven-deploy-plugin`:发布到远程仓库,结合`distributionManagement`使用 ### pom 项目pom可做的事情很多,最基本的依赖管理,依赖使用,插件依赖管理,插件依赖使用。添加项目信息、仓库信息、发布管理等等带过就好了,建议大家在使用pom时保持良好的风格,比如: 1. properties管理依赖版本号 2. `dependencyManagement`管理依赖 3. `dependencies`导入依赖 4. `pluginManagement`管理插件依赖,一般用不着,使用`springboot`默认集成了 5. `plugins`管理插件,普通jar可使用`maven-jar-plugin`/`maven-source-plugin`,可执行jar使用`spring-boot-maven-plugin` --- [^一次异常]: 这里我们有个小伙伴出现过奇怪的问题,使用`lombok`的/`mapstruct`情况下,生成源码后(generate-sources目录),同时在项目包和generate-sources包下都产生了字节码,导致class全限定名冲突编译失败,很奇怪指定使用了compiler插件正常了,怀疑使用错了插件忘记看日志了 # springboot `springboot`的出现,占据了Java后端web开发框架老大的宝座,不管是传统的单体项目还是分布式、微服务项目,`springboot`都能快速搭建开发。前后端项目的分离,以及像中台能力的建设,后端的开发相对更加专注于服务的设计和数据的交互(所以`restful`推出也是有一定背景的呗?) ## 文档 其实对于`springboot`的使用,我更建议大家有空阅读下官方文档就好,比较细致全面的对`springboot`的特性都有描述 ## 依赖 ```xml org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-validation ``` ## 使用 拥抱`springboot`注解开发方式,侵入较轻(比如依赖包设置`optinal`,不会影响正常代码,但如果是实现接口类的侵入,就很难过了比如`ApplicationContextAware`) ### web 1. `@RestController`、`@RequestMapping`、`@GetMapping`、`@PostMapping`、`@PutMapping`、`@DeleteMapping`、`@PathVariable`、`@RequestParam`、`@RequestHeader`、`@RequestAttribute` 2. `@RestControllerAdvice`,`@ExceptionHandler` 3. `@Validated`、`@Valid`、`@Pattern`、`@NotBlank`... ### 配置类 1. `@Component`、`@Repository`、`@Service`、`@RestController`、 2. `@SpringBootApplication、@Configuration`、`@Bean`、`@Scope`、`@ConditionalOnMissingBean`、`@ConditionalOnClass`、`@ConditionalOnProperty`... 3. `@Primary`、`@Priority` 4. `@Autowired`、`@DependsOn`、`@Lazy` 5. `@ComponentScan`、`@Import`、`@Profile`、`@PropertySource` 6. `@ConfigurationProperties`、`@Value` ### 自动配置 `springboot`自动配置遵循约定大于配置,集成第三方组件,并提供默认的配置注入。也建议开发小伙伴在建立配置类时遵循约定大于配置的理念,为配置类提供默认的约定配置。 `springboot`自动配置也是使用spi的机制,通过`SpringFactoriesLoader`加载`META-INF/spring.factories`文件,获取自动配置类。通常开发`springboot`项目使用`@SpringBootApplication`或者`@ComponentScan`扫描bean注入足够了,但有时候我们做一些公共依赖包时,也可以使用自动配置相关Bean来自动注入spring容器。 自动注入对于多模块应用,有个好处是可以调整自动注入配置类的顺序,以及控制某些配置类是否注入,尤其写一些公共包时比较灵活。 `bizframework`项目有使用自动配置的功能。 # spring `SpringAop`、声明式事务 ## SpringAop ### 依赖 ```xml org.springframework.boot spring-boot-starter-aop ``` ### 自动配置 略,`AspectJAwareAdvisorAutoProxyCreator`通过`SmartInstantiationAwareBeanPostProcessor`增强 ### 使用 1. `@EnableAspectJAutoProxy`、`@Aspect`、 2. `@Pointcut`、`@Before`、`@Around`、`@After`、`@AfterReturning`、`@AfterThrowing`、 3. `JoinPoint`、`ProceedingJoinPoint` ## spring事务 spring事务用来管理事务,事务的隔离级别和传播行为。切面的方式织入事务。事务的实现由数据存储提供支持。 ### 依赖 引入`spring-jdbc`、`spring-data-commons`的组件会默认提供`spring-tx`依赖 ### 自动配置 1. `TransactionAutoConfiguration`:`TransactionManagerCustomizers` 2. `TransactionProperties`、`PlatformTransactionManagerCustomizer` ### 使用 1. `@Transactional`、`Propagation`、`Isolation` # spring-data ## db 面向关系型数据库,数据库依赖的组件基本如下: 1. 数据库驱动:与实际使用的数据库有关,互联网行业一般使用mysql。示例中我会使用h2文件/内存数据库。这个区别不大 2. 连接池:`druid` vs `hikari`, `druid`的功能更强(sql拦截监控等),历经稳定的生产检验。`hikari`是`springboot`默认的连接池,性能更好(更偏重只做连接池本身)。建议业务复杂,依赖db的系统使用`druid`,可以借助其强大的sql监控能力。对于业务简单的系统使用`hikari`也足够了。 3. `orm`:`jdbc`、`mybatis`、`jpa`等等 ### 依赖 ```xml com.h2database h2 com.alibaba druid-spring-boot-starter org.springframework.boot spring-boot-starter-jdbc org.springframework.boot spring-boot-starter-data-jpa org.mybatis.spring.boot mybatis-spring-boot-starter ``` ### 自动配置 1. `DataSourceAutoConfiguration` 2. `DataSourceConfiguration`:`HikariDataSource` 3. `DataSourceProperties` 4. `DataSourceTransactionManagerAutoConfiguration`:`DataSourceTransactionManager` 5. `JdbcTemplateAutoConfiguration`:`JdbcTemplate`、`NamedParameterJdbcTemplate` 6. `JdbcProperties` 7. `HibernateJpaAutoConfiguration`:`PlatformTransactionManager`、`JpaVendorAdapter`、`EntityManagerFactoryBuilder`、`LocalContainerEntityManagerFactoryBean`、`OpenEntityManagerInViewInterceptor` 8. `JpaProperties`、`HibernateProperties` 9. `MybatisAutoConfiguration`:`SqlSessionFactory`、`SqlSessionTemplate` 10. `MybatisProperties` **druid** 1. `DruidDataSourceAutoConfigure`:`ServletRegistrationBean`、`DruidStatInterceptor`、`FilterRegistrationBean` 2. `DruidFilterConfiguration`:`StatFilter`、`WallFilter`... 3. `DruidDataSourceWrapper`、`DruidStatProperties` ### 使用 1. `spring-jdbc`: `JdbcTemplate` 2. `mybatis-spring`: `@MapperScan`、代码生成器 3. `spring-data-jpa`: `JpaRepository`、`@Entity`、`@Table`、`@Id`... ### 一般配置 ```yaml spring: # datasource datasource: driver-class-name: org.h2.Driver url: jdbc:h2:mem:test username: ds password: 123456 hikari: minimum-idle: 8 maximum-pool-size: 20 druid: enable: true filter: stat: enabled: true min-idle: 8 max-active: 20 validation-query: select 1 jpa: show-sql: true hibernate: ddl-auto: update jdbc: template: fetch-size: -1 mybatis: mapper-locations: classpath*:/mapper/*.xml ``` ### sharding-jdbc 目前我们使用sharding-jdbc来做数据库的分库分表中间件。这里说的是横向分库分表,业务上一张逻辑表,物理上分库分表。 #### 依赖 ```xml org.apache.shardingsphere sharding-jdbc-spring-boot-starter ``` #### 自动配置 1. `SpringBootConfiguration`: `ShardingDataSource` 2. `SpringBootShardingRuleConfigurationProperties`、`SpringBootMasterSlaveRuleConfigurationProperties`、`SpringBootEncryptRuleConfigurationProperties`、`SpringBootPropertiesConfigurationProperties`、`SpringBootShadowRuleConfigurationProperties` #### 配置文件 ```yaml spring: shardingsphere: datasource: names: ds0 ds0: # sharding-jdbc 根据type获取datasource,并设置属性;不复用spring-datasource type: com.alibaba.druid.pool.DruidDataSource driver-class-name: org.h2.Driver url: jdbc:h2:mem:test username: ds password: 123456 props: sql.show: false sharding: # 分库策略 default-database-strategy: inline: sharding-column: id algorithm-expression: ds$->{id % 2} # 分表策略: default-table-strategy: inline: sharding-column: id algorithm-expression: t$->{id % 1024} # 分布式id default-key-generator: type: SNOWFLAKE column: id props: worker.id: 1 # 相同分库分表规则的多个表,join不用跨库 binding-tables: t1,t2 # 广播表,每个数据源都冗余的一张表,避免跨库join broadcast-tables: t0 # 实际分表配置 tables: t1: logicTable: t1 actual-data-nodes: ds$->{0..1}.t$->{0..1023} ``` ### mysql > 索引、事务、锁 `mysql`是一个开源的数据库,设计上它的物理存储层是插件式的可解耦,`innodb`是其应用超广的一个存储引擎,支持行锁和事务。 #### 索引 > 揭开索引的神秘面纱,所谓的索引设计、索引优化也就不神秘了 索引是一种有序的数据结构,`innodb`的索引是B+树,数据与索引存在一起,叶子节点存数据,非叶子节点存索引。这样的好处是,非叶子节点层一次读取可以读取更多的索引值,减少io次数。另外数据都在叶子节点上,支持高性能的范围查询。 `innodb`的索引是怎么建的呢?默认会按主键建主键索引(聚簇索引),普通的索引,索引的数据是主键值,所以就有回表查询和复合索引优化。关于索引的设计上,要结合数据使用场景尽可能减少索引字段和索引字段长度,使用区分度高的字段作为索引字段。 那么对于查询的优化,说的都是对普通索引的查询场景,无非是让查询走到索引,缩小查询范围,减少回表次数。所以最左前缀,模糊查询、范围查询、索引失效等等优化原则或者问题产生都是显而易见的,因为没有正确的使用好索引。 #### 事务 `innodb`的事务是通过`MVCC`(多版本并发控制)来做的,有兴趣额外了解,看看`mvcc`是怎么实现事务的`ACID`特性的。 #### 锁 `innodb`设计上是行锁,悲观锁的方式。行锁其实就是锁的粒度到数据记录级,那是怎么做的呢?其实就是锁索引,每条数据都有主键的,锁在索引上就能控制对单条数据的访问。所以这里会有个间隙锁,其实就是范围查询锁住了整个范围(向前向后)。 ## redis ### 依赖 ```xml org.springframework.boot spring-boot-starter-data-redis org.apache.commons commons-pool2 ``` ### 自动配置 1. `RedisAutoConfiguration`: `RedisTemplate`、`StringRedisTemplate` 2. `LettuceConnectionConfiguration`:`DefaultClientResources`、`LettuceConnectionFactory` 3. `LettuceClientConfigurationBuilderCustomizer` 4. `RedisProperties` ### 使用 1. `spring-data-redis`: 使用 `RedisTemplate`、`StringRedisTemplate`、`RedisConnectionFactory`、`RedisConnection` 2. `lettuce`: `RedisClusterClient`、`StatefulConnection` ### 配置文件 ```yml spring: # redis redis: cluster: nodes: 127.0.0.1:6379,127.0.0.1:6379 max-redirects: 3 lettuce: cluster: refresh: # lettuce 集群节点刷新,低版本不支持 adaptive: true period: 15s dynamic-refresh-sources: true pool: min-idle: 8 max-wait: 300ms # Read timeout. timeout: 5s password: 123456 client-type: lettuce connect-timeout: 10s ``` ### redis redis是kv内存数据库,5种丰富的数据结构类型,单线程执行命令,多路复用的线程模型。 #### 集群架构 3个节点+组成AP集群架构,也有过半机制防止脑裂问题。数据分片存储在16384个slot上。 crc16(key) & (16384-1)-> slot 注意redis命令基本是单机的,所以像mget、mset可能是有问题的。(其实就是看clusterClient如何实现了,按key计算slot分组到多个节点分别mget再汇总也不是不行?) ## kafka ### 依赖 ```xml org.springframework.kafka spring-kafka ``` ### 自动配置 1. `KafkaAutoConfiguration`:`KafkaTemplate`、`ProducerListener`、`ConsumerFactory`、`ProducerFactory`、`KafkaTransactionManager`、`KafkaJaasLoginModuleInitializer`、`KafkaAdmin` 2. `KafkaAnnotationDrivenConfiguration`:`ConcurrentKafkaListenerContainerFactoryConfigurer`、`ConcurrentKafkaListenerContainerFactory` 3. `KafkaProperties` ### 使用 1. `spring-kafka`: `KafkaTemplate`、`@KafkaListener`、`@KafkaHandler`、`KafkaAdmin` 2. `kafka`:`KafkaProducer`、`KafkaConsumer`、`KafkaAdminClient` ### 配置文件 ```yml spring: # kafka kafka: bootstrap-servers: 127.0.0.1:9092 producer: bootstrap-servers: 127.0.0.1:9092 # acks: -1 # batch-size: 10 key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer # compression-type: lz4 # retries: 0 # transaction-id-prefix: "tx" consumer: bootstrap-servers: 127.0.0.1:9092 enable-auto-commit: false auto-offset-reset: latest group-id: "default" key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializer max-poll-records: 50 listener: type: single ack-mode: batch concurrency: 1 ``` ### kafka 生产者、主题、分区、副本(LSR)、消费者、消费者组、索引 ## elasticsearch ### 依赖 ```xml org.springframework.boot spring-boot-starter-data-elasticsearch ``` ### 自动配置 1. `ElasticsearchRestClientAutoConfiguration`: `RestClientBuilder`、`RestHighLevelClient`、 2. `RestClientBuilderCustomizer` 3. `ElasticsearchRestClientProperties` 4. `ElasticsearchDataAutoConfiguration`:`ElasticsearchCustomConversions`、`SimpleElasticsearchMappingContext`、`ElasticsearchConverter`、`ElasticsearchRestTemplate` ### 使用 1. `spring-data-elasticsearch`: `ElasticsearchRestTemplate`、`ElasticsearchRepository` 2. `elasticsearch`:`RestHighLevelClient` ### 配置文件 ```yaml spring: # elasticsearch elasticsearch: rest: uris: localhost:9200,localhost:9200,localhost:9200 username: elastic password: 123456 connection-timeout: 1s read-timeout: 30s ``` ### elasticsearch 索引、分片、路由、倒排索引 # production 生产特性,比如日志、监控 ## 日志 门面模式+日志实现(`slf4j`+`logback`/`log4j2`) ### 依赖 **logback** ```xml org.springframework.boot spring-boot-starter-logging ``` **log4j2** ```xml org.springframework.boot spring-boot-starter-logging ch.qos.logback logback-classic org.apache.logging.log4j log4j-to-slf4j org.springframework.boot spring-boot-starter-log4j2 ``` ### 使用 使用门面方式(`slf4j`),`lombok` ```java import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @Slf4j public class Slf4jLogger { private static final Logger logger = LoggerFactory.getLogger(Slf4jLogger.class); public static void main(String[] args) { log.error("this is a log"); logger.error("this is a log too"); } } ``` ### 配置文件 `springboot`只对`logback`做了一些扩展,通常使用具体日志实现的配置文件 ```yaml logging: level: root: warn com.gitee.theskyzero: info # config: classpath*:/spring-log4j2.xml ``` ## actuator `spring-boot-actuator`+`prometheus`+`grafana`监控体系 ### 依赖 ```xml org.springframework.boot spring-boot-starter-actuator io.micrometer micrometer-registry-prometheus ``` ### 自动配置 1. `MetricsAutoConfiguration`:`MeterRegistryPostProcessor`、`PropertiesMeterFilter`、`AutoConfiguredCompositeMeterRegistry`、`MetricsEndpoint` 2. `PrometheusMetricsExportAutoConfiguration`:`PrometheusConfig`、`PrometheusMeterRegistry`、`CollectorRegistry`、`PrometheusScrapeEndpoint` 3. `PrometheusProperties`、`MeterRegistryCustomizer` ### 配置文件 ```yaml management: endpoints: web: base-path: /actuator exposure: include: metrics,prometheus metrics: tags: application: ${spring.application.name} ``` 访问: http://localhost:8080/actuator/prometheus ### micrometer `Counter`、`DistributionSummary`、`FunctionCounter`、`FunctionTimer`、`Gauge`、`Timer`、`LongTaskTimer`、`TimeGauge` `Clock`、`Tag` # biz-framework `bizframework`基于springboot,封装适应业务项目的脚手架。基本上是对`spring-boot-starter-web`的增强,集成后端web项目统一异常、响应、日志、监控、接口文档等功能,以及集成`spring-data`、`spring-cloud`做简单的封装和增强。 对基本的web应用、消费者应用、分布式任务应用做了封装。 ## biz-framework-web `spring-boot-starter-web`已经很优秀了,轻松搭建后端web框架,`biz-framework-web`主要还是增强`restful`风格api,以及定义统一异常,基于aop定义业务操作日志和服务日志,并定义基本的crud操作/服务/api以及数据映射器接口。 ### 统一响应 **背景** 前后端分离的开发模式(中台也是类似的),后端开发不在提供页面,而是通常提供资源api,共前端调用。形式上即使用`@RestController`替代`@Controller`,用对象替代视图。 `@RestController` = `@Controller` + `@ResponseBody` 被`@ResponseBody`标记的返回值,会经过spring的`HttpMessageConverter`的处理,`springboot`集成了需要`HttpMessageConverter`,对于返回值对象,默认使用`MappingJackson2HttpMessageConverter`处理成`Json`字符串[^开发者的误区]。其实使用过`RestTemplate`的小伙伴也许可能注意到过,`exchange`方法从服务端获取的是个`ResponseEntity`对象。Java是面向对象的语言,可不仅仅是形式上定义个`Class`。 **设计** 统一响应的实现有2种,一种是侵入业务式定义统一响应体对象`R`,通过`R.success(data)`返回封装的传输对象`data`,另一种是不侵入式的封装传输对象`data`。后者的实现方式可以借助`spring`自身提供的扩展`ResponseBodyAdvice`,也可以通过如aop的方式拦截处理。 我们使用了前者定义了`R`对象在`controller`层返回,其一因为目前开发小伙伴对此认识不够,尽量简单点使用清晰易懂的方式,其二后者的方式要区分对待返回`viewName`的`controller`。 **实现** 参考《Java开发手册》及项目已有实现,设计`R`对象如下: ```java package com.yundasys.bizframework.business.web.controller.dto; import com.yundasys.bizframework.business.error.code.ErrorCode; import com.yundasys.bizframework.business.error.code.MessageEnum; import com.yundasys.bizframework.business.error.exception.ServiceException; import lombok.Data; import java.io.Serializable; /** * 统一API响应结果封装 * * @author jacky.liu */ @Data public class R implements Serializable { private static final long serialVersionUID = 1L; private String resMsg; private T data; private String info; private boolean success; public R() { } public R(String resCode, String resMsg, T data, String info, boolean success) { this.resCode = resCode; this.resMsg = resMsg; this.data = data; this.info = info; this.success = success; } public static R success() { return success(null); } public static R success(T data) { return new R<>(ErrorCode.SUCCESS.code(), ErrorCode.SUCCESS.msg(), data, null, true); } public static R fail(MessageEnum messageEnum, String message) { return new R<>(messageEnum.code(), messageEnum.msg(), null, message, false); } public static R fail(ServiceException exception) { return fail(exception.getMessageEnum(), exception.getMessage()); } } ``` --- [^开发者的误区]: 在维也纳的项目代码里,`controller`层,到处可见使用`fastjson`手动转换对象成`String`返回,这是开发者使用上的误区,为了`Json`而`Json`,而其实`spring`已经帮开发者做完了这些事情,开发者只需要关注自己需要的对象是什么就🆗,如果我想支持`xml`呢?如果我想同时支持`json`和`xml`呢?我们知道是应该可以根据`@RequestMapping`的`producers`和`consumers`向请求方提供适应的数据样式的(`Content-Type`)。 ### 异常 **背景** 异常/错误是开发不得不要面对的,如何优雅简单的处理异常/错误呢? 异常无非2种,一种运行时错误,即开发未预知的错误;一种已知/可预知异常,某段程序可能抛出某类异常,开发提前预知。后者还可分为正常的异常和程序运行错误的异常。 相应的,处理方式通常如下[^异常和日志]: 1. 运行时异常,运行时拦截并记录堆栈(最简单的比如空指针) 2. 已知正常异常,抛出返回(如参数校验失败不允许执行的) 3. 可预知异常,捕获忽略(如依赖不重要的组件,异常不影响主程序) 服务的异常通常不暴露给请求方,需要转换为友好的错误提示。 **设计** 异常包含错误码和`Exception`。主要参考《Java开发手册》使用较通用的错误码定义和业务`ServiceException`。 `@RestControllerAdvice`结合`@ExceptionHandler`处理运行时异常,返回友好提示。`AbstractErrorController`处理请求路径错误。 **实现** ```java package com.yundasys.bizframework.business.error.code; import lombok.ToString; /** * 参考Java开发手册,错误码: *

* 1. 【强制】错误码为字符串类型,共 5 位,分成两个部分:错误产生来源+四位数字编号 * 说明:错误产生来源分为 A/B/C, * A 表示错误来源于用户,比如参数错误,用户安装版本过低等问题; * B 表示错误来源于当前系统,往往是业务逻辑出错,或程序健壮性差等问题; * C 表示错误来源于第三方服务,比如 CDN 服务出错,消息投递超时等问题; * 四位数字编号从 0001 到 9999,大类之间的步长间距预留 100 *

* 2. 【强制】全部正常,但不得不填充错误码时返回五个零:00000 *

* 3. 【参考】错误码分为一级宏观错误码、二级宏观错误码、三级宏观错误码。 * 说明:在无法更加具体确定的错误场景中,可以直接使用一级宏观错误码, * 分别是:A0001(用户端错误)、B0001(系统执行出错)、C0001(调用第三方服务出错)。 * * @author wenxy * @date 2020/9/28 */ @ToString public enum ErrorCode implements MessageEnum { /* * 一级宏观码 */ SUCCESS("00000", "操作成功"), A0001("A0001", "用户端错误"), B0001("B0001", "系统执行出错"), C0001("C0001", "调用第三方服务出错"), /* * 二级、三级宏观码 */ /****************************************************************/ // 用户 A0100("A0100", "用户注册失败"), A0200("A0200", "用户登录异常"), A0300("A0300", "访问权限异常"), A0301("A0301", "访问未授权"), A0303("A0303", "用户授权申请被拒绝"), A0310("A0310", "因访问对象隐私设置被拦截"), A0311("A0311", "授权已过期"), A0312("A0312", "无权限使用API"), A0320("A0320", "用户访问被拦截"), A0321("A0321", "黑名单用户"), A0322("A0322", "账号被冻结"), A0323("A0323", "非法IP地址"), A0324("A0324", "网关访问受限"), A0330("A0330", "服务已欠费"), A0340("A0340", "用户签名异常"), A0341("A0341", "RSA签名错误"), A0400("A0400", "用户请求参数错误"), A0402("A0402", "无效的用户输入"), A0410("A0410", "请求必填参数为空"), A0411("A0411", "用户订单号为空"), A0413("A0413", "缺少时间戳参数"), A0414("A0414", "非法的时间戳参数"), A0420("A0420", "请求参数值超出允许的范围"), A0421("A0421", "参数格式不匹配"), A0422("A0422", "地址不在服务范围"), A0423("A0423", "时间不在服务范围"), A0424("A0424", "金额超出限制"), A0425("A0425", "数量超出限制"), A0426("A0426", "请求批量处理总个数超出限制"), A0427("A0427", "请求JSON解析失败"), A0430("A0430", "用户输入内容非法"), A0440("A0440", "用户操作异常"), A0500("A0500", "用户请求服务异常"), A0501("A0501", "请求次数超出限制"), A0502("A0502", "请求并发数超出限制"), A0506("A0506", "用户重复请求"), A0600("A0600", "用户资源异常"), A0605("A0605", "用户配额已用光"), A0700("A0700", "用户上传文件异常"), A0800("A0800", "用户当前版本异常"), A0900("A0900", "用户隐私未授权"), A1000("A1000", "用户设备异常"), /*****************************************************************/ // 系统 B0100("B0100", "系统执行超时"), B0101("B0101", "系统订单处理超时"), B0200("B0200", "系统容灾功能被触发"), B0210("B0210", "系统限流"), B0220("B0220", "系统功能降级"), B0300("B0300", "系统资源异常"), B0310("B0310", "系统资源耗尽"), B0320("B0320", "系统资源访问异常"), B0321("B0321", "系统读取磁盘文件失败"), /****************************************************************/ // 第三方 C0100("C0100", "中间件服务出错"), C0110("C0110", "RPC服务出错"), C0111("C0111", "RPC服务未找到"), C0112("C0112", "RPC服务未注册"), C0113("C0113", "接口不存在"), C0120("C0120", "消息服务出错"), C0121("C0121", "消息投递出错"), C0122("C0122", "消息消费出错"), C0123("C0123", "消息订阅出错"), C0124("C0124", "消息分组未查到"), C0130("C0130", "缓存服务出错"), C0131("C0131", "key长度超过限制"), C0132("C0132", "value长度超过限制"), C0133("C0133", "存储容量已满"), C0134("C0134", "不支持的数据格式"), C0140("C0140", "配置服务出错"), C0150("C0150", "网络资源服务出错"), C0151("C0151", "VPN服务出错"), C0152("C0152", "CDN服务出错"), C0153("C0153", "域名解析服务出错"), C0154("C0154", "网关服务出错"), C0200("C0200", "第三方系统执行超时"), C0210("C0210", "RPC执行超时"), C0220("C0220", "消息投递超时"), C0230("C0230", "缓存服务超时"), C0240("C0240", "配置服务超时"), C0250("C0250", "数据库服务超时"), C0300("C0300", "数据库服务出错"), C0311("C0311", "表不存在"), C0321("C0321", "多表关联中存在多个相同名称的列"), C0331("C0331", "数据库死锁"), C0341("C0341", "主键冲突"), C0400("C0400", "第三方容灾系统被触发"), C0401("C0401", "第三方系统限流"), C0402("C0402", "第三方功能降级"), C0500("C0500", "通知服务出错"), C0501("C0501", "短信提醒服务失败"), C0502("C0502", "语音提醒服务失败"), C0503("C0503", "邮件提醒服务失败"), ; final String code; final String msg; ErrorCode(String code, String msg) { this.code = code; this.msg = msg; } @Override public String code() { return code; } @Override public String msg() { return msg; } public static boolean isSuccess(String code) { return SUCCESS.code.equals(code); } } ``` ```java package com.yundasys.bizframework.business.error.exception; import com.yundasys.bizframework.business.error.code.MessageEnum; /** * ServiceException * * @author wenxy * @date 2020/9/28 */ public class ServiceException extends RuntimeException { private static final long serialVersionUID = -2351852312234556372L; MessageEnum messageEnum; public ServiceException(MessageEnum messageEnum, String s) { // 2020/12/15 不传递自身异常堆栈 this(messageEnum, s, null); } public ServiceException(MessageEnum messageEnum, String s, Throwable throwable) { super(s, throwable); this.messageEnum = messageEnum; } public MessageEnum getMessageEnum() { return messageEnum; } public void setMessageEnum(MessageEnum messageEnum) { this.messageEnum = messageEnum; } } ``` ```java package com.yundasys.bizframework.web.error.handle; import com.yundasys.bizframework.business.error.code.ErrorCode; import com.yundasys.bizframework.business.error.exception.DaoException; import com.yundasys.bizframework.business.error.exception.ServiceException; import com.yundasys.bizframework.business.web.controller.dto.R; import com.yundasys.bizframework.log.common.util.RequestUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import org.springframework.web.util.WebUtils; /** * 控制器异常处理 *

* 1. 处理web框架类异常、自定义异常 * 2. 返回A类错误码 * × 2.1 对于B、C类错误,直接返回 * × 2.2 记录日志,封装A类错误码返回 * * @author wenxy * @date 2020/10/26 */ @RestControllerAdvice @Slf4j public class ControllerErrorHandler extends ResponseEntityExceptionHandler { /** * 第三方调用C类异常-服务端-第三方错误、第二类B类异常-服务端错误 * * @param daoException daoException * * @return ResponseEntity> */ @ExceptionHandler(DaoException.class) public ResponseEntity> handleException(DaoException daoException) { ServiceException serviceException = new ServiceException(daoException.getMessageEnum(), daoException.getMessage()); return handleException(serviceException); } /** * 第三方调用C类异常-服务端-第三方错误、第二类B类异常-服务端错误 * * @param serviceException * * @return */ @ExceptionHandler(ServiceException.class) public ResponseEntity> handleException(ServiceException serviceException) { // B/C类异常在DaoLogAspect/ServiceLogAspect已经处理并打印堆栈,这里冗余简单记录,防止手动异常错误使用,如在Service/controller抛出daoException,在controller抛出service不被处理 logWarnSimple(serviceException); return ResponseEntity.ok(R.fail(serviceException)); } /** * A类异常-客户端错误 覆盖ResponseEntityExceptionHandler,返回统一的异常响应 * * @param ex @param ex * @param body body * @param headers headers * @param status status * @param request request * * @return R */ @Override protected ResponseEntity handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) { // A类异常简单记录信息,不用记录堆栈,堆栈一定是web框架的错误 logWarnSimple(ex); if (HttpStatus.INTERNAL_SERVER_ERROR.equals(status)) { request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, ex, WebRequest.SCOPE_REQUEST); } return ResponseEntity.ok(R.fail(ErrorCode.A0001, ex.getMessage())); } /** * A类异常-客户端错误 * * @param throwable throwable * * @return */ @ExceptionHandler(Throwable.class) public ResponseEntity handleThrowable(Throwable throwable) { // A类异常简单记录信息,不用记录堆栈,堆栈一定是web框架的错误 logWarnSimple(throwable); return ResponseEntity.ok(R.fail(ErrorCode.A0001, throwable.getMessage())); } private void logWarnSimple(Throwable exception) { if (log.isWarnEnabled()) { log.warn("controller error advice: request={},error={}", RequestUtils.getMethodAndUri((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()), exception.getMessage() , exception); } } } ``` ```java package com.yundasys.bizframework.web.error.handle; import com.yundasys.bizframework.business.error.code.ErrorCode; import com.yundasys.bizframework.business.error.exception.ServiceException; import com.yundasys.bizframework.business.web.controller.dto.R; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.web.ErrorProperties; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.autoconfigure.web.servlet.error.AbstractErrorController; import org.springframework.boot.autoconfigure.web.servlet.error.ErrorViewResolver; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.error.DefaultErrorAttributes; import org.springframework.boot.web.servlet.error.ErrorAttributes; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.WebRequest; import javax.servlet.http.HttpServletRequest; import java.util.List; import java.util.Objects; import java.util.Optional; /** * 异常控制器,对默认/error的处理 * * @author theskyzero * @see org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController */ @RestController @RequestMapping("${server.error.path:${error.path:/error}}") @EnableConfigurationProperties({ServerProperties.class}) @Slf4j public class ErrorController extends AbstractErrorController { private static final String ERROR_ATTRIBUTE = DefaultErrorAttributes.class.getName() + ".ERROR"; private final ErrorProperties errorProperties; public ErrorController(ErrorAttributes errorAttributes, ServerProperties errorProperties, List errorViewResolvers) { super(errorAttributes, errorViewResolvers); Assert.notNull(errorProperties, "ErrorProperties must not be null"); this.errorProperties = errorProperties.getError(); } @Override public String getErrorPath() { return errorProperties.getPath(); } @RequestMapping public ResponseEntity> error(HttpServletRequest request) { // 对于抛出到/error的请求,拿到封装的初始请求 WebRequest webRequest = new ServletWebRequest(request); Throwable throwable = getError(webRequest); logWarnSimple(request, throwable); if (Objects.isNull(throwable)) { Object message = webRequest.getAttribute("javax.servlet.error.message", RequestAttributes.SCOPE_REQUEST); return ResponseEntity.ok(R.fail(ErrorCode.B0001, String.valueOf(message))); } ServiceException exception = throwable instanceof ServiceException ? (ServiceException) throwable : new ServiceException(ErrorCode.B0001, throwable.getMessage()); return ResponseEntity.ok(R.fail(exception)); } private Throwable getError(WebRequest webRequest) { return (Throwable) Optional.ofNullable(webRequest.getAttribute(ERROR_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST)) .orElse(webRequest.getAttribute("javax.servlet.error.exception", RequestAttributes.SCOPE_REQUEST)); } private void logWarnSimple(HttpServletRequest request, Throwable exception) { if (log.isWarnEnabled()) { log.warn("dispatch to default error: request={},error={}", request.getRequestURI(), Objects.isNull(exception) ? null : exception.getMessage(), exception); } } } ``` --- [^异常和日志]: 有些小伙伴有记录日志的习惯,是个好习惯,日志的记录可以参考《Java开发手册》,记录日志产生的相关信息,可以帮助定位分析问题等。不过对于业务项目来说,更简易大家采用解耦的方式实现日志等,让业务逻辑更加清晰。简单的有2种,一种是侵入的使用抽象类等方式,提供业务服务基类,在基类里封装通用非业务功能(之前的consumer日志设计上即如此),另一种使用切面方式非侵入实现,本质是类似的,代理类封装通用非业务功能。 ### 日志 **背景** 日志,从业务功能来说,通常可分为操作日志和服务日志。其中操作日志记录接收的操作/请求,如操作者、操作项、操作参数、操作结果、操作时间等信息,通常可以认为一次请求/操作产生一条操作日志。服务日志则侧重于业务应用运行信息,如服务调用方法、出入参数等。 **设计** 实现如上操作日志和服务日志。基于`springboot`,操作日志拦截`@RestController`记录请求的api接口,服务日志则拦截`@Service`、`@Repository`、`@Component`等,借助aop实现日志切面。对于如工具类等,也可在代码中耦合`trace`日志(好习惯,开发业务不太建议,简单清晰对开发更友好) **实现** 结合`MDC`等追踪记录日志。 ```java package com.yundasys.bizframework.log.operation.aspect; import com.yundasys.bizframework.log.common.mdc.MdcGenerator; import com.yundasys.bizframework.log.common.util.TraceUtils; import com.yundasys.bizframework.log.operation.entity.OperationLog; import com.yundasys.bizframework.log.operation.entity.RequestTransfer; import com.yundasys.bizframework.log.operation.repository.OperationLogRepository; import com.yundasys.bizframework.log.operation.repository.impl.Slf4jOperationLogRepositoryImpl; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.slf4j.MDC; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import java.time.Instant; import java.util.Optional; /** * 拦截controller记录操作日志 * * @author theskyzero * @date 2021/4/14 */ @Component @Aspect @Order(Ordered.HIGHEST_PRECEDENCE + 100) public class OperationLogAspect { @Autowired(required = false) OperationLogRepository repository = new Slf4jOperationLogRepositoryImpl(); ThreadPoolTaskExecutor executor; @Autowired(required = false) MdcGenerator mdcGenerator = new MdcGenerator.MdcGeneratorSupport(); public OperationLogAspect(ObjectProvider taskExecutors) { executor = Optional.ofNullable(taskExecutors.getIfAvailable()) .orElse(new ThreadPoolTaskExecutor()); } @Pointcut("@within(org.springframework.stereotype.Controller)||@within(org.springframework.web.bind.annotation.RestController)") public void pointCut() {} @Around("pointCut()") public Object aroundController(ProceedingJoinPoint joinPoint) throws Throwable { Instant accessTime = Instant.now(); boolean success = true; MDC.put(MdcGenerator.TRACE_KEY, mdcGenerator.generateMdc()); try { return joinPoint.proceed(); } catch (Throwable throwable) { success = false; throw throwable; } finally { // 传递请求 RequestTransfer transfer = RequestTransfer.builder() .traceId(TraceUtils.getTraceId()) .accessTime(accessTime) .requestAttributes(RequestContextHolder.currentRequestAttributes()) .args(joinPoint.getArgs()) .success(success) .build(); executor.execute(() -> { // 创建日志 OperationLog operationLog = LogCreator.INSTANCE.create(transfer); repository.save(operationLog); }); MDC.remove(MdcGenerator.TRACE_KEY); } } } ``` ```java package com.yundasys.bizframework.log.service.aspect; import com.yundasys.bizframework.log.common.util.TraceUtils; import com.yundasys.bizframework.log.service.entity.ServiceLog; import com.yundasys.bizframework.log.service.repository.ServiceLogRepository; import com.yundasys.bizframework.log.service.repository.impl.Slf4jServiceLogRepositoryImpl; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Pointcut; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.time.Instant; import java.util.Arrays; import java.util.Optional; /** * @author theskyzero * @date 2021/4/14 */ public class AbstractServiceLogAspect { @Autowired(required = false) ServiceLogRepository repository = new Slf4jServiceLogRepositoryImpl(); ThreadPoolTaskExecutor executor; public AbstractServiceLogAspect(ObjectProvider taskExecutors) { executor = Optional.ofNullable(taskExecutors.getIfAvailable()) .orElse(new ThreadPoolTaskExecutor()); } // 因为日志持久化使用@Repository,要忽略BizLogRepository,否则无限循环触发打印BizLogRepository @Pointcut("!within(com.yundasys.bizframework.log.common.BizLogRepository+)") public void notLogRepositoryPointCut() {} public Object logService(ProceedingJoinPoint joinPoint) { long begin = System.currentTimeMillis(); Object result = null; try { result = joinPoint.proceed(); return result; } catch (Throwable throwable) { result = throwable; throw new UnsupportedOperationException(); } finally { long cost = System.currentTimeMillis() - begin; String traceId = TraceUtils.getTraceId(); Object finalResult = result; executor.execute(() -> { // 创建日志 ServiceLog serviceLog = createLog(joinPoint, traceId, finalResult, begin, cost); repository.save(serviceLog); }); } } private ServiceLog createLog(ProceedingJoinPoint joinPoint, String traceId, Object result, long begin, long cost) { ServiceLog serviceLog = new ServiceLog(); serviceLog.setId(traceId); serviceLog.setCreateTime(Instant.ofEpochMilli(begin)); serviceLog.setMethod(joinPoint.getSignature().toShortString()); serviceLog.setParams(Arrays.asList(joinPoint.getArgs())); serviceLog.setResult(result); serviceLog.setCost(cost); return serviceLog; } } ``` ### Crud 基本的增删改查操作,可能涉及如,daosdk层、domain层、api层。定义基本对象如PO等,定义业务应用代码结构如`BaseDao`、`BaseRepository`、`BaseService`、`BaseController`等、定义各层次数据映射器如`BasePOConverter`、`BaseDTOConverter`、`BaseVOConverter`等。 ```java package com.yundasys.bizframework.business.web.common; import com.yundasys.bizframework.common.exception.NotImplementedException; import java.util.List; /** * @author theskyzero * @date 2021/4/14 */ public interface CrudOperation { default T save(T obj) { throw new NotImplementedException(); } default List save(List objects) { throw new NotImplementedException(); } default T findById(ID id) { throw new NotImplementedException(); } default List findAll() { throw new NotImplementedException(); } default List findById(List ids) { throw new NotImplementedException(); } default long count() { throw new NotImplementedException(); } default void deleteById(ID id) { throw new NotImplementedException(); } default void delete(T obj) { throw new NotImplementedException(); } default void delete(List objects) { throw new NotImplementedException(); } default void deleteAll() { throw new NotImplementedException(); } } ``` ### 开放api **背景** 保证接口安全,要验证请求合法、安全。 **设计** 参考通用的开放api设计,为请求方分配appId和accessKey,对请求参数&accessKey生成请求摘要,验证请求时间戳和请求摘要有效性。 **实现** ```java package com.yundasys.bizframework.web.verify.aspect; import com.yundasys.bizframework.business.error.code.ErrorCode; import com.yundasys.bizframework.business.error.exception.ServiceException; import com.yundasys.bizframework.common.util.Jdk8DateUtils; import com.yundasys.bizframework.web.verify.AnonymousAccessor; import com.yundasys.bizframework.web.verify.OpenApiVerifyProvider; import com.yundasys.bizframework.web.verify.annotation.OpenApiVerify; import com.yundasys.bizframework.web.verify.util.SignUtils; import com.yundasys.bizframework.web.verify.vo.OpenApiVerifyVO; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.Objects; /** * @author wenxy * @date 2021/3/30 */ @Component @Aspect public class OpenApiVerifyAspect { /** * 允许时效范围请求 */ private int allowTimeRange = 5 * 60 * 1000; @Autowired OpenApiVerifyProvider provider = new OpenApiVerifyProvider() {}; @Autowired AnonymousAccessor anonymousAccessor = new AnonymousAccessor() {}; @Before("@within(annotation) || @annotation(annotation)") public void verifyOpenApi(JoinPoint joinPoint, OpenApiVerify annotation) { // 未开启校验 if (Objects.isNull(annotation) || !annotation.enable()) { return; } // 开启校验 Object[] args = joinPoint.getArgs(); for (Object arg : args) { // 开放api校验 if (arg instanceof OpenApiVerifyVO) { // 匿名访问校验 OpenApiVerifyVO vo = (OpenApiVerifyVO) arg; if (annotation.anonymous() && isValidAnonymous(vo)) { return; } // 正常验签 verifyOpenApiRequest(vo); return; } } } /** * 匿名访问验证 * * @param vo 验签对象 * * @return */ private boolean isValidAnonymous(OpenApiVerifyVO vo) { return anonymousAccessor.accessAnonymous(vo); } private void verifyOpenApiRequest(OpenApiVerifyVO vo) { // 校验时间戳,处理5分钟内请求 final long nowT = System.currentTimeMillis(); if (!(vo.getTimestamp() < nowT && nowT - vo.getTimestamp() <= allowTimeRange)) { throw new ServiceException(ErrorCode.A0414, "时间戳校验失败,timestamp=" + Jdk8DateUtils.fromTimestamp(vo.getTimestamp())); } // 校验请求方(获取请求方密钥) String accessKey = provider.provideAccessKey(vo.getAppId()); // 校验签名 String sign = SignUtils.createAccessSign(vo, accessKey); if (!sign.equals(vo.getSign())) { throw new ServiceException(ErrorCode.A0320, "签名校验失败"); } } } ``` ### 事件/异步 spring的事件&监听机制,异步 ### 监控 数据采集&数据处理,读写分离