WeChat Template
一套基于SpringBoot & Shiro 以及 Uni-app构建的微信小程序脚手架
开源地址: https://github.com/TyCoding/wechat-template 欢迎Star、Fork支持作者。
后端项目封装
今天我们讲解如何优雅的封装后端项目。
目录结构
└── src
├── main
│ ├── java
│ │ └── cn
│ │ └── tycoding
│ │ ├── Application.java
│ │ ├── biz
│ │ │ ├── controller -- 控制层
│ │ │ ├── entity -- 实体类
│ │ │ ├── mapper -- Mybatis映射接口
│ │ │ └── service -- 业务层
│ │ └── common -- 公共层
│ │ ├── auth -- Shiro鉴权相关
│ │ ├── config -- 项目配置相关
│ │ ├── constants -- 项目公共常量
│ │ ├── controller -- 控制层公共方法提取
│ │ ├── exception -- 异常类
│ │ ├── handler -- 处理器类
│ │ ├── properties -- 框架配置参数
│ │ └── utils -- 工具类
│ └── resources
│ ├── application-dev.yml -- 开发环境配置
│ ├── application-prod.yml -- 生产环境配置
│ ├── application.yml -- 项目基础配置
│ ├── mapper -- Mybatis接口映射XML文件
│ └── tycoding.properties -- 项目自定义参数配置
Tips
以上是一个比较标准的项目目录结构,也可能一些人对此设计有所疑惑,下面我解释一下:
-
cn.tycoding
代表作者的域名,也是强调项目开发者是谁 -
biz
Business,项目的控制层,包含了项目的主体业务代码 -
common
通用层,包含了对项目基础环境的配置 -
application-.yml
是按照SpringBoot默认的配置文件读取方式命名,dev
代表开发环境,prod
代表生产环境
Spring集成Mybatis
- 在
pom.xml
中添加mybatis
依赖
<!-- Mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
- 编写测试接口
/**
* @author tycoding
* @date 2020/6/9
*/
public interface UserMapper extends BaseMapper<SysUser> {
}
- 编写测试接口映射
<mapper namespace="cn.tycoding.biz.mapper.UserMapper">
</mapper>
Tips
以上就是简单在SpringBoot中集成Mybatis框架。你需要注意几点:
- 添加的依赖是:
mybatis-sspring-boot-starter
,好处就是SpringBoot已经自动对该框架做了基础配置 - 按照Mybatis的规定,仅需要提供一个
interface
继承BaseMapper
即可,BaseMapper
中提供了很多操控SQL的方法;其次如果需要使用XML自定义SQL,默认需要提供一个XML文件,他应当和上述接口的名称相同,目录名称也相同,并且XML中namespace
必须指定这个interface
的相对路径。按照SpringBoot的规定,XML文件必须写在resources
目录下。
集成Mybatis-Plus
Mybatis框架本身提供了很多操控SQL的方法,但是对于分页、条件查询等可能并不能满足我们的需求,所以我们需要再集成Mybatis-plus
。
- 在
pom.xml
中添加mybatis-plus
依赖:
<!-- Mybatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
- 编写Mybatis-plus配置类:
/**
* @author tycoding
* @date 2020/6/9
*/
@Configuration
public class MybatisPlusConfig {
/**
* Mybatis-Plus 分页插件
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
return new PaginationInterceptor();
}
}
- 在
application.yml
中添加自定义配置,比如扫描哪里的xml
文件等
mybatis-plus:
type-aliases-package: cn.tycoding.biz.entity
mapper-locations: classpath:mapper/*.xml
configuration:
jdbc-type-for-null: null
global-config:
banner: false
- 在Application.java中添加
@MapperScan
指定Mybatis接口类在哪里,避免Mybatis-plus找不到接口类的情况
/**
* @author tycoding
* @date 2020/6/9
*/
@SpringBootApplication
@MapperScan("cn.tycoding.biz.mapper")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
- 在Entity实体类中,根据Mybatis-plus的规定,如果Entity的名称与数据库中字段名称不同需要手动配置(遵循驼峰命名):
@Data
@Accessors(chain = true)
@TableName(value = "tb_user")
public class SysUser implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String avatar;
}
Spring集成Shiro
- 在
pom.xml
中添加shiro依赖:
<!-- Shiro -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>${shiro-spring-version}</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>${shiro-ehcache.version}</version>
<exclusions>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 由于前面的包里面不包含这个,所以要单独引入, 如果不引入, shiro的权限注解不起作用 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
<version>${spring-aspects.version}</version>
</dependency>
为了保证Shiro注解能正常使用,最好同时引入aspects
和aop
的依赖。
- Shiro Config
/**
* @author tycoding
* @date 2020/6/9
*/
@Configuration
public class ShiroConfig {
@Autowired
private AppProperties properties;
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroProperties shiro = properties.getShiro();
ShiroFilterFactoryBean filter = new ShiroFilterFactoryBean();
filter.setSecurityManager(securityManager);
filter.setLoginUrl(shiro.getLoginUrl());
filter.setSuccessUrl(shiro.getSuccessUrl());
Map<String, String> filterChain = new LinkedHashMap<>();
String[] urls = shiro.getAnonUrl().split(",");
for (String url : urls) {
filterChain.put(url, "anon");
}
filterChain.put("/**", "user");
// filterChain.put("/**", "anon");
filter.setFilterChainDefinitionMap(filterChain);
return filter;
}
@Bean
public SecurityManager securityManager(AuthRealm authRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(authRealm);
securityManager.setSessionManager(sessionManager());
securityManager.setCacheManager(cacheManager());
securityManager.setRememberMeManager(rememberMeManager());
return securityManager;
}
@Bean
public SimpleCookie rememberMeCookie() {
ShiroProperties shiro = properties.getShiro();
// 需要和前端<input name="remember">中的name对应
SimpleCookie simpleCookie = new SimpleCookie("remember");
simpleCookie.setMaxAge(shiro.getCookieTimeout());
return simpleCookie;
}
@Bean
public CookieRememberMeManager rememberMeManager() {
ShiroProperties shiro = properties.getShiro();
CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
cookieRememberMeManager.setCookie(rememberMeCookie());
cookieRememberMeManager.setCipherKey(Base64.decode(shiro.getCipherKey()));
return cookieRememberMeManager;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
@Bean
public CacheManager cacheManager() {
return new EhCacheManager();
}
@Bean
public SessionDAO sessionDAO() {
return new EnterpriseCacheSessionDAO();
}
@Bean
public AuthSessionManager sessionManager() {
// 自定义SessionManager,校验请求头中的Token信息
AuthSessionManager sessionManager = new AuthSessionManager();
Collection<SessionListener> listeners = new ArrayList<>();
listeners.add(new ShiroSessionListener());
// 设置 session超时时间
sessionManager.setGlobalSessionTimeout(properties.getShiro().getSessionTimeout() * 1000L);
sessionManager.setSessionListeners(listeners);
sessionManager.setSessionDAO(sessionDAO());
return sessionManager;
}
}
- 自定义Session Manager,用于在前后端分离项目中判断请求头是否包含Token
/**
* @author tycoding
* @date 2020/6/9
*/
public class AuthSessionManager extends DefaultWebSessionManager {
public AuthSessionManager() {
super();
}
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
// 获取请求头Header中的Token,登录时定义了Token = SessionID
String token = WebUtils.toHttp(request).getHeader(CommonConstant.AUTHORIZATION);
if (!StringUtils.isEmpty(token)) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, token);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
return token;
} else {
// 否则按默认从Cookie中获取JSESSIONID
return super.getSessionId(request, response);
}
}
}
- 自定义Realm
/**
* @author tycoding
* @date 2020/6/9
*/
@Component
public class AuthRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
/**
* 身份校验
*
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String username = (String) authenticationToken.getPrincipal();
if (username == null) {
throw new AuthenticationException(CommonEnum.TOKEN_ERROR.getMsg());
}
String password = new String((char[]) authenticationToken.getCredentials());
SysUser sysUser = userService.findByName(username);
if (sysUser == null || !sysUser.getPassword().equals(password)) {
throw new IncorrectCredentialsException(CommonEnum.LOGIN_ERROR.getMsg());
}
return new SimpleAuthenticationInfo(
sysUser,
password,
getName()
);
}
/**
* 权限校验
*
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
}
- Shiro 密码加密配置
@Component
public class MD5Util {
// 算法名称
private static final String ALGORITH_NAME = "MD5";
// 迭代次数
private static final int HASH_ITERATIONS = 2;
//加密算法
public static String encryptPassword(String username, String password) {
if (username == null || password == null) {
return null;
}
return new SimpleHash(
ALGORITH_NAME,
StringUtils.lowerCase(StringUtils.lowerCase(password)),
ByteSource.Util.bytes(StringUtils.lowerCase(username)),
HASH_ITERATIONS).toHex();
}
}
一般我们选择MD5的加密方式,而MD5只是一种哈希算法,每个字符串都有唯一的哈希值,也就是若单纯的对密码做MD5加密,他的加密值也和密码一样是恒定的,若保证安全,可以再加salt保证即使知道了加密方式和加密后的密码也得不到原密码。这个salt可以是随机值(那么密码就不会是恒定的了,每次做MD5加密密码都有改变),也可以是固定的,比如这里我们将username
作为salt。
如何使用这个工具类(或者说如何加密密码)?这里以添加User的功能为例:
@Override
@Transactional
public void update(SysUser sysUser) {
if (sysUser.getPassword() != null && sysUser.getUsername() != null) {
// 加密
String encrypt_password = MD5Util.encryptPassword(sysUser.getUsername(), sysUser.getPassword());
sysUser.setPassword(encrypt_password);
} else {
sysUser.setPassword(null);
}
userMapper.updateById(sysUser);
}
可以看到,前端传来的是明文密码,而后端需要对明文面貌做加密处理得到encrypt password,然后将这个加密的密码保存到数据库中。当用户再登录时,Shiro会自动根据配置的加密算法和salt逆向得到加密的密码并与数据库中的密码比对。
Login
/**
* 登录接口
*
* @param username
* @param password
* @return
*/
@GetMapping("/login")
public R login(@RequestParam(value = "username", required = false) String username,
@RequestParam(value = "password", required = false) String password) {
if (username == null || password == null) {
throw new GlobalException(CommonEnum.LOGIN_ERROR.getMsg());
}
Subject subject = getSubject();
String encrypt_password = MD5Util.encryptPassword(username, password);
UsernamePasswordToken token = new UsernamePasswordToken(username, encrypt_password);
try {
subject.login(token);
Map<String, Object> map = new HashMap<>();
map.put("token", subject.getSession().getId());
map.put("user", this.getCurrentUser());
return new R<>(map);
} catch (Exception e) {
e.printStackTrace();
return new R<>(e);
}
}
前端传来用户名和密码,后端将明文密码加密,再将用户名和加密后的密码传入UsernamePasswordToken
对象中,调用subject.login(token)
登录,Shiro将自动把加密后的密码与数据库中的密码相比对,如果相同则登录成功。登录成功后返回一个Map,包含了token
值(这里以Session ID作为Token值)。
Logout
/**
* 注销接口
*
* @return
*/
@DeleteMapping(value = "/logout")
public R logout() {
Subject subject = getSubject();
subject.logout();
return new R();
}
注销接口仅需要调用subject.logout()
即可清空Shiro中存储的Session等数据。
Others
全局数据返回格式封装
首先你要明白,前后端分离的实质是后端仅作为Restful返回数据,而这些数据通常是序列化后的JSON格式吗,因此不要脱离JSON格式去传输数据
一些人很不理解为什么要对数据返回格式进行封装,我直接返回数据不行吗?
栗子:
Request:
get http://localhost:8080/user/info
Response:
{
"username": "123",
"password": "123"
}
有毛病吗?没毛病,你确实可以这样写。但是在前后端分离项目中,你返回这数据想让前端开发者怎么处理?首先这数据代表什么?其次返回的状态是什么?最后如果接口出现异常你返回什么?
针对以上,接口返回的数据中应当包含了:
-
code
:响应状态码 -
data
:存储响应数据 -
msg
:异常返回信息
因此,我们可以猜到,接口响应格式应该是这样的:
{
"code": 200,
"data": {
"username": "123",
"password": "123"
},
"msg": "success"
}
对此,需要封装一个包含响应格式的类:
/**
* @author tycoding
* @date 2020/6/9
*/
@Builder
@ToString
@AllArgsConstructor
public class R<T> implements Serializable {
@Getter
@Setter
private int code = CommonConstant.SUCCESS;
@Getter
@Setter
private Object msg = "success";
@Getter
@Setter
private T data;
public R() {
super();
}
public R(T data) {
super();
this.data = data;
}
public R(int code, String msg) {
this.code = code;
this.msg = msg;
}
public R(T data, String msg) {
super();
this.data = data;
this.msg = msg;
}
public R(CommonEnum enums) {
super();
this.code = enums.getCode();
this.msg = enums.getMsg();
}
public R(Throwable e) {
super();
this.code = CommonConstant.ERROR;
this.msg = e.getMessage();
}
public R(String message, Throwable e) {
super();
this.msg = message;
this.code = CommonConstant.ERROR;
}
}
此类已经近乎包含了所有返回格式的情况。如何使用?
@GetMapping("/{id}")
public R findById(@PathVariable Long id) {
return new R<>(userService.getById(id));
}
直接return new R<>()
即可。
全局异常封装
整个项目运行过程中会产生很多异常,而Spring等框架默认是返回一个error page
,这中方式及其不友好(特别是在单体项目中),因此我们需要对全局异常进行拦截和封装。
首先写一个自定义异常类:
public class GlobalException extends RuntimeException {
@Getter
@Setter
private String msg;
public GlobalException(String message) {
super(message);
this.msg = message;
}
}
再写异常类对应的Handle:
/**
* 全局异常处理器
*
* @author tycoding
* @date 2020/6/9
*/
@Slf4j
@RestControllerAdvice
@Order(value = Ordered.HIGHEST_PRECEDENCE)
public class GlobalExceptionHandler {
@ExceptionHandler(value = Exception.class)
public R exception(Exception e) {
log.error("内部错误, {}", e.getMessage());
e.printStackTrace();
return new R(e);
}
@ExceptionHandler(value = GlobalException.class)
public R globalExceptionHandle(GlobalException e) {
log.error("全局异常, {}", e.getMessage());
e.printStackTrace();
return new R<>(CommonConstant.ERROR, e.getMsg());
}
@ExceptionHandler(value = UnauthorizedException.class)
public R handleUnauthorizedException(UnauthorizedException e) {
log.error("UnauthorizedException, {}", e.getMessage());
return new R(HttpStatus.FORBIDDEN, e.getMessage());
}
@ExceptionHandler(value = AuthenticationException.class)
public R handleAuthenticationException(AuthenticationException e) {
log.error("AuthenticationException, {}", e.getMessage());
return new R(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
}
@ExceptionHandler(value = AuthorizationException.class)
public R handleAuthorizationException(AuthorizationException e) {
log.error("AuthorizationException, {}", e.getMessage());
return new R<>(HttpStatus.UNAUTHORIZED, e.getMessage());
}
}
-
@RestControllerAdvice
简单来说他可以应用在所有包含@RequestMapping
注解的类中,可以做一些自定义处理 -
@Order(value = Ordered.HIGHEST_PRECEDENCE)
表示此过滤器在所有过滤器中优先级最高 -
@ExceptionHandler
捕捉@RequestMapping
类中产生的异常,自定义返回值信息
综上也就是说,此Handle仅仅对于项目中Controller
类有效,对于未包含@RequestMapping
的类,此Handle是无法拦截其抛出的异常的。但是我们仍可以抛出GlobalException
,然后在controller
中进行try catch
处理。
例如在UserService
的新增方法中,用户名可能已存在而产生异常,需要手动throw GlobalException
,而此GlobalException
在GlobalExceptionHandler
是无法进行处理的(也就是说即使定义了@ExceptionHandler(value = Exception.class)
也无法拦截到此异常),那么就需要手动在Controller层try catch
处理该异常,实现自定义异常返回信息:
UserServiceImpl
@Override
@Transactional
public void add(SysUser sysUser) {
SysUser user = this.findByName(sysUser.getUsername());
if (user != null) {
throw new GlobalException("该用户名已存在");
}
// 加密
String encrypt_password = MD5Util.encryptPassword(sysUser.getUsername(), sysUser.getPassword());
sysUser.setPassword(encrypt_password);
userMapper.insert(sysUser);
}
UserController
@PostMapping
public R save(@RequestBody SysUser sysUser) {
try {
userService.add(sysUser);
return new R();
} catch (Exception e) {
throw new GlobalException(e.getMessage());
}
}
BaseController封装
之前大家一直在写Controller,项目中也用到了Mybatis-plus和Shiro框架,那么必不可少项目中会经常使用两个功能:
- 数据分页。(前端使用UI分页组件,后端需要按照分页参数查询指定页码数据)
- Shiro中
session
、subject
对象的经常调用
因此,可以预先封装一个BaseController,其中包含常用的方法,其他的Controller继承这个BseController就可以直接调用方法了,提高了代码的复用性:
/**
* Controller公共层方法提取
*
* @author tycoding
* @date 2020/6/9
*/
public class BaseController {
protected static Subject getSubject() {
return SecurityUtils.getSubject();
}
protected SysUser getCurrentUser() {
return (SysUser) getSubject().getPrincipal();
}
protected Session getSession() {
return getSubject().getSession();
}
protected Session getSession(Boolean flag) {
return getSubject().getSession(flag);
}
protected void login(AuthenticationToken token) {
getSubject().login(token);
}
public Map<String, Object> getData(IPage<?> page) {
Map<String, Object> data = new HashMap<>();
data.put("rows", page.getRecords());
data.put("total", page.getTotal());
return data;
}
public Map<String, Object> getToken() {
Map<String, Object> map = new HashMap<>();
map.put("token", getSession().getId());
return map;
}
}
例如在UserController
中需要些分页接口:
@PostMapping("/list")
public R findByPage(@RequestBody SysUser sysUser, QueryPage queryPage) {
return new R<>(super.getData(userService.list(sysUser, queryPage)));
}
直接调用super.getData
就可以自定将UserService
返回的数据做分页的封装处理(主要是Map参数格式)
全局分页数据格式封装
在上面已经提到了项目中肯定经常用到数据分页,那么必须定义一个包含分页数据格式的类:
/**
* @author tycoding
* @date 2020/6/9
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class QueryPage implements Serializable {
/**
* 当前页
*/
private int page;
/**
* 每页的记录数
*/
private int limit;
}
项目公共常量
很多时候,后端需要定义一个指定名称的属性,而这个属性又不能写死,得方便修改,那么我们一般定义一个公共的常量类存储项目中用到常量:
/**
* 项目公共常量
*
* @author tycoding
* @date 2020/6/9
*/
public interface CommonConstant {
/**
* 前端Token Key
*/
String AUTHORIZATION = "Authorization";
}
而定义interface
的原因就是在其他类中可以方便的调用CommonConstant.AUTHORIZATION
快速获取某个属性值。
全局定义配置文件类
例如Shiro、Swagger等框架在使用时需要自定义配置,而这些配置是不能直接在application.yml
中定义的,于是你可以写在resources/
目录下,一般以.properties
结尾命名,Spring会自动扫描到这个配置文件。
但Spring自动扫描了,并不会自动给你配置到某个类中,需要手动将其注入到Spring Environment,然后再获取到并定义到某个类中:
/**
* @author tycoding
* @date 2020/6/9
*/
@Data
@SpringBootConfiguration
@PropertySource(value = {"classpath:tycoding.properties"})
@ConfigurationProperties(prefix = "tycoding")
public class AppProperties {
private ShiroProperties shiro = new ShiroProperties();
}
-
@SpringBootConfiguration
指定这是SpringBoot项目的配置文件类 -
@PropertySource
将配置文件加载到Spring Environment中,value
指定了配置文件的配置,classpath
也就是resources
目录的相对路径 -
@ConfigurationProperties
扫描包含指定prefix
前缀的配置
这种方式最大的好处就是可以根据此类中定义的对应名称自动在配置文件中查找指定的配置并注入到配置类中。
例如上面写的AppProperties
类中声明了ShiroProperties shiro
对象,那么Spring自动扫描tycoding.properties
文件中以tycoding.shiro
开头的配置参数,这些配置参数将按照驼峰命名规则自动装配到ShiroProperties
对象属性中。
首先看下tycoding.properties
的配置:
tycoding.shiro.session_timeout=3600
tycoding.shiro.cookie_timeout=86400
tycoding.shiro.anon_url=/login,/logout
tycoding.shiro.login_url=/login
tycoding.shiro.success_url=/index
tycoding.shiro.logout_url=/logout
tycoding.shiro.cipher_key=tycoding
再看下ShiroProperties
类的属性:
@Data
public class ShiroProperties {
private long sessionTimeout;
private int cookieTimeout;
private String anonUrl;
private String loginUrl;
private String successUrl;
private String logoutUrl;
private String cipherKey;
}
那么如果要使用tycoding.properties
的配置参数,直接在类中初始化ShiroProperties shiro
对象,然后调用shiro.xx
即可获取到参数配置。
交流
QQGroup:671017003
WeChatGroup: 关注公众号查看