此外本文也通过关于雇员薪酬调整的案例,渗透讲解 DDD 模型中的聚合对象、实体对象和值对象在领域模型中的实践。
同时也通过简单干净实践的方式教会读者,使用 SpringBoot 配置 MyBatis 并完成对插入、批量插入、修改、查询以及注解事务和编程事务的使用,通过扩展插件开发对置顶字段进行加解处理。
本文涉及的工程:
说一千道一万,给小卡拉米写的教程,得简单还好看!
为了更好的把 MyBatis 常用的各项功能体现的清晰明了,小傅哥这里设定了公司雇员和对应薪酬关系的一个开发场景。
模型定义:https://bugstack.cn/md/road-map/ddd.html - 你可以先参考小傅哥的 DDD 篇,这样可以更好的理解模型概念和设计原则。
DDD 领域驱动设计的中心,主要在于领域模型的设计,以领域所需驱动功能实现和数据建模。一个领域服务下面会有多个领域模型,每个领域模型都是一个充血结构。一个领域模型 = 一个充血结构
model 模型对象;
repository 仓储服务;从数据库等数据源中获取数据,传递的对象可以是聚合对象、实体对象,返回的结果可以是;实体对象、值对象。因为仓储服务是由基础层(infrastructure) 引用领域层(domAIn),是一种依赖倒置的结构,但它可以天然的隔离PO数据库持久化对象被引用。
service 服务设计;这里要注意,不要以为定义了聚合对象,就把超越1个对象以外的逻辑,都封装到聚合中,这会让你的代码后期越来越难维护。聚合更应该注重的是和本对象相关的单一简单封装场景,而把一些重核心业务方到 service 里实现。此外;如果你的设计模式应用不佳,那么无论是领域驱动设计、测试驱动设计还是换了三层和四层架构,你的工程质量依然会非常差。
此场景的业务用于对指定的用户进行晋升加薪调幅,但因为加薪会需要操作3个表,包括;雇员表 - 修改个人Title、薪资表 - 修改薪酬、调薪记录表 - 每一次加薪都写一条记录。
public enum EmployeePostVO {
T1("T-1", "初级工程师"),
T2("T-2", "初级工程师"),
T3("T-3", "中级工程师"),
T4("T-4", "中级工程师"),
T5("T-5", "高级工程师"),
T6("T-6", "高级工程师"),
T7("T-7", "架构师"),
T8("T-8", "架构师");
private final String code;
private final String desc;
// 省略部分
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class EmployeeEntity {
/** 雇员级别 */
private EmployeePostVO employeeLevel;
/** 雇员岗位Title */
private EmployeePostVO employeeTitle;
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class EmployeeSalaryAdjustEntity {
/** 总额调薪 */
private BigDecimal adjustTotalAmount;
/** 基础调薪 */
private BigDecimal adjustBaseAmount;
/** 绩效调薪 */
private BigDecimal adjustMeritAmount;
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AdjustSalaryApplyOrderAggregate {
/** 雇员编号 */
private String employeeNumber;
/** 调薪单号 */
private String orderId;
/** 雇员实体 */
private EmployeeEntity employeeEntity;
/** 雇员实体 */
private EmployeeSalaryAdjustEntity employeeSalaryAdjustEntity;
}
public interface ISalaryAdjustRepository {
String adjustSalary(AdjustSalaryApplyOrderAggregate adjustSalaryApplyOrderAggregate);
}
public interface ISalaryAdjustApplyService {
String execSalaryAdjust(AdjustSalaryApplyOrderAggregate adjustSalaryApplyOrderAggregate);
}
综上,有了这样的模型结构设计定义,相信你也可以很好的拆分自己的业务对象并完成领域功能实现了。
接下来我们介绍一些关于 MyBatis 的使用功能,但你可以带着 DDD 的思想来看这些内容实现时所在的位置,这会让你不只是学习 MyBatis 也能学会一些 DDD 的设计。
源码:cn.bugstack.xfg.dev.tech.infrastructure.dao.IEmployeeDAO
@Mapper
public interface IEmployeeDAO {
void insert(EmployeePO employee);
void insertList(List<EmployeePO> list);
void update(EmployeePO employeePO);
EmployeePO queryEmployeeByEmployNumber(String employNumber);
}
xml:employee_mapper.xml
<insert id="insert" parameterType="cn.bugstack.xfg.dev.tech.infrastructure.po.EmployeePO">
INSERT INTO employee(employee_number, employee_name, employee_level, employee_title, create_time, update_time)
VALUES(#{employeeNumber}, #{employeeName}, #{employeeLevel}, #{employeeTitle}, now(), now())
</insert>
<insert id="insertList" parameterType="JAVA.util.List">
INSERT INTO employee(employee_number, employee_name, employee_level, employee_title, create_time, update_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.employeeNumber}, #{item.employeeName}, #{item.employeeLevel}, #{item.employeeTitle}, now(), now())
</foreach>
</insert>
Spring 提供的事务分为注解事务和编程事务,编程事务可以更细粒度的控制。
Spring Boot 事务管理的级别可以通过 @Transactional
注解的 isolation
属性进行配置。常见的事务隔离级别有以下几种:
DEFAULT
:使用底层数据库的默认隔离级别。MySQL 默认为 REPEATABLE READ
,Oracle 默认为 READ COMMITTED
。READ_UNCOMMITTED
:最低的隔离级别,允许读取未提交的数据变更,可能会导致脏读、不可重复读和幻读问题。READ_COMMITTED
:允许读取已经提交的数据变更,可以避免脏读问题,但可能会出现不可重复读和幻读问题。REPEATABLE_READ
:保证同一事务中多次读取同一数据时,结果始终一致,可以避免脏读和不可重复读问题,但可能会出现幻读问题。SERIALIZABLE
:最高的隔离级别,可以避免脏读、不可重复读和幻读问题,但会影响并发性能。在 Spring Boot 中,默认的事务隔离级别为 DEFAULT
。如果没有特殊需求,建议使用默认隔离级别。
SpringBoot 事务的传播行为可以通过 @Transactional
注解的 propagation
属性进行配置。常用的传播行为有以下几种:
Propagation.REQUIRED
:默认的传播行为,如果当前存在事务,则加入该事务,否则新建一个事务;Propagation.SUPPORTS
:如果当前存在事务,则加入该事务,否则以非事务的方式执行;Propagation.MANDATORY
:如果当前存在事务,则加入该事务,否则抛出异常;Propagation.REQUIRES_NEW
:无论当前是否存在事务,都会新建一个事务,如果当前存在事务,则将当前事务挂起;Propagation.NOT_SUPPORTED
:以非事务的方式执行操作,如果当前存在事务,则将当前事务挂起;Propagation.NEVER
:以非事务的方式执行操作,如果当前存在事务,则抛出异常;Propagation.NESTED
:如果当前存在事务,则在该事务的嵌套事务中执行,否则新建一个事务。嵌套事务是独立于外部事务的,但是如果外部事务回滚,则嵌套事务也会回滚。除了传播行为,@Transactional
注解还可以配置其他属性,例如隔离级别、超时时间、只读等。
源码:cn.bugstack.xfg.dev.tech.infrastructure.repository.SalaryAdjustRepository
@Transactional(rollbackFor = Exception.class, timeout = 350, propagation = Propagation.REQUIRED, isolation = Isolation.DEFAULT)
public String adjustSalary(AdjustSalaryApplyOrderAggregate adjustSalaryApplyOrderAggregate) {
String employeeNumber = adjustSalaryApplyOrderAggregate.getEmployeeNumber();
String orderId = adjustSalaryApplyOrderAggregate.getOrderId();
EmployeeEntity employeeEntity = adjustSalaryApplyOrderAggregate.getEmployeeEntity();
EmployeeSalaryAdjustEntity employeeSalaryAdjustEntity = adjustSalaryApplyOrderAggregate.getEmployeeSalaryAdjustEntity();
EmployeePO employeePO = EmployeePO.builder()
.employeeNumber(employeeNumber)
.employeeLevel(employeeEntity.getEmployeeLevel().getCode())
.employeeTitle(employeeEntity.getEmployeeTitle().getDesc()).build();
// 更新岗位
employeeDAO.update(employeePO);
EmployeeSalaryPO employeeSalaryPO = EmployeeSalaryPO.builder()
.employeeNumber(employeeNumber)
.salaryTotalAmount(employeeSalaryAdjustEntity.getAdjustTotalAmount())
.salaryMeritAmount(employeeSalaryAdjustEntity.getAdjustMeritAmount())
.salaryBaseAmount(employeeSalaryAdjustEntity.getAdjustBaseAmount())
.build();
// 更新薪酬
employeeSalaryDAO.update(employeeSalaryPO);
EmployeeSalaryAdjustPO employeeSalaryAdjustPO = EmployeeSalaryAdjustPO.builder()
.employeeNumber(employeeNumber)
.adjustOrderId(orderId)
.adjustTotalAmount(employeeSalaryAdjustEntity.getAdjustTotalAmount())
.adjustBaseAmount(employeeSalaryAdjustEntity.getAdjustMeritAmount())
.adjustMeritAmount(employeeSalaryAdjustEntity.getAdjustBaseAmount())
.build();
// 写入流水
employeeSalaryAdjustDAO.insert(employeeSalaryAdjustPO);
return orderId;
}
private TransactionTemplate transactionTemplate;
@Override
public void insertEmployeeInfo(EmployeeInfoEntity employeeInfoEntity) {
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
try {
EmployeePO employeePO = EmployeePO.builder()
.employeeNumber(employeeInfoEntity.getEmployeeNumber())
.employeeName(employeeInfoEntity.getEmployeeName())
.employeeLevel(employeeInfoEntity.getEmployeeLevel())
.employeeTitle(employeeInfoEntity.getEmployeeTitle())
.build();
employeeDAO.insert(employeePO);
EmployeeSalaryPO employeeSalaryPO = EmployeeSalaryPO.builder()
.employeeNumber(employeeInfoEntity.getEmployeeNumber())
.salaryTotalAmount(employeeInfoEntity.getSalaryTotalAmount())
.salaryMeritAmount(employeeInfoEntity.getSalaryMeritAmount())
.salaryBaseAmount(employeeInfoEntity.getSalaryBaseAmount())
.build();
employeeSalaryDAO.insert(employeeSalaryPO);
} catch (Exception e) {
status.setRollbackOnly();
e.printStackTrace();
}
}
});
}
使用 MyBatis 时,也会经常会用到插件开发。尤其是做一些数据的加解密、路由、日志等,都可以基于插件实现。
那么这里小傅哥就带着你实现一个对指定字段加解密的处理,比如雇员的姓名、薪资、级别是可以隐藏的,避免被有心之人盗取。
源码:cn.bugstack.xfg.dev.tech.plugin.FieldEncryptionAndDecryptionMybatisPlugin
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class FieldEncryptionAndDecryptionMybatisPlugin implements Interceptor {
/**
* 密钥,必须是16位
*/
private static final String KEY = "1898794876567654";
/**
* 偏移量,必须是16位
*/
private static final String IV = "1233214566547891";
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement mappedStatement = (MappedStatement) args[0];
Object parameter = args[1];
String sqlId = mappedStatement.getId();
if (parameter != null && (sqlId.contains("insert") || sqlId.contains("update")) ) {
String columnName = "employeeName";
if (parameter instanceof Map) {
List<Object> parameterList = (List<Object>) ((Map<?, ?>) parameter).get("list");
for (Object obj : parameterList) {
if (hasField(obj, columnName)) {
String fieldValue = BeanUtils.getProperty(obj, columnName);
String encryptedValue = encrypt(fieldValue);
BeanUtils.setProperty(obj, columnName, encryptedValue);
}
}
} else {
if (hasField(parameter, columnName)) {
String fieldValue = BeanUtils.getProperty(parameter, columnName);
String encryptedValue = encrypt(fieldValue);
BeanUtils.setProperty(parameter, columnName, encryptedValue);
}
}
}
Object result = invocation.proceed();
if (result != null && sqlId.contains("query")) {
// 查询操作,解密
String columnName = "employeeName";
if (result instanceof List) {
List<Object> resultList = (List<Object>) result;
for (Object obj : resultList) {
if (!hasField(obj, columnName)) continue;
String fieldValue = BeanUtils.getProperty(obj, columnName);
if (StringUtils.isBlank(fieldValue)) continue;
String decryptedValue = decrypt(fieldValue);
BeanUtils.setProperty(obj, columnName, decryptedValue);
}
}
}
return result;
}
public String encrypt(String content) throws Exception {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
byte[] raw = KEY.getBytes();
SecretKeySpec secretKeySpec = new SecretKeySpec(raw, "AES");
IvParameterSpec ivParameterSpec = new IvParameterSpec(IV.getBytes());
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
byte[] encrypted = cipher.doFinal(content.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
}
/**
* AES解密
*
* @param content 密文
* @return 明文
* @throws Exception 异常
*/
public String decrypt(String content) throws Exception {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
byte[] raw = KEY.getBytes();
SecretKeySpec secretKeySpec = new SecretKeySpec(raw, "AES");
IvParameterSpec ivParameterSpec = new IvParameterSpec(IV.getBytes());
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec);
byte[] encrypted = Base64.getDecoder().decode(content);
byte[] original = cipher.doFinal(encrypted);
return new String(original);
}
public boolean hasField(Object obj, String fieldName) {
Class<?> clazz = obj.getClass();
while (clazz != null) {
try {
Field field = clazz.getDeclaredField(fieldName);
return true;
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
}
return false;
}
}
Intercepts
之后在 intercept 接口实现方法中,获取 MappedStatement 这个 MyBatis的映射核心类。《手写MyBatis:渐进式源码实践》 - 跟小傅哥学MyBatis,从零手写源码级复杂项目,提升架构思维与设计逻辑,锻炼编码能力!
@Test
public void test_execSalaryAdjust() {
AdjustSalaryApplyOrderAggregate adjustSalaryApplyOrderAggregate = AdjustSalaryApplyOrderAggregate.builder()
.employeeNumber("10000001")
.orderId("100908977676001")
.employeeEntity(EmployeeEntity.builder().employeeLevel(EmployeePostVO.T3).employeeTitle(EmployeePostVO.T3).build())
.employeeSalaryAdjustEntity(EmployeeSalaryAdjustEntity.builder()
.adjustTotalAmount(new BigDecimal(100))
.adjustBaseAmount(new BigDecimal(80))
.adjustMeritAmount(new BigDecimal(20)).build())
.build();
String orderId = salaryAdjustApplyService.execSalaryAdjust(adjustSalaryApplyOrderAggregate);
log.info("调薪测试 req: {} res: {}", JSON.toJSONString(adjustSalaryApplyOrderAggregate), orderId);
}
23-07-15.13:23:11.514 [main ] INFO HikariDataSource - HikariPool-1 - Start completed.
23-07-15.13:23:11.910 [main ] INFO ISalaryAdjustApplyServiceTest - 调薪测试 req: {"employeeEntity":{"employeeLevel":"T3","employeeTitle":"T3"},"employeeNumber":"10000001","employeeSalaryAdjustEntity":{"adjustBaseAmount":80,"adjustMeritAmount":20,"adjustTotalAmount":100},"orderId":"100908977676002"} res: 100908977676002
@Test
public void test_queryEmployInfo() {
EmployeeInfoEntity employeeInfoEntity = employeeService.queryEmployInfo("10000001");
log.info("测试结果:{}", JSON.toJSONString(employeeInfoEntity));
}
23-07-15.13:24:54.000 [main ] INFO HikariDataSource - HikariPool-1 - Start completed.
23-07-15.13:24:54.490 [main ] INFO IEmployeeServiceTest - 测试结果:{"employeeLevel":"T-3","employeeName":"小傅哥","employeeNumber":"10000001","employeeTitle":"中级工程师","salaryBaseAmount":5200.00,"salaryMeritAmount":5200.00,"salaryTotalAmount":5200.00}