{T}

技术栈分析

SpringBoot 技术栈分析

在第一章里面,我们成功的把本课程四个项目都运行起来了。这节课我们看看 Java 项目都用到了哪些技术栈,它们的大致运行原理是什么。因为两个Java项目的技术栈是相同的,所以我就拿其中的一个来做详细讲解。另外更多的细节,大家可以看课程附录部分,有从零搭建Java项目的步骤。

application.yml 配置文件

application.yml 文件中配置了 Jetty 服务器、数据库连接池、Redis、SaToken 等内容

yaml
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语言异常,哪些是业务异常

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 提供了集中处理异常的功能,这里我们要加以利用

java
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 使用的序列化机制

java
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 格式。那么客户端得到的响应内容格式非常的统一

java
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, "未知异常,请联系管理员");
    }
 
}

封装分页结果

在本项目中有很多模块都用到了数据分页显示,为了把分页用到的数据封装存储起来,我们需要创建一个封装类

java
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 方法。 img

即便有人破解了 SaToken 生成令牌的算法,也不用担心,因为开启了 Redis 保存 Token 副本的功能。如果 SaToken 检测到请求头的Token 在 Redis 中没有缓存副本,那么这个 Token 肯定是伪造的,所以就拒绝请求。另外 Redis 中的 Token 有过期时间,客户端提交的 Token 是过期的,SaToken 也能判断出来,然后拒绝请求

判断是否登陆

判断用户是否已经在客户端登陆,在 Web 方法上添加 @SaCheckLogin 注解即可。SaToken 只要检测到 Token 是有效的,就会允许 HTTP 请求调用该 Web 方法

无论哪位用户登陆患者端小程序,拥有的权限都是相同的。不存在不同身份的人去登陆患者端小程序,能登陆患者端小程序的人,都是相同身份的人。所以后端 Java 项目不需要判断是否具备不同的权限,这些患者具有相同的权限,因此只需要判断他们是否登陆即可

判断是否具有权限

在 MIS 端登陆的用户可能是超级管理员,也可能是普通医护人员。所以要在 hospital-api 项目中验证用户是否登陆,而且还要验证他们是否具备相关的权限才能调用某个 Web 方法。这需要用到 @SaCheckPermission 注解了

java
@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 方法,否则就拒绝请求抛出异常。

java
@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 界面上看到各个数据表的结构和数据了

image-20240403220243380

注意要更换驱动程序

image-20240403220343028

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

img

CRUD权限模型

在 MIS_MODULE 数据表中,保存了系统拥有的业务模块记录。

主键值(id)模块编号(module_code)模块名称(module_name)
1MIS-USERMIS端用户管理
2PATIENT-USER患者端用户管理
3WORKER-USER医护端用户管理
4DEPT部门管理
5MEDICAL_DEPT医疗科室管理
6SCHEDULE出诊管理
7REGISTRATION挂号管理
8VIDEO-DIAGNOSIS视频问诊管理

在 MIS_ACTION 数据表中,保存的是操作动作记录

主键值(id)动作编号(action_code)动作名称(action_name)
1INSERT添加
2DELETE删除
3UPDATE修改
4SELECT查询
5APPROVAL审批
6IMPORT导入
7EXPORT导出
8BACKUP备份
9ARCHIVE归档

在MIS_PERMISSION数据表中,保存的是权限记录。

主键值(id)权限编号(permission_code)模块ID(module_id)动作ID(action_id)
0ROOT00
1MIS-USER:INSERT11
2MIS-USER:DELETE12
3MIS-USER:UPDATE13
4MIS-USER:SELECT14
5PATIENT-USER:INSERT21
6PATIENT-USER:DELETE22
7PATIENT-USER:UPDATE23
8PATIENT-USER:SELECT24
9WORKER-USER:INSERT31
10WORKER-USER:DELETE32
11WORKER-USER:UPDATE33
12WORKER-USER:SELECT34
13DEPT:INSERT41
14DEPT:DELETE42
15DEPT:UPDATE43
16DEPT:SELECT44

在 MIS_ROLE 数据表中,保存的角色记录

主键值(id)角色名称(role_name)备注(remark)
0超级管理员超级管理员

我们看看角色和权限是怎么关联在一起的,MIS_ROLE_PERMISSION 数据表是角色和权限的交叉表

主键值(id)角色ID(role_id)权限ID(permission_id)
000

最后需要把用户和角色关联在一起,需要用到 MIS_USER_ROLE 交叉表。

主键值(id)用户ID(user_id)角色ID(permission_id)
000

查询用户拥有的权限

我们在 IDEA 工具上面,编写如下的 SQL 语句,就可以查询到某个用户具备的权限了。

java
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;