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导入功能时将详细讨论。
(未完待续...)