Spring Boot实现高质量的CRUD-1

1、前言

​	在Spring Boot的SMM框架(SpringBoot+Mysql+Mybatis)的WEB项目中,CRUD(增删改查)大致占了50%-70%左右的工作量。提高CRUD的代码质量,提高CRUD的开发效率,是一件值得探讨的事项。
​	一般认为,CRUD是一件体力活。在SMM框架项目开发中,项目团队通常将这类CRUD的开发任务交由知道如何写Mybatis脚本的初中级Java工程师来做,一般往往仅仅是业务层面的交代。实践表明,如果不在一开始制定CRUD的开发规范,得到的结果往往差强人意。由于缺乏整体规划,不同工程师的代码风格迥异,方法命名,参数命名,五花八门,检查项也不完整,代码臃肿,重复性高,后期常常需要大量修修补补,维护成本高得惊人。	​	
​	而基于CRUD开发规范,则可大大提高了CRUD代码的易维护性、接口的可用性和健壮性、用户友好性,以及功能扩展灵活性。多个项目实践显示,使用CRUD开发规范至少可以提高20%的开发效率,考虑包括后期维护成本,可以有效降低开发成本超过30%。

2、CRUD的常规功能

​	CRUD的常规功能如下:
​	1)增加单个对象;    
​	2)批量增加对象;
​	3)修改单个对象;
​	4)批量修改对象;
​	5)删除单个对象;
​	6)批量删除对象;
​	7)前端分页查询;    
​	8)获取指定对象;    
​	9)根据条件查询对象;    
​	10)导入Excel数据;
​	11)导出Excel数据。

​	其中,导入Excel数据是批量增加或修改对象的一种方式,导出Excel数据是数据查询的一种形式。Excel作为最方便的办公文件格式,在数据交换中使用十分广泛。
​	根据代码分层开发思想,CRUD将涉及8个文件:
​	1)实体类Entity,基本与表结构字段进行映射对应;简单起见,可忽略其它POJO对象,在实体类中添加其它需要的属性。
​	2)数据访问对象类Dao,这个在SMM框架中,为支持Mybatis或Mybatis-Plus的接口类。
​	3)Dao实现类Mybatis,为xml脚本文件,使用Mybatis脚本语言实现Dao的相关接口。
​	4)服务接口类Service,定义Controller层接口所需的各种CRUD接口形式,以及内部处理所需的其它接口形式。
​	5)服务接口实现类ServiceImpl,实现Service的相关接口。
​	6)服务单元测试类ServiceTest,使用JUnit,实现Service的相关接口的单元测试。
​	7)业务方法接口类Controller,也可称为API接口类,提供HTTP接口服务。    
​	8)API接口文档,格式比较灵活,当然使用YAPI导出的格式最好,但录入很费时间。当然不管使用哪种格式,API接口相关的要素说明都要有。    

3、数据库设计规范

​	由于CRUD与数据库表结构高度相关​,因此先简单说明一下表结构的设计规范。

3.1、表结构设计例子

​	先看2个表结构的DDL(Data Definition Language,数据库定义语言)脚本例子。
-- ----------------------------
-- Table structure for exa_users
-- 用户表
-- ----------------------------
DROP TABLE IF EXISTS exa_users;
CREATE TABLE exa_users
(
  user_id			BIGINT(20)        NOT NULL DEFAULT 0	COMMENT '用户ID',

  -- 登录信息
  user_name			VARCHAR(80)		UNIQUE NOT NULL DEFAULT '' COMMENT '用户名',
  -- 加密算法:md5(concat(md5(密码明文),salt));
  -- 前端发送一次md5的值到服务器,服务器添加salt值,计算二次md5(32)大写值,与passwd值比较
  password			VARCHAR(64)		NOT NULL DEFAULT '' COMMENT '用户密码',
  -- salt,可用记录生成的时间
  salt				VARCHAR(64)		NOT NULL DEFAULT '' COMMENT '加盐md5算法中的盐',
  user_type			TINYINT(4)		NOT NULL DEFAULT 3  COMMENT '用户类型,1-系统管理员、2-公司内部用户、3-外部用户,由系统参数表user_type类别定义',
  org_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '组织ID',

  -- 用户资料
  real_name			VARCHAR(64)		NOT NULL DEFAULT '' COMMENT '真实姓名',
  email				VARCHAR(100)    	NOT NULL DEFAULT '' COMMENT 'Email',
  phone_number  		VARCHAR(20)		NOT NULL DEFAULT '' COMMENT '手机号码',
  sex				TINYINT(4)		NOT NULL DEFAULT 1  COMMENT '性别,1-无值、2-男、3-女、4-其它,由系统参数表sex类别定义',
  birth				DATETIME		DEFAULT NULL COMMENT '生日',
  id_no				VARCHAR(30)		NOT NULL DEFAULT '' COMMENT '身份证号码',

  open_id			VARCHAR(40)		NOT NULL DEFAULT "" COMMENT '微信小程序的openid',
  woa_openid    		VARCHAR(40)		NOT NULL DEFAULT "" COMMENT '微信公众号openid',
  remark			VARCHAR(200)	NOT NULL DEFAULT '' COMMENT '备注',

  -- 记录操作信息
  operator_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '操作人账号',
  delete_flag		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '记录删除标记,0-正常、1-禁用,由系统参数表delete_flag类别定义',
  create_time		DATETIME		NOT NULL DEFAULT NOW() COMMENT '创建时间',
  update_time		DATETIME		DEFAULT NULL ON UPDATE NOW() COMMENT '更新时间',
  PRIMARY KEY (user_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8 COMMENT '用户表';

-- ----------------------------
-- Table structure for exa_user_roles
-- 用户和角色关系表
-- 用户和角色是多对多关系
-- ----------------------------
DROP TABLE IF EXISTS exa_user_roles;
CREATE TABLE exa_user_roles
(
  user_id			BIGINT(20)		NOT NULL DEFAULT 0  COMMENT '用户ID',
  role_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '角色ID',

  -- 记录操作信息
  operator_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '操作人账号',
  delete_flag		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '记录删除标记,保留字段',
  create_time		DATETIME		NOT NULL DEFAULT NOW() COMMENT '创建时间',
  update_time		DATETIME 		DEFAULT NULL ON UPDATE NOW() COMMENT '更新时间',
  PRIMARY KEY (user_id, role_id)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8 COMMENT ='用户和角色关系表';

3.2、表结构设计规范

​	1)表名使用项目简称前缀,这个前缀一般为3-4个字符,使用了表名前缀,数据表属于哪个项目一目了然。由于不同项目,有一些如用户表,角色表等通用的表,对于测试团队而言,有了表的前缀,只需一个测试数据库,就可以支持多个项目的测试。表名一般使用复数形式后缀,因为数据表是对象或关系的集合。
​	2)字段名采用下划线规则,小写字母,使用英文词汇或易理解的英文缩写,不能使用汉语拼音或汉语拼音缩写,那样会令人费解。
​	3)字符串类型使用VARCHAR类型,长文本考虑TEXT或更长的TEXT类型。
​	4)枚举类型一般使用TINYINT类型,更长的可考虑INT类型,且枚举类型均在系统参数表定义,不建议使用字符串作为枚举值。
​	5)除DATETIME、BLOB和TEXT类型外,均使用NOT NULL DEFAULT 形式。
​	6)TIMESTAMP类型,为4字节,有至多到2038年问题,为避免类似于千年虫问题,不再使用,使用DATETIME类型。如果需要精确到毫秒,可以用DATETIME(3),NOW默认值对应为NOW(3)。
​	7)所有字段都要有注释,即有COMMENT部分。
​	8)所有表都要有记录操作信息部分,即有operator_name、delete_flag、create_time、update_time这4个字段。operator_name值为空串,表示系统内部生成的记录(如统计记录);delete_flag字段,对于对象表(如用户表),通过delete_flag字段的值,启用或禁用对象,不做记录的物理删除;对于关系表(如用户和角色关系表),则支持物理删除,delete_flag字段保留。create_time和update_time用于标记记录的生成时间和最后修改时间,insert和update时无需考虑,由数据库自动维护,这2个字段在记录的增量使用时,非常有用,如表记录的定时同步,或采样数据的增量统计和分析。
​	9)对象表的主键ID字段,使用全局ID,不建议使用自增ID,因为自增ID在进行数据库的集群或分布式扩容以及数据迁移时,会遇到很大麻烦。
​	10)ID字段名称,应尽量使用符合对象身份的ID名称,如user_id、org_id等,而不应随便都用id或rec_id这样的通用名称(特殊情况除外),这样处理一方面可降低沟通成本,另外在多表联结时,也不必考虑字段别名以及别名与实体类属性的映射,可降低开发成本。另外,ID字段尽量使用INTEGER或BIGINT类型,而避免使用字符串类型,这样多表联结查询有性能优势。
​	11)要有主键字段定义。
​	12)要有表名注释。
​	13)关于数据库引擎,使用InnoDB,目前阿里云数据库已取消了对MYISAM的支持,因为两者性能已差别不大,而MYISAM不支持事务处理,显然不如统一使用InnoDB。
​	14)关于字符集,一般使用utf8,特殊场景,如需支持微信表情,则可考虑utf8mb4。
​	15)密码字段,考虑信息安全,签名密码可使用签名算法如MD5,第三方系统的账号密码如邮箱密码等,使用AES算法存储。
​	16)如果本表涉及数据权限,则应添加相关数据权限字段,以方便数据权限控制,这将在数据权限讨论中详细展开。

​	表结构设计,必须有字段说明,对于Mysql数据库而言,字段和表注释可以直接在建表语句中加入,这样即使不查阅表结构设计文档,也可利用数据库访问工具如Navicat等,通过查看表的DDL脚本了解各字段的含义和用处。对于如Hibernate、Golang等支持ORM(Object-Relation Mapping,对象关系映射)的框架,可以在代码中直接添加字段,这个原则上是不允许的。没有字段描述的表结构DDL脚本,是不可取的,只会给后人留下太多的坑,从而大大提高了维护成本。

3.3、使用系统参数表管理字段枚举值

​	关于系统参数表,表结构及记录的例子如下:
-- ----------------------------
-- Table structure for exa_sys_parameters
-- 系统参数表
-- ----------------------------
DROP TABLE IF EXISTS exa_sys_parameters;
CREATE TABLE exa_sys_parameters
(
  class_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '参数类别ID',
  class_key			VARCHAR(60)		NOT NULL DEFAULT '' COMMENT '参数类别key',
  class_name		VARCHAR(60)		NOT NULL DEFAULT '' COMMENT '参数类别名称',
  item_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '参数类别下子项ID',
  item_key			VARCHAR(200)	NOT NULL DEFAULT '' COMMENT '子项key',
  item_name			VARCHAR(60)		NOT NULL DEFAULT '' COMMENT '子项名称',
  item_value		VARCHAR(200)	NOT NULL DEFAULT '' COMMENT '子项值',
  item_desc			VARCHAR(512)	NOT NULL DEFAULT '' COMMENT '子项描述',

  -- 记录操作信息
  operator_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '操作人账号',
  delete_flag		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '记录删除标记,0-正常、1-已删除',
  create_time		DATETIME		NOT NULL DEFAULT NOW() COMMENT '创建时间',
  update_time		DATETIME		DEFAULT NULL ON UPDATE NOW() COMMENT '更新时间',
  PRIMARY KEY (class_id, item_id)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8 COMMENT '系统参数表';

CREATE INDEX exa_sys_parameters_class_key_item_key ON exa_sys_parameters (class_key, item_key);

-- 10001-10099 保留给基础表,即用户、权限相关的类型定义

INSERT INTO exa_sys_parameters(class_id, class_key, class_name, item_id, item_key, item_name,item_value, item_desc)
VALUES (10001, 'user_type', '用户类型', 1, '1', 'admin','系统管理员', '');
INSERT INTO exa_sys_parameters(class_id, class_key, class_name, item_id, item_key, item_name,item_value, item_desc)
VALUES (10001, 'user_type', '用户类型', 2, '2', 'internal person','公司内部用户', '');
INSERT INTO exa_sys_parameters(class_id, class_key, class_name, item_id, item_key, item_name,item_value, item_desc)
VALUES (10001, 'user_type', '用户类型', 3, '3', 'external person','外部用户', '');

INSERT INTO exa_sys_parameters(class_id, class_key, class_name, item_id, item_key, item_name,item_value, item_desc)
VALUES (10002, 'sex', '性别', 1, '1', 'none','无值', '');
INSERT INTO exa_sys_parameters(class_id, class_key, class_name, item_id, item_key, item_name,item_value, item_desc)
VALUES (10002, 'sex', '性别', 2, '2', 'male','男', '');
INSERT INTO exa_sys_parameters(class_id, class_key, class_name, item_id, item_key, item_name,item_value, item_desc)
VALUES (10002, 'sex', '性别', 3, '3', 'female','女', '');
INSERT INTO exa_sys_parameters(class_id, class_key, class_name, item_id, item_key, item_name,item_value, item_desc)
VALUES (10002, 'sex', '性别', 4, '4', 'other','其它', '');
​	关于系统参数表的作用,参见:(<a href="https://www.cnblogs.com/alabo1999/p/14907461.html" target="_blank">使用系统参数表,提升系统的灵活性</a>)。
​	系统参数也可以复用,如true_false类别,0表示否,1表示是,这个系统参数类别可以为多个表的枚举字段使用。
​	系统参数表的item_name可用于Java的枚举类的枚举项的名称,这样可提高代码的可读性,在接口参数校验时,对于枚举字段,需要使用枚举类型进行值的合法性检查。在后面提到的单元测试随机构造测试样本时,枚举字段的取值受此枚举类约束。

3.4、关于全局ID算法

​	全局ID可以有多种生成方法,如基于Redis的算法,雪花算法等,此处提供了一种基于Mysql数据库的全局ID方案,包括2张表和一个函数。
-- ----------------------------
-- Table structure for exa_table_code_config
-- ID编码配置表
-- ----------------------------
DROP TABLE IF EXISTS exa_table_code_config;
CREATE TABLE exa_table_code_config
(
  table_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '表ID',
  table_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '表名称',
  field_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT 'ID字段名称',

  -- 格式化编码使用,如15编码为HR000015,即数字前面用0填补。
  prefix			VARCHAR(20)		NOT NULL DEFAULT '' COMMENT '编码前缀字符串',
  prefix_len		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '编码前缀字符串长度',
  seqno_len			TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '序列号长度',
  PRIMARY KEY (table_id)
) ENGINE = Innodb
  DEFAULT CHARSET = utf8 COMMENT 'ID编码配置表';

CREATE INDEX exa_table_code_config_table_name ON exa_table_code_config(table_name);

-- 本系统的table_id以10001开始
INSERT INTO exa_table_code_config(table_id,table_name,field_name) 
VALUES(10001,'exa_users','user_id');
INSERT INTO exa_table_code_config(table_id,table_name,field_name) 
VALUES(10002,'exa_roles','role_id');
INSERT INTO exa_table_code_config(table_id,table_name,field_name) 
VALUES(10004,'exa_orgnizations','org_id');
INSERT INTO exa_table_code_config(table_id,table_name,field_name) 
VALUES(10005,'exa_functions','func_id');

-- ----------------------------
-- Table structure for exa_table_id_allocate
-- ID最新可用值表
-- ----------------------------
DROP TABLE IF EXISTS exa_table_id_allocate;
CREATE TABLE exa_table_id_allocate
(
  table_id			INT(11)			NOT NULL DEFAULT 0 COMMENT '表ID',
  last_id			BIGINT(20)		NOT NULL DEFAULT 0 COMMENT '最新可用ID值',
  PRIMARY KEY (table_id)
) ENGINE = Innodb
  DEFAULT CHARSET = utf8 COMMENT 'ID最新可用值表';

-- 初始记录
-- exa_users 预留10个账号,保留给内部系统使用
INSERT INTO exa_table_id_allocate(table_id,last_id) VALUES(10001,11);
INSERT INTO exa_table_id_allocate(table_id,last_id) VALUES(10002,4);
INSERT INTO exa_table_id_allocate(table_id,last_id) VALUES(10004,2);
INSERT INTO exa_table_id_allocate(table_id,last_id) VALUES(10005,1000);

-- ----------------------------------------------------------------------
-- 函数:获取全局ID
-- tableid: 表ID
-- record_count: 需要分配ID的记录条数,大于等于1
-- return: 第一条记录的ID
-- ----------------------------------------------------------------------
DELIMITER ;
DROP FUNCTION IF EXISTS exa_get_global_id;
CREATE FUNCTION exa_get_global_id(tableid INT(11), record_count INT(11))
  RETURNS BIGINT(20)
  DETERMINISTIC
BEGIN
  UPDATE exa_table_id_allocate
  SET
    last_id = (@exaid := last_id) + record_count
  WHERE table_id = tableid;
  RETURN @exaid;
END;
​	经过测试,这种方法获取的全局ID约为1000条/秒,而自增ID的获取速度约为600-800条/秒,另外当表的字段越多,自增ID的性能越差,而此方法的性能不受字段数目影响。
​	当有高频记录ID需要处理时,针对这类ID,可由服务器每次批量领取一定数目的ID,如1000个连续ID,服务器内部使用内存分发ID,这样就可以达到100万条/秒的处理性能,有点类似于雪花算法了,这与服务器是否分布式部署无关。
​	注意:@exaid为Mysql的全局变量,因此不同项目需要使用不同的变量名。

3.5、关于字段名下划线到属性字段驼峰的映射

​	Java的实体类属性字段名,一般使用驼峰规则。这样就涉及到下划线到驼峰的映射。
​	首先,配置文件需要设置mybatis使用下划线转驼峰的映射:
mybatis.configuration.map-underscore-to-camel-case=true
​	这样,表字段名可以映射实体类的属性名。
​	另外,需要注意的是,对于返回类型为Map<String, Object>或List<Map<String, Object>>的查询,字段名不会从下划线转为驼峰(参见:<a href="https://www.definesys.com/blog/post/18409.html" target="_blank">mybatis Map查询结果下划线转驼峰的实例</a>)。
​	这里提供另外一种解决思路,即开发下列三个公共方法:
	// 下划线字符串转为驼峰字符串
	public static String underlineToCamel(String input);

	// key为下划线字符串转为key为驼峰字符串的字典
	public static Map<String, Object> underlineToCamel(Map<String, Object> map);

	// key为下划线字符串转为key为驼峰字符串的字典列表
	public static List<Map<String, Object>> underlineToCamel(List<Map<String, Object>> mapList);

​	对于返回类型为Map<String, Object>或List<Map<String, Object>>的查询结果,先调用underlineToCamel方法处理,然后就可以用属性名访问了。

3.6、权限管理

​	权限管理与数据库设计有关。
​	权限管理分两个层面,一个是功能权限,一个是数据权限。

3.6.1、功能权限管理

​	功能权限是用户可以观察和操作的页面元素的权限,包括菜单权限和功能操作权限,这个通常是基于RBAC(Role-Based Access Control),即基于角色的访问控制,就是角色表、功能表、角色与功能关系表、用户与角色关系表,来定义功能项,角色项,每种角色可以操作的功能集合,用户拥有的角色集合,从而得到用户可以操作的功能集合。
​	下面是相关表结构:
-- ----------------------------
-- Table structure for exa_functions
-- 功能表
-- ----------------------------
DROP TABLE IF EXISTS exa_functions;
CREATE TABLE exa_functions
(
  func_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '功能ID',
  func_name			VARCHAR(100)	NOT NULL DEFAULT '' COMMENT '功能名称',
  parent_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '父功能ID',
  level				TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '功能所在层级',
  order_no			INT(11)			NOT NULL DEFAULT 0  COMMENT '显示顺序',
  url				VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '访问接口url',
  dom_key			VARCHAR(80)		NOT NULL DEFAULT '' COMMENT 'dom对象的ID',
  img_tag			VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '节点icon名称',

  remark			VARCHAR(200)	NOT NULL DEFAULT '' COMMENT '备注',

  -- 记录操作信息
  operator_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '操作人账号',
  delete_flag		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '记录删除标记,0-正常、1-已删除',
  create_time		DATETIME		NOT NULL DEFAULT NOW() COMMENT '创建时间',
  update_time		DATETIME		DEFAULT NULL ON UPDATE NOW() COMMENT '更新时间',
  PRIMARY KEY (func_id)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8 COMMENT ='功能表';

-- ----------------------------
-- Table structure for exa_roles
-- 角色表
-- ----------------------------
DROP TABLE IF EXISTS exa_roles;
CREATE TABLE exa_roles
(
  role_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '角色ID',
  role_name			VARCHAR(40)		NOT NULL DEFAULT '' COMMENT '角色名称',
  role_type			TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '角色类型,参见系统参数表',
  remark			VARCHAR(100)	NOT NULL DEFAULT '' COMMENT '描述',

  operator_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '操作人账号',
  delete_flag		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '记录删除标记,0-正常、1-已删除',
  create_time		DATETIME		NOT NULL DEFAULT NOW() COMMENT '创建时间',
  update_time		DATETIME		DEFAULT NULL ON UPDATE NOW() COMMENT '更新时间',
  PRIMARY KEY (role_id)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8 COMMENT ='角色表';

INSERT INTO exa_roles(role_id,role_name,role_type) VALUES(1,'系统管理员',1);
INSERT INTO exa_roles(role_id,role_name,role_type) VALUES(2,'账号管理员',2);
INSERT INTO exa_roles(role_id,role_name,role_type) VALUES(3,'运维人员',3);

-- ----------------------------
-- Table structure for exa_role_func_rights
-- 角色和功能权限关系表
-- 角色和功能权限是多对多关系
-- ----------------------------
DROP TABLE IF EXISTS exa_role_func_rights;
CREATE TABLE exa_role_func_rights
(
  role_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '角色ID',
  func_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '功能ID',
  sub_full_flag		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '是否包含全部子节点权限,0-否,1-是',

  operator_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '操作人账号',
  delete_flag		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '记录删除标记,保留字段',
  create_time		DATETIME		NOT NULL DEFAULT NOW() COMMENT '创建时间',
  update_time		DATETIME 		DEFAULT NULL ON UPDATE NOW() COMMENT '更新时间',
  PRIMARY KEY (role_id, func_id)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8 COMMENT ='角色和功能权限关系表';  
​	功能表的URL字段,用于后端访问控制,一般使用AOP来实现。前端使用dom_key字段(即element的id属性),对于Vue等单页面框架,不同模块可以重复使用相同的dom_key值,如新增按钮都使用"add";对于非单页面框架,如bootstrap的js框架等,要注意确保dom_key值的唯一性。
​	关于Vue前端的访问控制,参见:<a href="https://www.cnblogs.com/alabo1999/p/14960584.html" target="_blank">Vue前端访问控制方案</a>和<a href="https://www.cnblogs.com/alabo1999/p/14976481.html" target="_blank">Vue 前端权限控制的优化改进版</a>。
​	功能权限管理与CRUD关系不大,各个模块无需维护。

3.6.2、数据权限管理

​	对于某项功能,如果有功能权限时,所有用户看到或操作的数据集如果是一样的,则无需数据权限控制,只需要功能权限控制即可。如果不同用户,使用相同的功能,可访问的数据集不同,则需要数据权限。
​	数据权限是用户可以观察和操作的数据集的权限,这个也称为RBAC(Resource-Based Access Control),即基于资源的访问控制,为了区别于基于角色的访问控制,我将之定义为R2BAC。
​	如果不同组织有各自的账号管理员,其可以分配管理自己组织的用户账号,但不能看到和操作其它组织的用户账号,这就是数据权限的典型例子。
​	数据权限,有“功能+行+列”3个维度。功能维度,即不同操作功能可访问的数据集不同,如某类账号管理员可以查询本组织的所有账号,但只能编辑由其创建的那些账号。行维度,为数据记录集合,即可以访问哪些记录。列维度,为字段集合,如对某些用户,可以看到全部字段;对另外一些用户,某些字段不显示。
​	功能维度,对于大部分应用,不需要,即增删改查的数据集是相同的(撇开功能权限)。如需要,配置数据权限的功能ID。
​	行维度,即数据筛选,实际上是定义数据过滤的条件。数据过滤条件可以非常复杂,复杂的过滤条件一般直接用代码层面控制,而常用的数据过滤条件一般是记录的某个或某几个ID字段的取值范围,相当于sql语句的"in (...)",下面给出的设计方案是ID字段的行维度数据权限访问控制。
​	列维度,可以用数据权限字段过滤表来定义,但由于不大常用,一般也不用,而是直接用代码层面控制,将相关过滤的属性值设置为null即可。
​	在CRUD代码实现时,需要加入数据权限的校验和过滤。如对组织的账号管理员,新增时,需要判断orgId是否在许可集中;编辑时,原记录的orgId是否在许可集中,如修改了orgId,新的orgId是否在许可集中;删除时,需要判断orgId是否在许可集中;查询时,需要将权限许可的orgIdList加入查询条件,或查询后,检查orgId是否在许可集中。
​	下面是数据权限行维度访问控制的相关表:
-- ----------------------------
-- Table structure for exa_dr_fields
-- 数据权限相关字段表
-- 为简化处理,要求权限相关字段,在所有表中具有相同的定义和含义
-- ----------------------------
DROP TABLE IF EXISTS exa_dr_fields;
CREATE TABLE exa_dr_fields
(
  field_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '字段ID',
  field_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '字段名称',
  prop_name			VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '属性名称',
  invalid_value		VARCHAR(20)		NOT NULL DEFAULT '' COMMENT '无效值,用于ID字段无权限时的查询条件',
  has_sub			TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '是否有下级对象,0-否,1-是',
  is_user_prop		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '是否为用户属性字段,0-否,1-是',
  is_id				TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '是否为ID字段,即起源表的主键,0-否,1-是',
  remark			VARCHAR(200)	NOT NULL DEFAULT '' COMMENT '备注',

  -- 记录操作信息
  operator_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '操作人账号',
  delete_flag		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '记录标记,,0-正常、1-已删除',
  create_time		DATETIME		NOT NULL DEFAULT NOW() COMMENT '创建时间',
  update_time		DATETIME		DEFAULT NULL ON UPDATE NOW() COMMENT '更新时间',
  PRIMARY KEY (field_id)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8 COMMENT ='数据权限相关字段表';

INSERT INTO exa_dr_fields(field_id,field_name,prop_name,invalid_value,has_sub,is_user_prop,is_id) 
VALUES(1,'org_id','orgId','-1',1,1,1);

-- ----------------------------
-- Table structure for exa_user_drs
-- 用户数据权限表
-- 用户对各个权限相关字段,应都有一条记录,如无记录,表示无权限
-- ----------------------------
DROP TABLE IF EXISTS exa_user_drs;
CREATE TABLE exa_user_drs
(
  user_id			BIGINT(20)		NOT NULL DEFAULT 0  COMMENT '用户ID',
  field_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '字段ID',
  field_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '字段名',
  -- 如果字段为用户属性字段,则允许使用默认规则,否则不允许
  dr_type			TINYINT(4)		NOT NULL DEFAULT 1  COMMENT '数据权限类型,1-默认规则、2-自定义、3-全部',
  -- 如果字段不是ID类型,可以使用表达式,目前暂时不用
  expr				VARCHAR(255)	NOT NULL DEFAULT '' COMMENT '表达式',
  func_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '功能ID,0-所有功能',

  -- 记录操作信息
  operator_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '操作人账号',
  delete_flag		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '记录标记,保留',
  create_time		DATETIME		NOT NULL DEFAULT NOW() COMMENT '创建时间',
  update_time		DATETIME		DEFAULT NULL ON UPDATE NOW() COMMENT '更新时间',
  PRIMARY KEY (user_id,field_id)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8 COMMENT ='数据权限规则表';

-- ----------------------------
-- Table structure for exa_user_custom_drs
-- 用户自定义数据权限表
-- 仅针对数据权限类型为自定义的用户数据权限记录
-- 由于不确定有多少个自定义权限字段,因此使用了记录ID作为主键,方便增加字段
-- ----------------------------
DROP TABLE IF EXISTS exa_user_custom_drs;
CREATE TABLE exa_user_custom_drs
(
  user_id			BIGINT(20)		NOT NULL DEFAULT 0  COMMENT '用户ID',
  field_id			INT(11)			NOT NULL DEFAULT 0  COMMENT '字段ID',
  field_value		INT(11)			NOT NULL DEFAULT 0  COMMENT '字段值',

  -- 记录操作信息
  operator_name		VARCHAR(80)		NOT NULL DEFAULT '' COMMENT '操作人账号',
  delete_flag		TINYINT(4)		NOT NULL DEFAULT 0  COMMENT '记录标记,保留',
  create_time		DATETIME		NOT NULL DEFAULT NOW() COMMENT '创建时间',
  update_time		DATETIME		DEFAULT NULL ON UPDATE NOW() COMMENT '更新时间',
  PRIMARY KEY (user_id,field_id,field_value)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8 COMMENT ='用户数据权限关系表';
​	使用了三个表来管理数据权限,如果需要列维度的数据权限,则还需要增加数据权限字段过滤表。
​	数据权限相关字段表exa_dr_fields,用于定义数据权限相关的字段,包括数据库字段名称,对象属性名。为方便数据权限管理,这个名称和含义是整个系统一致的,如org_id,表示组织ID,则所有表中如包含org_id字段的,其含义必须是组织ID。
​	invalid_value为字段的某个无效值,如ID字段一般大于0,但有时0表示全部,所以一般取-1表示ID的无效值。当对该字段无权限时,使用无效值,如orgIdList为[-1],查询条件为"org_id in (-1)",查询到的结果集为空集,表示无权限。
​	has_sub,表示是否有下级对象,即ID字段代表的对象是否支持树型结构,如组织对象,支持下级对象;而班级对象,则不支持下级对象。为了方便权限设置,如果包含所有下级对象的权限,只需设置父级对象的权限,而不必一一设置。
​	is_user_prop,是否为用户属性字段,如果为用户属性字段(用户表或用户扩展表,用户的某种属性值),则可以使用默认权限。如用户只能属于某一个组织,则org_id为用户属性字段;如果用户可以属于某几个组织,则用户表的组织ID属性失去意义,就不能使用默认数据权限。
​	is_id,是否为ID字段,即起源表的主键,即为某种对象的ID。目前所有权限字段都是ID字段,该字段是为了保留扩展的可能性(如需要增加数据权限字段过滤表,则过滤字段一般就不是ID字段)。

​	用户数据权限表exa_user_drs,用于定义对不同数据权限字段和功能ID的数据权限类型。
​	dr_type,数据权限类型,1-默认规则、2-自定义、3-全部。如果字段为用户属性字段,则可以使用“默认规则”。如用户只能属于某一个组织,则org_id为用户属性字段,默认规则就是用户可以访问本组织及下属组织的所有数据。“自定义”,就是权限范围通过用户自定义数据权限表exa_user_custom_drs来配置,如未配置,则无数据权限(树型对象默认包含全部下级对象,下级对象可不配置)。“全部”,就是对此字段不进行过滤。数据权限类型支持默认规则和全部,可以方便数据配置,减少维护工作量。如支持默认规则,则本组织下级增加一个子组织,该用户自动拥有对新增子组织的访问权限,而无需在用户自定义数据权限表中配置。
​	func_id,功能ID,用于功能维度,0表示全部。每个用户,在每个权限字段上,都需要配置一条数据权限记录,功能ID为0,用于不特别指定功能ID的情况下的数据权限配置。如某些功能需要特别数据权限,则再加上特定功能ID的数据权限记录。
​	expr,用于sql条件表达式,为扩展保留,未使用。

​	用户自定义数据权限表exa_user_custom_drs,用于数据权限类型为自定义时,ID字段的取值范围。
​	field_value,字段值,不同ID字段的数据类型,此处使用整数型,如有Long型,则需要修改字段数据类型。

​	假设系统需要进行数据权限控制的字段集为A={d1,d2,...,dn},某个表T如需要进行数据权限控制,则其需要包含A的某个非空子集的字段。如果表T包括多个数据权限字段,则数据权限为交集,相当于"d1 in (...) and d2 in (...)"。为了方便查询过滤,表结构设计时,应该增加相关数据权限字段的冗余设计。这样查询T时,可以使用如:"di in (...)"形式的查询条件,而不必进行表的联结,并且冗余设计后,由于实体类对象包含了相关数据权限字段,也便于代码实现。

4、Entity实体类 ​

4.1、实体类代码示例

​	Entity实体类的代码示例如下:
package com.abc.example.entity;

import java.time.LocalDate;
import java.time.LocalDateTime;
import javax.persistence.Column;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

/**
 * @className	: User
 * @description	: 用户对象实体类
 * @summary		: 
 * @history		:
 * ------------------------------------------------------------------------------
 * date			version		modifier		remarks
 * ------------------------------------------------------------------------------
 * yyyy/mm/dd	1.0.0		author		    初版
 *
 */
@Data
public class User {
	// 用户ID
	@Column(name = "user_id")
	@JsonFormat(shape= JsonFormat.Shape.STRING)
	private Long userId = 0L;
	
	// 用户名
	@Column(name = "user_name")
	private String userName = "";
	
	// 用户密码
	@Column(name = "password")
	private String password = "";
	
	// 加盐md5算法中的盐
	@Column(name = "salt")
	private String salt = "";
	
	// 用户类型,1-系统管理员、2-公司内部用户、3-外部用户
	@Column(name = "user_type")
	private Byte userType = 3;
	
	// 组织机构ID
	@Column(name = "org_id")
	private Integer orgId = 0;
	
	// 组织名称
	private String orgName = "";	
	
	// 真实姓名
	@Column(name = "real_name")
	private String realName = "";
	
	// Email
	@Column(name = "email")
	private String email = "";
	
	// 手机号码
	@Column(name = "phone_number")
	private String phoneNumber = "";
	
	// 性别,1-无值、2-男、3-女、4-其它
	@Column(name = "sex")
	private Byte sex = 1;
	
	// 生日
	@Column(name = "birth")
	@JsonFormat(shape= JsonFormat.Shape.STRING, pattern="yyyy-MM-dd")
	private LocalDate birth;
	
	// 身份证号码
	@Column(name = "id_no")
	private String idNo = "";
	
	// 微信小程序的openid
	@Column(name = "open_id")
	private String openId = "";
	
	// 微信公众号openid
	@Column(name = "woa_openid")
	private String woaOpenid = "";
	
	// 备注
	@Column(name = "remark")
	private String remark = "";
	
	// 操作人账号
	@Column(name = "operator_name")
	private String operatorName = "";
	
	// 记录删除标记,0-正常、1-禁用
	@Column(name = "delete_flag")
	private Byte deleteFlag = 0;
	
	// 创建时间
	@Column(name = "create_time")
	@JsonFormat(shape= JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
	private LocalDateTime createTime;
	
	// 更新时间
	@Column(name = "update_time")
	@JsonFormat(shape= JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
	private LocalDateTime updateTime;	

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		User other = (User) obj;
		if (userName == null) {
			if (other.userName != null)
				return false;
		} else if (!userName.equals(other.userName))
			return false;
		return true;
	}

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((userName == null) ? 0 : userName.hashCode());
		return result;
	}	
}    

4.2、实体类开发规范

​	1)一般使用lombok的@Data注解,以替代getter/setter方法的冗长代码。
​	2)属性字段名使用驼峰规则,要有对应的数据表字段名,以及属性的描述。数据表字段名可使用JPA的@Column来表述,其它添加属性字段则不需要@Column注解。
​	3)默认值,这个要与表结构一致,否则实现Mybatis脚本insert语句会比较复杂。
​	4)不同数据类型的前端支持问题,如LocalDateTime的格式化问题,前端Long型数据损失精度问题等,这些需要用@JsonFormat进行注解。
​	5)与数据库字段对应的属性字段,其数据类型使用类的形式,如Integer,Long,而不是int,long,这样可以赋值为null。考虑到信息安全及数据权限,某些属性不希望展示给前端,此时可以设置为null。需要注意的是,数据类型使用类的形式,类对象之间比较,==和!=操作符会失效,要使用equals方法来比较值。
​	6)是否需要支持对象克隆,如需支持,需要实现Cloneable的clone接口,支持克隆可大大简化对象复制的处理代码。
​	7)是否需要支持对象比较,如果实体类对象要作为Map的key,则需要实现equals和hashCode接口,这个可以使用IDE工具自动生成代码。
​	8)是否需要支持格式化输出,如作为TreeNode的节点数据,则需要实现toString接口。
​	9)是否需要增加其它表的引用字段,如orgId,需要引用组织表的orgName字段,否则前端不好展示。
​	10)是否有一些内部字段,不希望传到前端,可考虑使用@JsonIgnore进行注解。
​	11)考虑线程安全,日期时间类型不再使用Date类型,而是使用LocalDateTime,LocalDate,LocalTime。另外,关于时间,涉及到时区问题,关于Mysql驱动的配置项应考虑GMT+8,即:
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/demodb?verifyServerCertificate=false&useSSL=false&characterEncoding=UTF-8&serverTimezone=GMT%2B8
​	12)是否需要支持Excel导入,如需支持,应增加一个equals和hashCode方法,作为在导入数据时的异常数据行的定位,这个在后面Excel导入功能时将详细讨论。

(未完待续...)
posted @ 2023-06-11 12:07  阿拉伯1999  阅读(142)  评论(0编辑  收藏  举报