当前位置:首页 » 《我的小黑屋》 » 正文

sparrow-web-api开发底座指南

20 人参与  2024年11月02日 09:20  分类 : 《我的小黑屋》  评论

点击全文阅读


一、概述

sparrow-domainsparrow-spring-boot-web-api-startersparrow体系中用于开发web服务端的脚手架。
sparrow-domainsparrow-spring-boot-web-api-starterSpring BootApache ShiroHibernateShardingSphere等开源框架的基础上,提供了完整的部门管理、用户管理、角色管理、权限管理、作业管理、日志管理等功能。
使用这个开箱即用的开发底座,开发者只需要关注于核心业务的开发,而无需关注如用户登录、角色权限控制等非核心业务逻辑的实现。

1.1 技术选型

框架版本作用
Spring Boot2.7.xweb应用
Shiro1.10.x保护应用,负责身份验证、授权、加密和会话管理
Hibernate5.6.xORM,负责数据持久化
Jinq1.8.x支持LINQ风格的查询
ShardingSphere5.2.x分布式SQL事务和查询引擎,可通过数据分片、弹性伸缩、加密等能力对任意数据库进行增强
Quartz2.3.x作业调度
Logback1.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节点下的groupIdartifactIdversionname
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进行作业调度,也支持对作业进行持久化。
所以脚手架需要配置两个数据库(当然也可以配置为同一个数据库),并配置LogbackShardingSphere的功能。

2.4.1 修改application.yml

application.yml是应用的配置文件,既包含了Spring Boot的标准配置项,也有脚手架的自定义配置项。
Spring Boot的标准配置项可以参考Spring Boot Reference Documentation,脚手架的自定义配置项详见附录。
一般只需要修改数据库的相关配置,即spring.dataSource中的配置。

2.4.2 修改shardingsphere.yml

shardingphere.ymlShardingSphere的配置项,脚手架需要使用ShardingSphere对日志进行分表。
Logback的标准配置项可以参考YAML配置。
一般只需要修改数据库的相关配置,即dataSources.main中的配置。

2.4.3 修改logback-spring.xml

logback-spring.xmlLogback的配置项,可以参考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的主要职责是封装数据持久化逻辑,并为应用的其他部分提供数据交互的方式,而不必关心具体的实现细节。

脚手架提供了三个基类AbstractJinqRepositoryAbstractEntityRepositoryAbstractMediaAvailableRepository,以方便开发者快速开发Repository。

AbstractEntityRepositoryAbstractMediaAvailableRepository都是泛型类,接受一个AbstractEntity子类型的参数。
如果你的PO继承自AbstractEntity,则你的Repository可以继承AbstractEntityRepository类,从而使用getCurrentSessionstreamaddupdate等方法对数据库进行增删查改等操作。
如果在数据持久化的同时还要负责保存文件时,则可以继承AbstractEntityRepositoryAbstractMediaRepository类。AbstractEntityRepositoryAbstractMediaRepository重写了AbstractEntityRepositoryaddupdate方法,并新增了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、方言MySQLDialectMySQLInnoDBDialect,并自动装配到三个Repository基类中,对于MySQL常用的字符串处理函数substrsubstring_index,JSON处理函数json_containsjson_extract和日期处理函数hourminute,开发者可以开箱即用。

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-adminsparrow-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包下:

BeanAutoConfiguration
BEAN作用
java.net.ProxydefaultProxy网络的正向代理
java.util.concurrent.ThreadPoolExecutordefaultThreadPool与并发处理相关,线程池
okhttp3.ConnectionPooldefaultConnectionPoolokhttp的默认连接池
com.chinamobile.sparrow.domain.infra.code.DefaultResultParserresultParser与异常处理相关,用于将Exception转化为Result
com.chinamobile.sparrow.domain.infra.log.AutoLogAspectautoLogAspect自动日志切面,记录访问请求
com.chinamobile.sparrow.domain.infra.sec.shiro.LoginUtilloginUtil与登录相关,常用于获取登录用户
com.chinamobile.sparrow.domain.service.cmpassport.FacadecmPassportFacade中国移动一键登录服务
com.chinamobile.sparrow.domain.service.FileSystemServicefileSystemService文件系统服务
com.chinamobile.sparrow.domain.service.S3Adapters3Adapter对象存储服务
com.chinamobile.sparrow.domain.service.MASFacademasFacade云MAS短信服务
com.chinamobile.sparrow.domain.service.QXTFacadeqxtFacade企信通短信服务,已下线
com.chinamobile.sparrow.domain.service.QuartzFacadequartzFacadeQuartz作业调度服务
com.chinamobile.sparrow.domain.util.HttpUtilhttpUtil基于okhttp的网络请求工具类,会使用上述的连接池和正向代理
com.chinamobile.sparrow.domain.util.I18NUtili18NUtil与国际化处理相关
DataSourceAutoConfiguration
BEAN作用
javax.sql.DataSourcemainDataSource应用使用的数据库
org.springframework.orm.hibernate5.LocalSessionFactoryBeanmainSessionFactory数据库会话工厂,与处理数据库会话相关
org.springframework.transaction.PlatformTransactionManagermainTransactionManager数据库事务管理器,与处理数据库事务相关
org.jinq.jpaJinqJPAStreamProvidermainJinqJPAStreamProviderJinq组件,详见Repository开发章节。
javax.sql.DataSourcequartzDataSourceQuartz使用的数据库
RedisAutoConfiguration
BEAN作用
org.springframework.data.redis.serializer.RedisSerializerjackson2JsonRedisSerializer序列化器,将对象序列化为json对象
org.springframework.data.redis.coreRedisTemplate<String, Object>mainJsonRedisTemplate调用Redis
ShiroAutoConfiguration
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对象,复制其codemessage,以便在调用链中返回最底层的错误,例如:

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-domaincom.chinamobile.sparrow.domain.infra.sec.shiro包实现了Shiro的各种组件,再由sparrow-spring-boot-web-api-starterShiroAutoConfiguration完成装配。

3.5.1 用户验证

com.chinamobile.sparrow.domain.infra.sec.shiro提供了多种AuthenticationToken和Realm,用来支持多种用户验证方式:

Realm支持的AuthenticationToken说明
UsernamePasswordAuthorizingRealmDefaultUsernamePasswordToken用户名+密码登录
SMSAuthorizingRealmSMSToken短信验证码登录
CMPassportAuthorizingRealmCMPassportToken中国移动一键登录
WxCpAuthorizingRealmWxCpToken微信企业号授权登录
YzyAuthorizingRealmYzyToken粤政易授权登录
ConfigUserAuthorizingRealmConfigUserTokenDEBUG登录,在配置文件配置用户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.shiroDefaultAuthenticationListener可以监听用户的登录行为,在登录成功或注销时更新用户的登录状态;在登录失败时记录用户的尝试次数等都是通过监听器来实现的。

com.chinamobile.sparrow.domain.infra.sec.shiroDefaultSessionListener可以监听用户的会话,在会话失效时更新用户的登录状态,保证用户在关闭应用前即使未注销服务端也能准确记录用户的登录状态。

和其它安全框架一样,Shiro的用户验证也是基于Filter,com.chinamobile.sparrow.domain.infra.sec.shiro也提供了DefaultAuthenticationFilter作为唯一的过滤器。

脚手架当前未提供SSOOAuth2的实现,如果开发者需要实现这两种功能时,可以参考如下思路:

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协议时,建议开发者继承ShiroAccessControlFilter而不是脚手架的DefaultAuthenticationFilter的,也可以使用oltu项目进行开发。

3.5.2 鉴权

Shiro的鉴权是基于AOP的,脚手架推荐使用的是@RequiresPermissions注解,并使用字符串通配符标识权限。字符串通配符的规则时资源标识符:操作:对象实例ID,即可以对什么资源的什么实例进行什么操作,更多资料可以参考跟我学Shiro。
一般而言,开发者应该只在Controller中使用@RequiresPermissions对方法进行权限控制,而在Repository/Service中则使用PermissionRepositoryfindUserPermissionsRoleRepositoryfindUserRolesisUserInRole等方法判断用户是否被授权。

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的开发者会使用ShiroFilterFactoryBeansetFilterChainDefinitionMap方法来设置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是否在应用启动时自动触发一次:

属性类型说明
jobNameStringjob名称
jobGroupStringjob的分组
triggerNameString触发器名称
triggerGroupString触发器的分组
triggerCronStringCRON表达式,触发时机
triggerOnStartboolean是否在应用启动时自动触发一次

而开发者只要在@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_logssys_error_logs表中形成访问日志。

保存到文件
对于com.chinamobile包下的日志,则根据日志级别输出到对应的文件,日志文件仅保存7天。

输出到控制台
对于其它日志输出,则输出到标准输出流,即控制台。

3.7.1 禁用

如果开发者的应用不需要记录访问日志,则可以通过application.yml禁用:

aspect:  log:    enabled: false

四、附录

4.1 配置文件application.yml

application.yml是应用的配置文件,脚手架自定义的配置项如下:

属性类型示例说明
spring.datasource.main.configStringclasspath:shardingsphere.ymlShardingSphere配置文件的文件路径,支持从配置文件创建DataSource
spring.datasource.main.propertiesHibernateProperties-Hibernate的配置属性,由HibernateLocalSessionFactoryBean.setHibernateProperties使用
spring.datasource.main.packagesStringcom.chinamobile.sparrow.domain.modelHibernate会查找这些包及其子包下的所有类,从中识别出实体类(@Entity),Hibernate将对负责它们的持久化。当需要配置多个package时使用,分隔
shiro.session-id-cookieSimpleCookie-Shiro用于存储sessionId的cookie的属性
shiro.remember-me-cookieSimpleCookie-Shiro用于存储rememberMe的cookie的属性
shiro.session.timeoutint1800Shiro会话的有效期,即在未活跃的情况下会话保持活跃的时间长度
shiro.session.validation-intervalint3600Shiro检查会话有效性的周期性间隔
shiro.redis-session-dao.enabledbooleanfalse是否使用redis存储session
shiro.redis-session-dao.key-templateStringsparrow:shiro:session:%s存储key的前缀
aspect.log.enabledbooleantrue启用日志切面com.chinamobile.sparrow.domain.infra.log.AutoLogAspect,该切面将自动记录web访问请求
aspect.log.max-lengthint128000请求和响应数据的长度限制
file.extension.allowedString-支持上传的文件扩展名,多个扩展名使用,分隔
file.extension.forbiddenString-禁止上传的文件扩展名,多个扩展名使用,分隔
file.part.sizeint1048576分片下载文件时,每个分片的大小(字节)
s3.enabledbooleanfalse是否使用对象存储,否则将使用文件系统存储
s3.endpointStringhttps://eos-shanghai-1.cmecloud.cn对象存储服务的远程地址
s3.access-keyString-对象存储的accessKey
s3.secret-keyString-对象存储的secretKey
s3.bucket.envString用来生成对象存储的桶路径
s3.bucket.defaultString用来生成对象存储的桶路径
file.dir.envString用来生成文件系统的存储路径。当在同一服务器上部署多个环境(如开发环境、测试环境)时,可用来区分环境
file.dir.defaultStringdefault用来生成文件系统的存储路径
okhttp.connection-pool.default.keep-aliveint5okhttp的默认连接池允许的最大空闲时间(分钟)。当连接池中的连接空闲时间超过这个限制时,连接将被关闭,以便释放资源
okhttp.connection-pool.default.max-idleint5okhttp的默认连接池可以保持的最大空闲连接数量。当连接池中的空闲连接数量超过这个限制时,超出的部分连接将被关闭
sec.usernameString用户登录账号,当配置了该属性时,所有请求将使用该账号进行登录访问,一般仅用于开发调试
sec.login-urlStringShiro重定向到登录页的地址,一般不需要配置。在使用nginx代理访问的情况下可能需要使用该参数
sec.rsa.defaultObject-RSA算法的默认密钥对,用户密码默认使用该密钥对进行加解密
sec.sms.code-lengthint6短信验证码的长度
sec.sms.expires-inint1短信验证码的过期时间(分钟)
sec.sms.templateString-短信验证码的模板
thread-pool.default.coreint8默认线程池的核心线程数
thread-pool.default.maxint8默认线程池的最大线程数
web.corsCorsConfiguration-跨域配置
amap.api-keyString-高德开放平台web服务api的key
amap.js.keyString-高德开放平台js api的key
amap.js.codeString-高德开放平台js api的安全密钥
qqmap.js-keyString-腾讯位置服务js api的key
cmpassport.enabledbooleanfalse是否使用中国移动一键登录服务
cmpassport.base-urlStringhttps://token.cmpassport.com:8300一键登录服务的远程地址
cmpassport.app-idString-一键登录服务的appId
cmpassport.app-keyString-一键登录服务的appKey
cmpassport.rsaObject-一键登录服务RSA算法的密钥对
mas.enabledbooleanfalse是否使用云MAS短信服务
mas.sms.base-urlStringhttps://112.35.10.201:28888云MAS服务的远程地址
mas.sms.ec-nameString--
mas.sms.ap-idString--
mas.sms.secret-keyString--
mas.sms.signString--
wx.cp.enabledbooleanfalse是否使用企业微信服务
wx.cp.corp-idString--
wx.cp.corp-secretString--
wx.cp.redirectString-企业微信OAuth2的重定向地址,详见构造网页授权链接
wx.ma.enabledbooleanfalse是否使用微信小程序服务
wx.ma.app-idString--
wx.ma.secretString--
wx.ma.msg-data-formatStringJSON-
wx.ma.versionStringdevelop-
wx.yzy.enabledbooleanfalse是否使用粤政易服务
wx.yzy.base-urlStringhttps://zwwx.gdzwfw.gov.cn粤政易服务的远程地址
wx.yzy.corp-idString--
wx.yzy.corp-secretString--
wx.yzy.redirectString-粤政易OAuth2的重定向地址

点击全文阅读


本文链接:http://zhangshiyu.com/post/181286.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1