一、概述
sparrow-domain、sparrow-spring-boot-web-api-starter是sparrow体系中用于开发web服务端的脚手架。
sparrow-domain、sparrow-spring-boot-web-api-starter在Spring Boot、Apache Shiro、Hibernate、ShardingSphere等开源框架的基础上,提供了完整的部门管理、用户管理、角色管理、权限管理、作业管理、日志管理等功能。
使用这个开箱即用的开发底座,开发者只需要关注于核心业务的开发,而无需关注如用户登录、角色权限控制等非核心业务逻辑的实现。
1.1 技术选型
框架 | 版本 | 作用 |
---|---|---|
Spring Boot | 2.7.x | web应用 |
Shiro | 1.10.x | 保护应用,负责身份验证、授权、加密和会话管理 |
Hibernate | 5.6.x | ORM,负责数据持久化 |
Jinq | 1.8.x | 支持LINQ风格的查询 |
ShardingSphere | 5.2.x | 分布式SQL事务和查询引擎,可通过数据分片、弹性伸缩、加密等能力对任意数据库进行增强 |
Quartz | 2.3.x | 作业调度 |
Logback | 1.2.x | 日志 |
二、快速开始
你可以通过以下步骤快速启动一个新应用。
2.1 下载源码
https://gitee.com/phdbutbachelor/sparrow-web-api-template是一个模板项目,开发者可以下载到本地后导入到IDE。
2.2 工程目录
用脚手架开发的工程一般需包含如下目录及文件:
┌─ src 源代码目录│ └─ main 项目目录| | └─ java java源代码目录| | | └─ controller controller文件目录| | | | └─ UserController.java| | | └─ model model文件目录| | | | └─ User.java| | | └─ repository repository文件目录| | | | └─ UserRepository.java| | | └─ service service文件目录| | | | └─ FileService.java| | | └─ BeanConfiguration.java Bean配置类| | | └─ SpringBootApplication.java SpringBoot应用启动类| | └─ resources 资源文件目录| | └─ i18n 国际化支持| | | └─ messages.properties 错误信息配置文件| | └─ application.yml 应用配置文件| | └─ logback-spring.xml Logback配置文件| | └─ shardingsphere.yml ShardingSphere文件| └─ test 测试项目目录└─ pom.xml Maven项目的核心配置文件
2.3 修改项目信息
pom.xml
中定义了项目信息,一般只需要修改project
节点下的groupId
、artifactId
、version
和name
。
从pom.xml
中可以看到,sparrow-spring-boot-web-api-starter是以maven依赖的方式集成到项目中:
<dependency> <groupId>com.chinamobile.sparrow</groupId> <artifactId>sparrow-spring-boot-web-api-starter</artifactId> <version>${sparrow.version}</version> <exclusions> <exclusion> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-web-starter</artifactId> </exclusion> <exclusion> <artifactId>tomcat-embed-core</artifactId> <groupId>org.apache.tomcat.embed</groupId> </exclusion> </exclusions></dependency>
2.4 修改数据库配置
脚手架使用Logback对访问日志进行持久化,考虑到访问日志的数据规模,还引入了ShardingSphere对数据进行分表以提升访问速度。
同时脚手架还使用Quartz进行作业调度,也支持对作业进行持久化。
所以脚手架需要配置两个数据库(当然也可以配置为同一个数据库),并配置Logback和ShardingSphere的功能。
2.4.1 修改application.yml
application.yml
是应用的配置文件,既包含了Spring Boot的标准配置项,也有脚手架的自定义配置项。
Spring Boot的标准配置项可以参考Spring Boot Reference Documentation,脚手架的自定义配置项详见附录。
一般只需要修改数据库的相关配置,即spring.dataSource
中的配置。
2.4.2 修改shardingsphere.yml
shardingphere.yml
是ShardingSphere的配置项,脚手架需要使用ShardingSphere对日志进行分表。
Logback的标准配置项可以参考YAML配置。
一般只需要修改数据库的相关配置,即dataSources.main
中的配置。
2.4.3 修改logback-spring.xml
logback-spring.xml
是Logback的配置项,可以参考LOGBACK。
2.5 运行 & 初始化
完成上述修改后即可运行项目。
项目运行后在浏览器访问初始化API,即可完成用户、角色等基础的数据的初始化。
初始化完成后开发者可以使用默认的管理员账号(admin
/19890306
)登录系统。
三、开发指南
3.1 业务逻辑开发
服务端开发中最基础的功能是对数据库表进行增删查改,一般可以遵循“自底向上”的原则,按照PO -> Repository/Service -> Controller的顺序完成功能开发。
3.1.1 PO开发
PO即持久化对象,它的主要职责是对数据库表进行映射,脚手架使用Hibernate实现实体对象映射。
Hibernate提供了丰富的注解来简化代码开发,我们可以从下述实例中习得常用的注解和开发规范:
package com.chinamobile.sparrow.domain.model.sys;import com.chinamobile.sparrow.domain.model.AbstractEntity;import com.chinamobile.sparrow.domain.util.IdWorker;import org.springframework.util.StringUtils;import javax.persistence.*;import javax.validation.constraints.NotBlank;import javax.validation.constraints.Size;import java.util.Date;@Entity@Table(name = "sys_users", indexes = { @Index(columnList = "id, account, mp"), @Index(columnList = "name, account"), @Index(columnList = "name, account, alphabet, mp")})@Inheritance(strategy = InheritanceType.SINGLE_TABLE)public class User extends AbstractEntity { @Id @Column(length = 36) protected String id = String.valueOf(IdWorker.getInstance().nextId()); // 登录帐号 @Column(length = 64, nullable = false) @NotBlank @Size(max = 64) protected String account; ... // 手机号码 @Column(columnDefinition = "varchar(11) unique") @Size(max = 11) protected String mp; // 电子邮箱 @Column(columnDefinition = "text") protected String email; ... public String getId() { return id; } public void setId(String id) { this.id = id; } public String getAccount() { return account; } public void setAccount(String account) { this.account = StringUtils.trimWhitespace(account); } ...}
使用@Entity
对一个类进行注解,Hibernate会将该类与数据库表进行映射,类名对应于表名,字段则对应于表格的字段。该特性要求类路径在Hibernate的扫描路径下,且默认会根据类定义自动建表和更新表的定义,具体配置可以参考application.yml
。
一般来说,类名和表名的命名规范是不一样的,可以使用@Table
注解指定表名。
@Table
注解还可以用来定义索引,开发者可以根据自己的查询需求自定义索引以加快查询速度。
@Id
注解用来定义主键,开发者可以使用脚手架的IdWorker.getInstance().nextId()
方法,使用雪花算法生成一个分布式id。
@Column
注解则用来定义字段的相关属性,如长度(length
)、允许为空(nullable
)等。
应该注意到,javax.persistence
提供的@Column
可以完成PO的校验,使用javax.validation.constraints
下的@Size
这些注解也可以完成PO的校验,但这两个对表设计的影响和校验时机是不一样的,@Column
注解会影响DDL的生成,即@Column
的属性会创建到表中,并且在持久化的时候才进行校验;而javax.validation.constraints
下的注解则独立于表,除了在持久化时触发验证,也可以配合@Valid
、@Validate
在进入方法时触发验证。
被@Transiant
注解的字段则不会包含在DDL中,非常适合用来定义VO/DTO中区别于PO的字段,在某些情况下可以用来合并PO和VO/DTO,简化类的设计。
对于字符串类型的字段,我们推荐在setter
中使用StringUtils.trimWhitespace
方法来消除字符串头尾的空白字符。
脚手架提供了一个基类AbstractEntity
,以方便开发者快速开发PO和对应的Repository:
AbstractEntity
主要是定义了几个公共字段,并保证由AbstractEntityRepository
来自动维护这些公共字段,不增加开发者的心智负担。
3.1.1.1 内置的PO
com.chinamobile.sparrow.domain.model
包提供了很多内置的PO,用来映射用户、部门这些基础表。
PO | 领域 | 说明 |
---|---|---|
User | 系统 | 用户 |
Department | 系统 | 部门 |
Dictionary | 系统 | 字典 |
Page | 系统 | 网页 |
InfoLog | 系统 | 信息日志 |
ErrorLog | 系统 | 错误日志 |
SentSms | 系统 | 短信发送任务 |
Role | 安全 | 角色 |
Permission | 安全 | 权限 |
VerificationCode | 安全 | 短信验证码 |
Media | 文件 | 文件记录 |
3.1.2 Repository开发
Repository的主要职责是封装数据持久化逻辑,并为应用的其他部分提供数据交互的方式,而不必关心具体的实现细节。
脚手架提供了三个基类AbstractJinqRepository
、AbstractEntityRepository
和AbstractMediaAvailableRepository
,以方便开发者快速开发Repository。
AbstractEntityRepository
和AbstractMediaAvailableRepository
都是泛型类,接受一个AbstractEntity
子类型的参数。
如果你的PO继承自AbstractEntity
,则你的Repository可以继承AbstractEntityRepository
类,从而使用getCurrentSession
、stream
、add
、update
等方法对数据库进行增删查改等操作。
如果在数据持久化的同时还要负责保存文件时,则可以继承AbstractEntityRepositoryAbstractMediaRepository
类。AbstractEntityRepositoryAbstractMediaRepository
重写了AbstractEntityRepository
的add
和update
方法,并新增了remove
方法,可以在数据持久化的时候对getMediaIds
指向的资源进行相应的操作。
当然,如果你的Repository只需要从数据库查询(如读取第三方应用的数据库)的时候,脚手架也提供了AbstractJinqRepository
类,你可以使用其中读取数据的方法来完成开发。
3.1.2.1 数据库查询
Hibernate作为一个ORM框架,支持开发者可以通过HQL或者SQL查询数据库,但脚手架更推荐使用面向对象的查询框架Jinq。通过Jinq,开发者可以像对待存储在集合中的POJO一样对待数据库数据,使用普通的Java代码对它们进行迭代和过滤,所有代码都将被自动翻译成优化的数据库查询。
3.1.2.1.1 单表/多表查询
脚手架提供的三个Repository基类都可以使用Jinq查询功能,你可以使用以下的代码从数据库里查询所有的男性用户:
List<User> _men = stream(User.class).where(i -> i.getIsMale()).toList();
多表查询也很简单,我们在查询用户的同时返回其所在部门:
List<Pair<User, Department>> _men = stream(User.class).where(i -> i.getIsMale()) .leftOuterJoin((i, source) -> source.stream(Department.class), (i, j) -> j.getId().equals(i.getDeptId())) .toList();
更多资料可以参考开发者文档。
3.1.2.1.2 自定义函数
除了SQL标准函数,每个数据库产品一般都会支持非标函数,Jinq也支持开发者在查询过程中使用SQL函数,这涉及到比较多的改动,以脚手架支持MySQL的JSON函数为例:
定义静态函数要在Jinq中使用自定义函数,开发者必须创建对应的静态方法,静态方法不需要有对应的实现,因为当Jinq查询转换为SQL时,将调用数据库对应的函数而不是该静态方法:
public class MySqlFunctions { public static boolean jsonContains(String field, String str) { return false; } public static String jsonExtract(String field, String str) { return null; } }
注册函数开发者需要使用
JinqJPAStreamProvider
对象的registerCustomSqlFunction
方法注册上述函数: JinqJPAStreamProvider _provider = new JinqJPAStreamProvider(entityManagerFactory.createEntityManager().getMetamodel());_provider.registerCustomSqlFunction(MySqlFunctions.class.getDeclaredMethod("jsonContains", String.class, String.class), "json_contains");_provider.registerCustomSqlFunction(MySqlFunctions.class.getDeclaredMethod("jsonExtract", String.class, String.class), "json_extract");_provider.streamAll(getCurrentSession(), User.class) .select(i -> MySqlFuncions.jsonExtract(i.getAvatar(), "id")) .toList();
自定义Hibernate方言 Jinq底层依赖于Hibernate查询数据库,所以在Jinq完成函数注册后,开发者还得关注自己配置的Hibernate方言是否支持对应函数,如果不支持,则开发者还需要自定Hibernate方言: public class MySQLDialect extends MySQL5Dialect { public MySQLDialect() { super(); registerFunction("json_contains", new StandardSQLFunction("json_contains", StandardBasicTypes.BOOLEAN)); registerFunction("json_extract", new StandardSQLFunction("json_extract", StandardBasicTypes.STRING)); }}
在application.yml
中配置自定义方言: spring: datasource: main: properties: hibernate: dialect: com.chinamobile.sparrow.domain.infra.orm.hibernate.dialect.MySQLInnoDBDialect
脚手架默认开发者使用的是MySQL数据库,已经在com.chinamobile.sparrow.domain.infra.orm
包下提供了完整的函数定义MySqlFunctions
、方言MySQLDialect
、MySQLInnoDBDialect
,并自动装配到三个Repository基类中,对于MySQL常用的字符串处理函数substr
、substring_index
,JSON处理函数json_contains
、json_extract
和日期处理函数hour
、minute
,开发者可以开箱即用。
3.1.2.2 内置的Repository
com.chinamobile.sparrow.domain.repository
包提供了很多内置的Repository,当开发者需要操作用户、部门这些基础数据时,可以使用这些Repository提供的方法,尽量避免在自己的Repository中重复开发相关逻辑。
Repository | 领域 | 说明 |
---|---|---|
UserRepository | 系统管理 | 用户管理,支持对用户的增删查改 |
DepartmentRepository | 系统管理 | 部门管理,支持对部门的增删查改 |
DictionaryRepository | 系统管理 | 字典管理,支持对字典项的增删查改 |
LogRepository | 系统管理 | 日志管理,支持对访问日志的查询 |
SentSmsRepository | 系统管理 | 短信发送任务管理,支持对发送任务的维护 |
RoleRepository | 安全管理 | 角色管理,支持对角色的增删查改 |
PermissionRepository | 安全管理 | 权限管理,支持对权限的维护 |
VerificationCodeRepository | 安全管理 | 短信验证码管理,支持对验证码的维护 |
FSSRepository | 文件管理 | 使用文件系统操作文件,支持对文件的增删查改 |
OSSRepository | 文件管理 | 使用对象存储操作文件,支持对分布式文件的增删查改 |
3.1.3 Service开发
Service的作用和Repository差不多,主要区别在于Service不直接操作数据库,当需要操作数据库时可以注入对应的Repository来完成。
因此Service一般用于封装第三方开放平台的API,或者是组合多个Repository提供更抽象的服务。
3.1.3.1 内置的Service
com.chinamobile.sparrow.domain.service
包提供了很多内置的Service,开发者可以使用它们来发送短信、完成第三方开放平台鉴权等。
Service | 平台 | 说明 |
---|---|---|
cmpassport.Facade | 中国移动一键登录 | 中国移动一键登录服务 |
wx.cp.AccessFacade | 微信开放平台 | 企业号登录服务 |
wx.ma.AccessFacade | 微信开放平台 | 小程序登录服务 |
FileSystemService | 文件系统 | 使用文件系统读写文件,实现了IFileService接口 |
S3Adapter | 对象存储 | 使用对象存储读写文件,实现了IFileService接口 |
MASFacade | 云MAS | 发送短信 |
QXTFacade | 企信通 | 发送短信,已下线 |
QuartzFacade | 作业调度 | Quartz |
3.1.4 Controller开发
Controller主要负责处理来自客户端的HTTP请求,调用Repository或Service的方法执行业务逻辑后后返回HTTP响应。
com.chinamobile.sparrow.springboot.web.controller
包提供了很多内置的Controller,给开发者提供了对用户、部门、角色等基础数据进行增删查改的API,sparrow体系中的sparrow-antd-admin、sparrow-uni-app等客户端脚手架也是对接这套API。
对于Controller中的方法,脚手架在应用启动时还会根据其注解自动扫描生成权限,从而可以在RoleRepository
中完成角色对于API的授权管理,详见应用保护章节。
3.1.4.1 内置的Controller
Controller | 领域 | 说明 |
---|---|---|
ProfileController | 系统管理 | 提供维护个人信息的API |
UserController | 系统管理 | 提供增删查改用户的API |
DepartmentController | 系统管理 | 提供增删查改部门的API |
DictionaryController | 系统管理 | 提供增删查改字典项的API |
JobController | 系统管理 | 提供调度作业的API |
LogController | 系统管理 | 提供查询日志的API |
SentSmsController | 系统管理 | 提供维护短信发送任务的API |
StatisticsController | 系统管理 | 提供数据统计的API |
LoginController | 安全管理 | 提供多种登录和注销登录的API,如用户名密码登录、短信验证码登录 |
OnlineController | 安全管理 | 提供管理在线用户的API |
RoleController | 安全管理 | 提供增删查改角色的API |
PermissionController | 安全管理 | 提供维护权限的API |
ReaderController | 文件管理 | 提供多种读取文件的API,如分片下载 |
RecordController | 文件管理 | 提供增删查改文件的API |
3.2 自动装配
脚手架提供了多个自动装配类对内置类进行自动装配,这些类都位于com.chinamobile.sparrow.springboot.web.autoconfigure
包下:
类 | BEAN | 作用 |
---|---|---|
java.net.Proxy | defaultProxy | 网络的正向代理 |
java.util.concurrent.ThreadPoolExecutor | defaultThreadPool | 与并发处理相关,线程池 |
okhttp3.ConnectionPool | defaultConnectionPool | okhttp的默认连接池 |
com.chinamobile.sparrow.domain.infra.code.DefaultResultParser | resultParser | 与异常处理相关,用于将Exception 转化为Result 类 |
com.chinamobile.sparrow.domain.infra.log.AutoLogAspect | autoLogAspect | 自动日志切面,记录访问请求 |
com.chinamobile.sparrow.domain.infra.sec.shiro.LoginUtil | loginUtil | 与登录相关,常用于获取登录用户 |
com.chinamobile.sparrow.domain.service.cmpassport.Facade | cmPassportFacade | 中国移动一键登录服务 |
com.chinamobile.sparrow.domain.service.FileSystemService | fileSystemService | 文件系统服务 |
com.chinamobile.sparrow.domain.service.S3Adapter | s3Adapter | 对象存储服务 |
com.chinamobile.sparrow.domain.service.MASFacade | masFacade | 云MAS短信服务 |
com.chinamobile.sparrow.domain.service.QXTFacade | qxtFacade | 企信通短信服务,已下线 |
com.chinamobile.sparrow.domain.service.QuartzFacade | quartzFacade | Quartz作业调度服务 |
com.chinamobile.sparrow.domain.util.HttpUtil | httpUtil | 基于okhttp的网络请求工具类,会使用上述的连接池和正向代理 |
com.chinamobile.sparrow.domain.util.I18NUtil | i18NUtil | 与国际化处理相关 |
类 | BEAN | 作用 |
---|---|---|
javax.sql.DataSource | mainDataSource | 应用使用的数据库 |
org.springframework.orm.hibernate5.LocalSessionFactoryBean | mainSessionFactory | 数据库会话工厂,与处理数据库会话相关 |
org.springframework.transaction.PlatformTransactionManager | mainTransactionManager | 数据库事务管理器,与处理数据库事务相关 |
org.jinq.jpaJinqJPAStreamProvider | mainJinqJPAStreamProvider | Jinq组件,详见Repository开发章节。 |
javax.sql.DataSource | quartzDataSource | Quartz使用的数据库 |
类 | BEAN | 作用 |
---|---|---|
org.springframework.data.redis.serializer.RedisSerializer | jackson2JsonRedisSerializer | 序列化器,将对象序列化为json对象 |
org.springframework.data.redis.coreRedisTemplate<String, Object> | mainJsonRedisTemplate | 调用Redis |
类 | BEAN | 作用 |
---|
3.2.1 条件化装配
应该注意到,自动装配的每个bean都使用了@ConditionalOnMissingBean
进行条件化装配,例如:
@Bean@ConditionalOnMissingBeanpublic UserRepository<?> userRepository(@Value(value = "${sec.password-constraint}") String passwordConstraint, @Value(value = "${sec.rsa.default.private-key}") String rsaPrivateKey, @Qualifier(value = "mainSessionFactory") EntityManagerFactory entityManagerFactory, @Qualifier(value = "mainJinqJPAStreamProvider") JinqJPAStreamProvider jinqJPAStreamProvider, AbstractMediaRepository mediaRepository) { return new UserRepository<>(entityManagerFactory, jinqJPAStreamProvider, mediaRepository, passwordConstraint, rsaPrivateKey, User.class);}
这意味着,当内置的Repository无法满足业务需求时,开发者是可以通过继承内置Repository来重写或者增加方法,注册到Spring容器后即可替代脚手架提供的默认实现,而无需改动其他依赖代码。
例如,当开发者使用@Repository
注解了一个UserRepository
的子类,则脚手架将不会初始化内置的UserRepository
而用DefaultUserRepository
替代之:
@Repositorypublic class DefaultUserRepository extends UserRepository<DefaultUser> { ...}
3.2.2 排除自动装配
得益于对@Configuration
的拆分,开发者还可以按需禁用不必要的bean,只需要在application.yml
配置spring.autoconfigure.exclude
的值。
比如你正在开发一个服务端的定时服务,则你很可能不需要使用Shiro
来保护应用,这时就可以排除ShiroConfiguration
的配置:
spring: autoconfigure: exclude: - com.chinamobile.sparrow.springboot.web.autoconfigure.ShiroConfiguration
3.3 标准的返回类型
com.chinamobile.sparrow.domain.infra.code.Result
是脚手架的返回值类型,可以在开发者设置错误码的同时自动从配置文件中检索其错误信息,而不是在代码中进行硬编码。
对于所有的Controller都要求返回该对象,对于Repository和Service在一般情况下也应用返回该对象。
Result
是一个泛型类,有一个范型参数data
,可用于包装任何对象以便向开发者反映方法的调用是否正常,它的结构如下:
{ "code": "OK", "data": null, "message": null}
code
字段用于标识错误码,Result
定义了若干code
常量供开发者使用:
常量 | 类型 | 说明 |
---|---|---|
Result.OK | 系统 | 正常 |
Result.UNKNOWN | 系统 | 系统发生了未知异常 |
Result.SERVICE_UNKNOWN | 服务 | 服务发生了未知异常 |
Result.DATA_VALIDATE_ERROR | 持久化 | 数据校验失败 |
Result.DATA_ACCESS_DENY | 持久化 | 越权访问数据 |
Result.DATABASE_RECORD_NOT_FOUND | 持久化 | 数据记录不存在 |
Result.DATABASE_RECORD_ALREADY_EXIST | 持久化 | 数据记录重复 |
Result.DATABASE_UNKNOWN | 持久化 | 数据库发生了未知异常 |
可以使用isOK
方法检查code
是否为OK
,而无需直接比较两个字面值。
3.3.1 设置错误码
与@ErrorCode
注解搭配使用,开发者可以使用setCode
方法设置错误码。
@ErrorCode
注解在类上,并用一个module
属性标识所在模块,在调用链中,Result
会携带着模块代码让开发者知道错误来源。
setCode
方法可以接受一个字符串(格式为[错误类型]_[错误代码]
),Result
将拼接[应用代码]_[模块代码]_[错误类型]_[错误代码]
为完整的错误代码,并从src/main/resources/i18n/messages.properties
中搜索对应的错误信息设置到message
字段。(具体原理就是SpringBoot对国际化的支持)
Result
定义了以下错误类型:
错误类型 | 说明 |
---|---|
B | 业务逻辑错误 |
D | 数据库错误 |
F | 文件错误 |
H | - |
P | 参数错误 |
N | 网络错误 |
T | - |
开发者也可以使用错误代码类型、数字代码和参数列表明确地提供错误的具体类型和相关信息:
try { login(username, password);} catch (Throwable e) { Result<Void> _success = new Result<>(); _success.setCode(ENUM_ERROR.P, 1, new Object[]{username, password}); return _success;}
3.3.2 从调用链返回
pack
方法接受另一个Result
对象,复制其code
和message
,以便在调用链中返回最底层的错误,例如:
Result<Void> validate(User record) { return new Result<>().setCode("B_001");}Result<String> save(User record) { Result<String> _id = new Result<>(); Result<Void> _validated = validate(record); if (!_validated.isOK()) { return _id.pack(_validated); } ...}
3.4 全局异常处理
脚手架提供了两个异常处理器,用于将应用抛出的异常通过DefaultResultParser
转换为Result
对象,从而确保在任何情况下都能给客户端返回一个一致的结构,不破坏客户端的解析。
com.chinamobile.sparrow.springboot.web.controller.DefaultExceptionHandler
是一个Controller层面的异常处理器(使用@ControllerAdvice
注解),主要拦截一些Controller中常见的异常做解析,避免经过原始Exception
经过多层包装后,全局异常处理器无法获取最根本的异常信息,返回给客户端后误导开发者。
com.chinamobile.sparrow.springboot.web.controller.ErrorController
是一个全局异常处理器(继承自BasicErrorController
),ErrorController
可以拦截任何层抛出的异常(如过滤器)而不像DefaultExceptionHandler
只能作用于Controller。
3.5 应用保护
脚手架使用Shiro进行用户验证和鉴权,由sparrow-domain的com.chinamobile.sparrow.domain.infra.sec.shiro
包实现了Shiro的各种组件,再由sparrow-spring-boot-web-api-starter的ShiroAutoConfiguration
完成装配。
3.5.1 用户验证
com.chinamobile.sparrow.domain.infra.sec.shiro
提供了多种AuthenticationToken和Realm,用来支持多种用户验证方式:
Realm | 支持的AuthenticationToken | 说明 |
---|---|---|
UsernamePasswordAuthorizingRealm | DefaultUsernamePasswordToken | 用户名+密码登录 |
SMSAuthorizingRealm | SMSToken | 短信验证码登录 |
CMPassportAuthorizingRealm | CMPassportToken | 中国移动一键登录 |
WxCpAuthorizingRealm | WxCpToken | 微信企业号授权登录 |
YzyAuthorizingRealm | YzyToken | 粤政易授权登录 |
ConfigUserAuthorizingRealm | ConfigUserToken | DEBUG登录,在配置文件配置用户id或用户名跳过登录,一般仅用于开发环境 |
com.chinamobile.sparrow.domain.infra.sec.shiro
提供了一个DefaultModularRealmAuthenticator
用来配置用户验证策略,一般情况下一种AuthenticationToken仅匹配一种Realm,然而在多因素验证的场景下,开发者可以自行开发一个新的Realm,然后通过ModularRealmAuthenticator
配置多个AuthenticationToken。
为了简化用户权限的获取,com.chinamobile.sparrow.domain.infra.sec.shiro
提供了DefaultModularRealmAuthorizer
避免Shiro在获取用户权限时遍历每个Realm的授权方法,因为脚手架中仅有一套权限机制,而不是Shiro默认设计的每个Realm对应独立的一套权限机制。
com.chinamobile.sparrow.domain.infra.sec.shiro
的DefaultAuthenticationListener
可以监听用户的登录行为,在登录成功或注销时更新用户的登录状态;在登录失败时记录用户的尝试次数等都是通过监听器来实现的。
com.chinamobile.sparrow.domain.infra.sec.shiro
的DefaultSessionListener
可以监听用户的会话,在会话失效时更新用户的登录状态,保证用户在关闭应用前即使未注销服务端也能准确记录用户的登录状态。
和其它安全框架一样,Shiro的用户验证也是基于Filter,com.chinamobile.sparrow.domain.infra.sec.shiro
也提供了DefaultAuthenticationFilter
作为唯一的过滤器。
脚手架当前未提供SSO和OAuth2的实现,如果开发者需要实现这两种功能时,可以参考如下思路:
3.5.1.1 SSO
开发者可以继承DefaultAuthenticationFilter
,并完成以下方法的重写:
public class AuthenticationFilter extends DefaultAuthenticationFilter { public AuthenticationFilter(String username, String loginUrl, LoginUtil loginUtil) { super(username, loginUrl, loginUtil); } // 重写createToken方法,从request中提取SSO的token @Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) { HttpServletRequest _request = (HttpServletRequest) request; String _authorization = _request.getHeader("Authorization"); if (StringUtils.hasLength(_authorization)) { return new SSOToken(_authorization); } else { return super.createToken(request, response); } } // 重写isAccessAllowed方法,使用SSO的token进行登录 @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { Subject _subject = getSubject(request, response); if (_subject.getPrincipal() != null || !isLoginRequest(request, response) && isPermissive(mappedValue)) { return true; } try { AuthenticationToken _token = createToken(request, response); if (_token instanceof ConfigUserToken || _token instanceof SSOToken) { loginUtil.login(_token); return true; } } catch (Throwable e) { logger.error("ConfigUserToken验证失败", e); request.setAttribute(ERROR_ATTRIBUTE, e); } } // 重写重定向登录方法,修改重定向地址 @Override protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException { // 对于非GET的请求,验证失败则抛出异常到ErrorController处理,不能执行重定向 HttpServletRequest _request = (HttpServletRequest) request; if (HttpMethod.valueOf(_request.getMethod()) != HttpMethod.GET) { throwException(_request); } if (ConfigUserAuthorizingRealm.REALM_TYPE.equals(loginUtil.getRealmType(_request)) || StringUtils.hasLength(_request.getHeader("Authorization"))) { // 已登录但验证失败,则抛出异常到ErrorController处理,不重定向到登录页面 throwException(_request); return; } // 在代理下无法读取正确的requestURL /* String _url = _request.getRequestURL().toString(); String _query = _request.getQueryString(); if (StringUtils.hasLength(_query)) { _url = String.format("%s?%s", _url, _query); } _url = URLEncoder.encode(_url, StandardCharsets.UTF_8.name()); if (StringUtils.hasLength(loginUrl)) { String _loginUrl = loginUrl + (StringUtils.hasLength(_url) ? ("?redirect=" + _url) : ""); // 重定向到登录页 setLoginUrl(_loginUrl); super.redirectToLogin(request, response); } else { throwException(_request); } */ if (StringUtils.hasLength(loginUrl)) { setLoginUrl(loginUrl); super.redirectToLogin(request, response); } else { throwException(_request); } }}
3.5.1.2 OAuth2
当开发者需要实现OAuth2协议时,建议开发者继承Shiro的AccessControlFilter
而不是脚手架的DefaultAuthenticationFilter
的,也可以使用oltu
项目进行开发。
3.5.2 鉴权
Shiro的鉴权是基于AOP的,脚手架推荐使用的是@RequiresPermissions
注解,并使用字符串通配符标识权限。字符串通配符的规则时资源标识符:操作:对象实例ID
,即可以对什么资源的什么实例进行什么操作,更多资料可以参考跟我学Shiro。
一般而言,开发者应该只在Controller中使用@RequiresPermissions
对方法进行权限控制,而在Repository/Service中则使用PermissionRepository
的findUserPermissions
,RoleRepository
的findUserRoles
、isUserInRole
等方法判断用户是否被授权。
3.5.2.1 自动扫描权限
脚手架支持在@SpringBootApplication
中注解@ShiroPermissionScan
实现自动扫描权限并持久化到数据库:
@SpringBootApplication@ShiroPermissionScanpublic class Application { ...}
@ShiroPermissionScan
会扫描所有Controller,将其中所有@RequiresPermissions
的注解作为一个权限标识记录到数据库,从而允许开发者通过RoleRepository
完成角色的授权管理,而无需开发者手动维护:
public class PermissionRepository extends AbstractEntityRepository<Permission> { ... public void renew(String operatorId) throws NoSuchFieldException, IllegalAccessException { Map<String, String[]> _pagePermissionMappings = pageRepository.readPagePermissionMappings(); // 从页面生成映射 HashMap<String, String[]> _pathMappings = new HashMap<>(_pagePermissionMappings); // 从Controller合并映射 readControllerPathMappings().forEach((controller, path) -> Arrays.stream(controller.getMethods()) .filter(i -> i.getAnnotation(RequiresPermissions.class) != null) // 遍历要求鉴权的方法 .forEach(i -> { // 首先从RequestMapping读取path String _path = Optional.ofNullable(i.getAnnotation(RequestMapping.class)) .map(k -> k.value().length == 0 ? "" : k.value()[0]) .orElse(null); // 其次从GetMapping读取path if (_path == null) { _path = Optional.ofNullable(i.getAnnotation(GetMapping.class)) .map(k -> k.value().length == 0 ? "" : k.value()[0]) .orElse(null); } // 再次从PostMapping读取path if (_path == null) { _path = Optional.ofNullable(i.getAnnotation(PostMapping.class)) .map(k -> k.value().length == 0 ? "" : k.value()[0]) .orElse(null); } if (_path != null) { _path = path + _path; _pathMappings.put(StringUtils.hasLength(_path) ? _path : "/", i.getAnnotation(RequiresPermissions.class).value()); } })); Map<String, Permission> _itemsMap = new LinkedHashMap<>(); // 读取权限 List<Permission> _records = ((PermissionRepository) proxy).find(null); Map<String, Set<String>> _permissionPageMappings = new HashMap<>(); _pathMappings.forEach((key, value) -> { if (value == null) { return; } for (String i : value) { if (_permissionPageMappings.containsKey(i)) { _permissionPageMappings.get(i).add(key); } else { _permissionPageMappings.put(i, new LinkedHashSet<String>() {{ add(key); }}); } } }); _permissionPageMappings.forEach((key, value) -> { String _parentId = null; // 解析一级权限 String[] _permissions = key.split(":", 3); if (_permissions.length > 0) { String _name = String.format("%s:*", _permissions[0]); Permission _record = _itemsMap.get(_name); if (_record == null) { _record = _records.stream() .filter(i -> Objects.equals(i.getName(), _name)) .findFirst().orElse(new Permission(null, _name, null, null, 1)); _itemsMap.put(_record.getName(), _record); } _parentId = _record.getId(); } // 解析二级权限 if (_permissions.length > 1) { String _name = String.format("%s:%s:*", _permissions[0], _permissions[1]); Permission _record = _itemsMap.get(_name); if (_record == null) { _record = _records.stream() .filter(i -> Objects.equals(i.getName(), _name)) .findFirst().orElse(new Permission(_parentId, _name, null, null, 2)); _itemsMap.put(_record.getName(), _record); } _parentId = _record.getId(); } // 解析三级权限 Permission _record = _itemsMap.get(key); if (_record == null) { _record = _records.stream() .filter(i -> Objects.equals(i.getName(), key)) .findFirst() .orElse(null); if (_record == null) { String _resource = value.stream() .findFirst().orElse(null); Permission.ENUM_TYPE _type = StringUtils.hasLength(_resource) ? (_pagePermissionMappings.containsKey(_resource) ? Permission.ENUM_TYPE.PAGE : Permission.ENUM_TYPE.API) : null; _record = new Permission(_parentId, key, _type, _resource, 3); } _itemsMap.put(_record.getName(), _record); } }); // 分类 List<Permission> _itemsToAdd = new ArrayList<>(), _itemsToRemain = new ArrayList<>(); for (Permission i : _itemsMap.values()) { String _name = i.getName(); Permission _record = _records.stream() .filter(j -> Objects.equals(j.getName(), _name)) .findFirst().orElse(null); if (_record == null) { _itemsToAdd.add(i); } else { _itemsToRemain.add(_record); } } // 删除无效权限 _records.removeAll(_itemsToRemain); for (Permission i : _records) { remove(i.getId()); } // 新增权限 for (Permission i : _itemsToAdd) { add(i, operatorId); } } ...
3.5.3 URL访问策略
熟悉Shiro的开发者会使用ShiroFilterFactoryBean
的setFilterChainDefinitionMap
方法来设置URL适用的Filter,从而控制每个路由的访问策略。脚手架允许开发者直接在application.yml
中定义:
shiro: filter-chain-definition: - /sec/login/cancel, user - /sec/login/**, anon - /error, anon ... - /**, user
在每条URL后面标识适用的过滤器标识,并以,
分割。Shiro提供anon
用来支持匿名访问,而user
在脚手架中则对应DefaultAuthenticationFilter
,要求用户必须登录后才能继续访问。
3.5.4 禁用
如果开发者的应用不需要使用保护应用(如定时服务),则可以通过application.yml
禁用,有两种方式可供选择:
在application.yml
排除相关配置类的自动装配名,主要由Shiro的配置类和脚手架的配置类:
spring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration - org.apache.shiro.spring.config.web.autoconfigure.ShiroWebAutoConfiguration - com.chinamobile.sparrow.springboot.web.autoconfigure.ShiroAutoConfiguration
禁用配置 开发者也可以通过配置禁用Shiro:
spring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration - com.chinamobile.sparrow.springboot.web.autoconfigure.ShiroAutoConfiguration...shiro: enabled: false annotations: enabled: false web: enabled: false
3.6 作业调度
脚手架使用Quartz进行作业调度,所以开发者可以使用Quartz的 @DisallowConcurrentExecution
和@PersistJobDataAfterExecution
定义一个job:
@DisallowConcurrentExecution@PersistJobDataAfterExecutionpublic class UnlockAccountJob extends QuartzJobBean { ...}
@DisallowConcurrentExecution
可以在分布式环境中防止同一个任务被重复执行,当一个任务还在执行中,而调度器尝试再次触发该任务时,则新的触发会被阻塞,直到当前任务执行完成。
@PersistJobDataAfterExecution
则确保在任务执行完成后,相关数据会持久化到数据库中。
3.6.1 自动扫描作业
脚手架提供了两个注解@JobDetailAndTrigger
和@QuartzJobScan
来自动注册job到调度器中。
@JobDetailAndTrigger
允许开发者定义job和trigger的信息 ,并配置job是否在应用启动时自动触发一次:
属性 | 类型 | 说明 |
---|---|---|
jobName | String | job名称 |
jobGroup | String | job的分组 |
triggerName | String | 触发器名称 |
triggerGroup | String | 触发器的分组 |
triggerCron | String | CRON表达式,触发时机 |
triggerOnStart | boolean | 是否在应用启动时自动触发一次 |
而开发者只要在@SpringBootApplication
中注解@QuartzJobScan
,应用将在启动时将自动扫描所有作业并注册到调度器中,之后开发者可以使用QuartzFacade
对job进行触发、暂停、恢复等操作:
@SpringBootApplication@QuartzJobScanpublic class Application { public static void main(String[] args) { SpringApplication _application = new SpringApplication(ApiApplication.class); _application.addListeners(new ApplicationPidFileWriter()); _application.run(args); }}
3.7 访问日志
脚手架使用Logback进行日志输出,从配置文件logback-spring.xml
可以看出,脚手架采用了三种日志输出方式:
持久化到数据库
对于com.chinamobile.sparrow.domain.infra.log.AutoLogAspect
包下的日志将保存到数据库。com.chinamobile.sparrow.domain.infra.log.AutoLogAspect
是一个切面,该切面作用于@Controller
、@RestController
和@Log
注解的对象,当程序进入这些方法时,Logback会提取用户信息和参数、返回值并记录到数据库的sys_info_logs
和sys_error_logs
表中形成访问日志。
保存到文件
对于com.chinamobile
包下的日志,则根据日志级别输出到对应的文件,日志文件仅保存7天。
输出到控制台
对于其它日志输出,则输出到标准输出流,即控制台。
3.7.1 禁用
如果开发者的应用不需要记录访问日志,则可以通过application.yml
禁用:
aspect: log: enabled: false
四、附录
4.1 配置文件application.yml
application.yml
是应用的配置文件,脚手架自定义的配置项如下:
属性 | 类型 | 示例 | 说明 |
---|---|---|---|
spring.datasource.main.config | String | classpath:shardingsphere.yml | ShardingSphere配置文件的文件路径,支持从配置文件创建DataSource |
spring.datasource.main.properties | HibernateProperties | - | Hibernate的配置属性,由Hibernate的LocalSessionFactoryBean.setHibernateProperties 使用 |
spring.datasource.main.packages | String | com.chinamobile.sparrow.domain.model | Hibernate会查找这些包及其子包下的所有类,从中识别出实体类(@Entity ),Hibernate将对负责它们的持久化。当需要配置多个package时使用, 分隔 |
shiro.session-id-cookie | SimpleCookie | - | Shiro用于存储sessionId的cookie的属性 |
shiro.remember-me-cookie | SimpleCookie | - | Shiro用于存储rememberMe的cookie的属性 |
shiro.session.timeout | int | 1800 | Shiro会话的有效期,即在未活跃的情况下会话保持活跃的时间长度 |
shiro.session.validation-interval | int | 3600 | Shiro检查会话有效性的周期性间隔 |
shiro.redis-session-dao.enabled | boolean | false | 是否使用redis存储session |
shiro.redis-session-dao.key-template | String | sparrow:shiro:session:%s | 存储key的前缀 |
aspect.log.enabled | boolean | true | 启用日志切面com.chinamobile.sparrow.domain.infra.log.AutoLogAspect ,该切面将自动记录web访问请求 |
aspect.log.max-length | int | 128000 | 请求和响应数据的长度限制 |
file.extension.allowed | String | - | 支持上传的文件扩展名,多个扩展名使用, 分隔 |
file.extension.forbidden | String | - | 禁止上传的文件扩展名,多个扩展名使用, 分隔 |
file.part.size | int | 1048576 | 分片下载文件时,每个分片的大小(字节) |
s3.enabled | boolean | false | 是否使用对象存储,否则将使用文件系统存储 |
s3.endpoint | String | https://eos-shanghai-1.cmecloud.cn | 对象存储服务的远程地址 |
s3.access-key | String | - | 对象存储的accessKey |
s3.secret-key | String | - | 对象存储的secretKey |
s3.bucket.env | String | 用来生成对象存储的桶路径 | |
s3.bucket.default | String | 用来生成对象存储的桶路径 | |
file.dir.env | String | 用来生成文件系统的存储路径。当在同一服务器上部署多个环境(如开发环境、测试环境)时,可用来区分环境 | |
file.dir.default | String | default | 用来生成文件系统的存储路径 |
okhttp.connection-pool.default.keep-alive | int | 5 | okhttp的默认连接池允许的最大空闲时间(分钟)。当连接池中的连接空闲时间超过这个限制时,连接将被关闭,以便释放资源 |
okhttp.connection-pool.default.max-idle | int | 5 | okhttp的默认连接池可以保持的最大空闲连接数量。当连接池中的空闲连接数量超过这个限制时,超出的部分连接将被关闭 |
sec.username | String | 用户登录账号,当配置了该属性时,所有请求将使用该账号进行登录访问,一般仅用于开发调试 | |
sec.login-url | String | Shiro重定向到登录页的地址,一般不需要配置。在使用nginx代理访问的情况下可能需要使用该参数 | |
sec.rsa.default | Object | - | RSA 算法的默认密钥对,用户密码默认使用该密钥对进行加解密 |
sec.sms.code-length | int | 6 | 短信验证码的长度 |
sec.sms.expires-in | int | 1 | 短信验证码的过期时间(分钟) |
sec.sms.template | String | - | 短信验证码的模板 |
thread-pool.default.core | int | 8 | 默认线程池的核心线程数 |
thread-pool.default.max | int | 8 | 默认线程池的最大线程数 |
web.cors | CorsConfiguration | - | 跨域配置 |
amap.api-key | String | - | 高德开放平台web服务api的key |
amap.js.key | String | - | 高德开放平台js api的key |
amap.js.code | String | - | 高德开放平台js api的安全密钥 |
qqmap.js-key | String | - | 腾讯位置服务js api的key |
cmpassport.enabled | boolean | false | 是否使用中国移动一键登录服务 |
cmpassport.base-url | String | https://token.cmpassport.com:8300 | 一键登录服务的远程地址 |
cmpassport.app-id | String | - | 一键登录服务的appId |
cmpassport.app-key | String | - | 一键登录服务的appKey |
cmpassport.rsa | Object | - | 一键登录服务RSA 算法的密钥对 |
mas.enabled | boolean | false | 是否使用云MAS短信服务 |
mas.sms.base-url | String | https://112.35.10.201:28888 | 云MAS服务的远程地址 |
mas.sms.ec-name | String | - | - |
mas.sms.ap-id | String | - | - |
mas.sms.secret-key | String | - | - |
mas.sms.sign | String | - | - |
wx.cp.enabled | boolean | false | 是否使用企业微信服务 |
wx.cp.corp-id | String | - | - |
wx.cp.corp-secret | String | - | - |
wx.cp.redirect | String | - | 企业微信OAuth2的重定向地址,详见构造网页授权链接 |
wx.ma.enabled | boolean | false | 是否使用微信小程序服务 |
wx.ma.app-id | String | - | - |
wx.ma.secret | String | - | - |
wx.ma.msg-data-format | String | JSON | - |
wx.ma.version | String | develop | - |
wx.yzy.enabled | boolean | false | 是否使用粤政易服务 |
wx.yzy.base-url | String | https://zwwx.gdzwfw.gov.cn | 粤政易服务的远程地址 |
wx.yzy.corp-id | String | - | - |
wx.yzy.corp-secret | String | - | - |
wx.yzy.redirect | String | - | 粤政易OAuth2的重定向地址 |