技术栈分析
SpringBoot 技术栈分析
在第一章里面,我们成功的把本课程四个项目都运行起来了。这节课我们看看 Java 项目都用到了哪些技术栈,它们的大致运行原理是什么。因为两个Java项目的技术栈是相同的,所以我就拿其中的一个来做详细讲解。另外更多的细节,大家可以看课程附录部分,有从零搭建Java项目的步骤。
application.yml 配置文件
在 application.yml 文件中配置了 Jetty 服务器、数据库连接池、Redis、SaToken 等内容
server:
# Jetty 默认是非阻塞的,可以处理更多并发请求
jetty:
threads:
acceptors: 4 # acceptors 线程池接受 HTTP 请求
selectors: 8 # selectors 线程池负责处理 HTTP 消息协议数据包
# worker 线程池负责调用 Web 方法
min: 8
max: 200
port: 8092
servlet:
context-path: /hospital-api
spring:
datasource:
driver-class-name: org.apache.phoenix.queryserver.client.Driver
url: jdbc:phoenix:thin:url=http://192.168.0.120:8765;serialization=PROTOBUF
type: com.alibaba.druid.pool.DruidDataSource
druid:
test-on-borrow: true
test-while-idle: true
max-active: 8
min-idle: 4
validation-query: SELECT 1
servlet:
multipart:
enabled: true
max-file-size: 20MB # 上传文件不能超过 20M
max-request-size: 20MB # HTTP 请求不能超过 20M
redis:
database: 0
host: localhost
port: 6379
password: abc123456
jedis:
pool:
max-active: 1000
max-wait: -1ms
max-idle: 16
min-idle: 8
quartz:
job-store-type: memory
mybatis:
mapper-locations: classpath*:mapper/*.xml
type-aliases-package: com.example.hospital.api.db.pojo
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
logging:
level:
root: info
com.example.hospital.api.db.dao: warn
pattern:
console: "%d{yyyy/MM/dd HH:mm:ss} %-5level %msg%n"
sa-token:
# HTTP请求头中token的名称
token-name: token
# token有效期,单位s 默认30天, -1代表永不过期
timeout: 2592000
# token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒
activity-timeout: -1
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
allow-concurrent-login: true
# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
is-share: false
# token风格
token-style: uuid
#读取Cookie中Token
isReadCookie: false
wechat:
app-id: 你的小程序APPID
app-secret: 你的小程序密钥
pay:
v3:
patient-wx-api:
app-id: 课程提供的小程序APPID
app-v3-secret: 课程提供的小程序密钥
mch-id: 课程提供的商户号
domain: 内网穿透URL地址
cert-path: apiclient_cert.p12
tencent:
cloud:
appId: 你的腾讯云APPID
secretId: 你的腾讯云secretId
secretKey: 你的腾讯云secretKey
face:
groupName: hospital
region: ap-beijing
minio:
endpoint: http://云主机IP:9000
access-key: root
secret-key: abc123456自定义异常类
在开发项目的过程中,很多人都喜欢自定义异常,处理异常的时候可以根据异常的类型,判断出哪些是Java语言异常,哪些是业务异常
package com.example.hospital.api.exception;
import lombok.Data;
@Data
public class HospitalException extends RuntimeException {
private String msg;
private int code = 500;
public HospitalException(Exception e) {
super(e);
this.msg = "执行异常";
this.code = 500;
}
public HospitalException(String msg) {
super(msg);
this.msg = msg;
}
public HospitalException(String msg, Throwable e) {
super(msg, e);
this.msg = msg;
}
public HospitalException(String msg, int code) {
super(msg);
this.msg = msg;
this.code = code;
}
public HospitalException(String msg, int code, Throwable e) {
super(msg, e);
this.msg = msg;
this.code = code;
}
}全局处理异常
本项目中无论遇到什么样子的异常,都应该集中处理,简化返回给客户端的异常信息。SpringBoot 提供了集中处理异常的功能,这里我们要加以利用
package com.example.hospital.api.config;
import cn.dev33.satoken.exception.NotLoginException;
import cn.felord.payment.PayException;
import cn.hutool.json.JSONObject;
import com.example.hospital.api.exception.HospitalException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
@Slf4j
@RestControllerAdvice
public class ExceptionAdvice {
@ResponseBody
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public String exceptionHandler(Exception e) {
JSONObject json = new JSONObject();
if (e instanceof HttpMessageNotReadableException) {
HttpMessageNotReadableException exception = (HttpMessageNotReadableException) e;
log.error("error",exception);
json.set("error", "请求未提交数据或者数据有误");
}
else if(e instanceof MissingServletRequestPartException){
MissingServletRequestPartException exception= (MissingServletRequestPartException) e;
log.error("error",exception);
json.set("error", "请求提交数据错误");
}
//处理后端验证失败产生的异常
else if (e instanceof MethodArgumentNotValidException) {
MethodArgumentNotValidException exception = (MethodArgumentNotValidException) e;
json.set("error", exception.getBindingResult().getFieldError().getDefaultMessage());
}
//处理业务异常
else if (e instanceof HospitalException) {
log.error("执行异常", e);
HospitalException exception = (HospitalException) e;
json.set("error", exception.getMsg());
}
else if (e instanceof PayException) {
PayException exception = (PayException) e;
log.error("微信支付异常", e);
}
//处理其余的异常
else {
log.error("执行异常", e);
json.set("error", "执行异常");
}
return json.toString();
}
@ResponseBody
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(NotLoginException.class)
public String unLoginHandler(Exception e) {
JSONObject json = new JSONObject();
json.set("error", e.getMessage());
return json.toString();
}
}避免 RedisTemplate 保存乱码数据
SpringBoot Data 中默认的 RedisTemplate 因为序列化机制的问题,往 Redis 里面保存 Hash 类型数据通常是乱码的,为了解决这个问题需要定义配置类,修改 RedisTemplate 使用的序列化机制
package com.example.hospital.api.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setConnectionFactory(factory);
return template;
}
}封装 Web 方法返回结果格式
在前后端分离的架构中,后端返回给移动端或者前端的数据最好能有固定的格式,比如JSON中哪个属性是状态码,哪个属性是消息内容,哪个属性是业务数据等等。如果我们能定义一个Java类,里面设置好相应的变量。将来Web方法返回的数据封装在这个封装类,然后Spring 框架把数据转换成 JSON 格式。那么客户端得到的响应内容格式非常的统一
package com.example.hospital.api.common;
import org.apache.http.HttpStatus;
import java.util.HashMap;
import java.util.Map;
public class R extends HashMap<String, Object> {
public R() {
put("code", HttpStatus.SC_OK);
put("msg", "success");
}
public R put(String key, Object value) {
super.put(key, value);
return this;
}
public static R ok() {
return new R();
}
public static R ok(String msg) {
R r = new R();
r.put("msg", msg);
return r;
}
public static R ok(Map<String, Object> map) {
R r = new R();
r.putAll(map);
return r;
}
public static R error(int code, String msg) {
R r = new R();
r.put("code", code);
r.put("msg", msg);
return r;
}
public static R error(String msg) {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
}
public static R error() {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "未知异常,请联系管理员");
}
}封装分页结果
在本项目中有很多模块都用到了数据分页显示,为了把分页用到的数据封装存储起来,我们需要创建一个封装类
package com.example.hospital.api.common;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
@Data
public class PageUtils implements Serializable {
private static final long serialVersionUID = 1L;
private long totalCount;
private int pageSize;
private int totalPage;
private int pageIndex;
private List list;
public PageUtils(List list, long totalCount, int pageIndex, int pageSize) {
this.list = list;
this.totalCount = totalCount;
this.pageSize = pageSize;
this.pageIndex = pageIndex;
this.totalPage = (int) Math.ceil((double) totalCount / pageSize);
}
}SaToken 认证与授权简介
SaToken 框架是国内新崛起的开源免费认证与授权框架,2021 年荣获码云的 GVP 头衔 官方手册文档](https://sa-token.cc/doc.html#/),里面有入门说明和 API 接口介绍
在本课程项目中没有使用 Shiro 和 SpringSecurity 的原因非常简单:Shiro 和 SpringSecurity 都没有内置 JWT 功能,需要额外配置 JWT,过程非常繁琐
Token 认证的原理
传统的单体 JavaWeb 项目通常采用 HttpSession 保存登陆成功的凭证,但是 HttpSession 需要浏览器的 Cookie 机制配合。也就是说 Web 项目的客户端只能是浏览器,不可以是 APP、机顶盒、智能家电等。
为了让 Java 项目兼容更多的客户端设备,要放弃在客户端用 Cookie 保存 SessionId 的做法。在服务端生成 Token 字符串,然后交给客户端保存。APP 也好、浏览器也好、机顶盒也好,都能保存字符串
每次发送 HTTP 请求的时候,在请求头上附带 Token 字符串,SaToken 框架就能解析出该 Token 是否合法,如果没有问题,就允许客户端请求后端的 Web 方法。

即便有人破解了 SaToken 生成令牌的算法,也不用担心,因为开启了 Redis 保存 Token 副本的功能。如果 SaToken 检测到请求头的Token 在 Redis 中没有缓存副本,那么这个 Token 肯定是伪造的,所以就拒绝请求。另外 Redis 中的 Token 有过期时间,客户端提交的 Token 是过期的,SaToken 也能判断出来,然后拒绝请求
判断是否登陆
判断用户是否已经在客户端登陆,在 Web 方法上添加 @SaCheckLogin 注解即可。SaToken 只要检测到 Token 是有效的,就会允许 HTTP 请求调用该 Web 方法
无论哪位用户登陆患者端小程序,拥有的权限都是相同的。不存在不同身份的人去登陆患者端小程序,能登陆患者端小程序的人,都是相同身份的人。所以后端 Java 项目不需要判断是否具备不同的权限,这些患者具有相同的权限,因此只需要判断他们是否登陆即可
判断是否具有权限
在 MIS 端登陆的用户可能是超级管理员,也可能是普通医护人员。所以要在 hospital-api 项目中验证用户是否登陆,而且还要验证他们是否具备相关的权限才能调用某个 Web 方法。这需要用到 @SaCheckPermission 注解了
@PostMapping("/searchByPage")
@SaCheckLogin
@SaCheckPermission(value = {"ROOT", "DOCTOR:SELECT"}, mode = SaMode.OR)
public R searchByPage(@RequestBody @Valid SearchDoctorByPageForm form) {
Map param = BeanUtil.beanToMap(form);
int page = form.getPage();
int length = form.getLength();
int start = (page - 1) * length;
param.put("start", start);
PageUtils pageUtils = doctorService.searchByPage(param);
return R.ok().put("result", pageUtils);
}每当 SaToken 框架执行到 @SaCheckPermission 注解的时候,就会调用 StpInterfaceImpl 类。从里面的 getPermissionList() 函数中获取用户具备的权限,然后跟注解要求的权限比对。如果用户拥有相关权限就可以调用 Web 方法,否则就拒绝请求抛出异常。
@Component
public class StpInterfaceImpl implements StpInterface {
@Resource
private MisUserDao userDao;
/**
* 返回一个用户所拥有的权限集合
*/
@Override
public List<String> getPermissionList(Object loginId, String loginKey) {
int userId = Integer.parseInt(loginId.toString());
ArrayList<String> list = userDao.searchUserPermissions(userId);
return list;
}
/**
* 返回一个用户所拥有的角色标识集合
*/
@Override
public List<String> getRoleList(Object loginId, String loginKey) {
return null;
}
}基于 Phoenix 的 RBAC 权限模型
在以前的实战课中,利用 MySQL 数据库实现了 RBAC 权限模型。因为 MySQL 支持 JSON 类型的字段,所以定义多对多关系的时候,可以使用 JSON 数组字段。虽然 Phoenix 也支持 JSON 类型字段,但是相关的函数较少,而且 JSON 类型字段不支持表连接。所以在Phoenix中,只能用交叉表记录多对多关系了
IDEA 连接 Phoenix
由于 Phoenix 自带的命令行客户端没有图形界面直观,所以要借助 IDEA 自带的 DataGrip 功能连接 Phoenix,这样就可以在 GUI 界面上看到各个数据表的结构和数据了

注意要更换驱动程序

需要特别说明一句,DataGrip 对 Phoenix 支持的不够完美,带有事务的 SQL 语句都不能执行;而且默认无法查看数据表的记录,只能通过编写 SQL 语句的方式来查看数据。执行 SELECT 语句的时候,还会提示 SQLException,这个异常不用理会,不影响 SQL 语句执行

CRUD权限模型
在 MIS_MODULE 数据表中,保存了系统拥有的业务模块记录。
| 主键值(id) | 模块编号(module_code) | 模块名称(module_name) |
|---|---|---|
| 1 | MIS-USER | MIS端用户管理 |
| 2 | PATIENT-USER | 患者端用户管理 |
| 3 | WORKER-USER | 医护端用户管理 |
| 4 | DEPT | 部门管理 |
| 5 | MEDICAL_DEPT | 医疗科室管理 |
| 6 | SCHEDULE | 出诊管理 |
| 7 | REGISTRATION | 挂号管理 |
| 8 | VIDEO-DIAGNOSIS | 视频问诊管理 |
在 MIS_ACTION 数据表中,保存的是操作动作记录
| 主键值(id) | 动作编号(action_code) | 动作名称(action_name) |
|---|---|---|
| 1 | INSERT | 添加 |
| 2 | DELETE | 删除 |
| 3 | UPDATE | 修改 |
| 4 | SELECT | 查询 |
| 5 | APPROVAL | 审批 |
| 6 | IMPORT | 导入 |
| 7 | EXPORT | 导出 |
| 8 | BACKUP | 备份 |
| 9 | ARCHIVE | 归档 |
在MIS_PERMISSION数据表中,保存的是权限记录。
| 主键值(id) | 权限编号(permission_code) | 模块ID(module_id) | 动作ID(action_id) |
|---|---|---|---|
| 0 | ROOT | 0 | 0 |
| 1 | MIS-USER:INSERT | 1 | 1 |
| 2 | MIS-USER:DELETE | 1 | 2 |
| 3 | MIS-USER:UPDATE | 1 | 3 |
| 4 | MIS-USER:SELECT | 1 | 4 |
| 5 | PATIENT-USER:INSERT | 2 | 1 |
| 6 | PATIENT-USER:DELETE | 2 | 2 |
| 7 | PATIENT-USER:UPDATE | 2 | 3 |
| 8 | PATIENT-USER:SELECT | 2 | 4 |
| 9 | WORKER-USER:INSERT | 3 | 1 |
| 10 | WORKER-USER:DELETE | 3 | 2 |
| 11 | WORKER-USER:UPDATE | 3 | 3 |
| 12 | WORKER-USER:SELECT | 3 | 4 |
| 13 | DEPT:INSERT | 4 | 1 |
| 14 | DEPT:DELETE | 4 | 2 |
| 15 | DEPT:UPDATE | 4 | 3 |
| 16 | DEPT:SELECT | 4 | 4 |
在 MIS_ROLE 数据表中,保存的角色记录
| 主键值(id) | 角色名称(role_name) | 备注(remark) |
|---|---|---|
| 0 | 超级管理员 | 超级管理员 |
我们看看角色和权限是怎么关联在一起的,MIS_ROLE_PERMISSION 数据表是角色和权限的交叉表
| 主键值(id) | 角色ID(role_id) | 权限ID(permission_id) |
|---|---|---|
| 0 | 0 | 0 |
最后需要把用户和角色关联在一起,需要用到 MIS_USER_ROLE 交叉表。
| 主键值(id) | 用户ID(user_id) | 角色ID(permission_id) |
|---|---|---|
| 0 | 0 | 0 |
查询用户拥有的权限
我们在 IDEA 工具上面,编写如下的 SQL 语句,就可以查询到某个用户具备的权限了。
SELECT p."permission_code" AS "permission"
FROM HOSPITAL.MIS_USER u
JOIN HOSPITAL.MIS_USER_ROLE ur ON u."id" = ur."user_id"
JOIN HOSPITAL.MIS_ROLE_PERMISSION rp ON rp."role_id" = ur."role_id"
JOIN HOSPITAL.MIS_PERMISSION p ON rp."permission_id" = p."id"
WHERE u."id" = 0;