![怎么实现 mybatis 自动设置创建时间更新时间](/medias/featureimages/6.jpg)
![Redis 分布式锁你续约了吗](/medias/featureimages/22.jpg)
diff --git a/2023/03/27/gitlab-shang-he-xiang-mu-da-cang-ku-shuo-zai-jian/index.html b/2023/03/27/gitlab-shang-he-xiang-mu-da-cang-ku-shuo-zai-jian/index.html index e201f7f..99e7e94 100644 --- a/2023/03/27/gitlab-shang-he-xiang-mu-da-cang-ku-shuo-zai-jian/index.html +++ b/2023/03/27/gitlab-shang-he-xiang-mu-da-cang-ku-shuo-zai-jian/index.html @@ -377,6 +377,10 @@
mybatis 提供 Interceptor 接口以插件方式提供扩展能力。互联网上大都是对数据表映射类对象中关于时间属性设置当前时间的解决方案。但这种方法无法解决 mapper.xml 写更新 SQL 或 @XXXProvider 拼 SQL 的方式插入或更新数据表。但是依托于数据表映射类本身没有问题,因为需要知道创建时间和更新时间对应的数据库字段信息,这是光拦截到 SQL 而无法判断时间相关的字段是否存在并赋值。
+如果你项目中使用了 mybatis-plus 组件,恭喜你做这个决定你足够明智。 mybatis-plus 提供 MetaObjectHandler 抽象类实现公共字段自动写入能力。其大体思路是针对 @XXXProvider 拼 SQL 时将实体中标记需要自动填充的字段拼入 SQL 中,通过 metaObjectHandler 对实体属性字段填充相应值,最后带有自动填充字段的 PrepareStatement SQL 插入/更新数据表数据。
+但,项目上使用自写 BaseMapper<E,ID>
接口和 @XXXProvider 注解实现 BaseMapperSqlSourceBuilder 类完成 SQL 拼接。但未提供对公用字段自动写入能力。
Interceptor 拦截的位置是执行 SQL 之前,也就是 @Signature(type = Executor.class, method="update", args={MappedStatement.class, Object.class})
,在 SQL 里拼接时间的字段和字段值。字段值可以直接设置的 now()
数据库函数,缺点是强依赖数据库。这个缺点需要通过 driver 信息找确切的数据库类型,切换时间函数。时间字段信息则是通过 BaseMapper<E,ID>
获取泛型 E 指向的 Class,通过属性名匹配(没办法老代码只能匹配属性名)或注解匹配找到时间字段。SQL 里拼接时间字段是通过包装 SqlSource 通过 SqlSource#getBoundSql
替换最终 SQL 和当前时间函数。
mappedStatement#getId()
,id 的值对应类全路径,从这个类全路径获取类信息并确定 BaseMapper<E,ID>
E 指向的泛型/**
+ * 表的创建时间和更新时间会随着表的更新或插入行为进行赋值. 因为需要确定表中是否有创建时间或更新时间且确定时间字段名,所以需要使用的地方
+ * 的 mapper 继承 {@link BaseMapper}<br>
+ * 思路,从 BaseMapper 的泛型 T 获取实体类,从实体类里面解析出创建时间和更新时间字段对应的数据库字段名,这里创建时间和更新时间是通过名称
+ * 匹配的,大小写不论包含匹配.针对插入行为会增加创建时间和更新时间,针对更新行为会更新更新时间.<br>
+ * <lu>
+ * <li>创建时间,dCjsj、cjsj、dtCjsj、dCjrq、cjrq、dtCjrq、createTime、dCreateTime、dtCreateTime</li>
+ * <li>更新时间,dGxsj, gxsj, dtGxsj, dXgsj, xgsj, dtXgsj, dZhxgsj、zhxgsj、dtZhxgsj、updateTime、dUpdateTime、dtUpdateTime</li>
+ * </lu>
+ *
+ * @author liulili
+ * @date 2024/1/25 11:24
+ */
+@Slf4j
+@Component
+@Intercepts(@Signature(type = Executor.class, method="update", args={MappedStatement.class, Object.class}))
+public class AutofillCreateOrUpdateTimeInterceptor implements Interceptor {
+
+ private final String[] CJSJ_COLUMN_NAMES = new String[] {"cjsj", "dCjsj", "dtCjsj", "cjrq", "dCjrq", "dtCjrq", "createTime", "dCreateTime", "dtCreateTime"};
+
+ private final String[] GXSJ_COLUMN_NAMES = new String[] {"gxsj", "dGxsj", "dtGxsj", "xgsj", "dXgsj", "dtXgsj", "zhxgsj", "dZhxgsj", "dtZhxgsj", "updateTime", "dUpdateTime", "dtUpdateTime"};
+ @Override
+ public Object intercept(Invocation invocation) throws Throwable {
+ Object[] args = invocation.getArgs();
+ MappedStatement mappedStatement = (MappedStatement) args[0];
+ StatementType statement = mappedStatement.getStatementType();
+ if (statement == StatementType.CALLABLE) {
+ log.debug("【自动填充创建或修改时间】不支持在存储过程类型业务");
+ return invocation.proceed();
+ }
+ SqlCommandType command = mappedStatement.getSqlCommandType();
+ String id = mappedStatement.getId();
+ String className = StringUtils.substring(id, 0, id.lastIndexOf("."));
+ Class mapperClazz = null;
+ try {
+ mapperClazz = Class.forName(className);
+ } catch (Throwable e) {
+ if (log.isDebugEnabled()) {
+ log.debug("className[{}]不是Class无法继续【自动填充创建或修改时间】的工作", className, e);
+ } else if (log.isInfoEnabled()) {
+ log.info("className[{}]不是Class无法继续【自动填充创建或修改时间】的工作", className);
+ }
+ return invocation.proceed();
+ }
+ Class entityClazz = findEntityClazz(mapperClazz);
+ if (Objects.isNull(entityClazz)) {
+ log.debug("class[{}]非接口/未继承BaseMapper接口", entityClazz);
+ return invocation.proceed();
+ }
+ CUTimeDTO cuTimeDTO = findCreateAndUpdateTimeColumn(entityClazz);
+ if (StringUtils.isBlank(cuTimeDTO.getUpdateTimeColumnName()) && StringUtils.isBlank(cuTimeDTO.getCreateTimeColumnName())) {
+ log.debug("class[{}]无匹配的创建时间和更新时间字段, 请参考 AutofillCreateOrUpdateTimeInterceptor#CJSJ_COLUMN_NAMES 和 AutofillCreateOrUpdateTimeInterceptor#GXSJ_COLUMN_NAMES", entityClazz);
+ return invocation.proceed();
+ }
+ if (command == SqlCommandType.INSERT) {
+ autofillInsert(mappedStatement, args[1], entityClazz, cuTimeDTO);
+ } else if (command == SqlCommandType.UPDATE) {
+ autofillUpdate(mappedStatement, args[1], entityClazz, cuTimeDTO);
+ }
+ return invocation.proceed();
+ }
+
+ private void autofillUpdate(MappedStatement mappedStatement, Object param, Class entityClazz, CUTimeDTO cuTimeDTO) {
+ String updateTimeColumn = cuTimeDTO.getUpdateTimeColumnName();
+ if (StringUtils.isBlank(updateTimeColumn)) {
+ return;
+ }
+ BoundSql boundSql = mappedStatement.getBoundSql(param);
+ String sql = boundSql.getSql();
+ if (StringUtils.containsIgnoreCase(sql, updateTimeColumn)) {
+ autofillUTime(param, cuTimeDTO, entityClazz);
+ return;
+ }
+ SqlSource sqlSource = mappedStatement.getSqlSource();
+ SqlSource decoderSqlSource = new AutoFillUTimeUpdateSqlSource(sqlSource, updateTimeColumn, "now()");
+ BeanUtil.setProperty(mappedStatement, "sqlSource", decoderSqlSource);
+ }
+
+ private void autofillInsert(MappedStatement mappedStatement, Object param, Class entityClazz, CUTimeDTO cuTimeDTO) {
+ String createColumnName = cuTimeDTO.getCreateTimeColumnName();
+ String updateColumnName = cuTimeDTO.getUpdateTimeColumnName();
+ BoundSql boundSql = mappedStatement.getBoundSql(param);
+ String sql = boundSql.getSql();
+ List<String> addColumn = new ArrayList<>(2);
+ if (StringUtils.isNotBlank(createColumnName) && !StringUtils.containsIgnoreCase(sql, createColumnName)) {
+ addColumn.add(createColumnName);
+ }
+ if (StringUtils.isNotBlank(updateColumnName) && !StringUtils.containsIgnoreCase(sql, updateColumnName)) {
+ addColumn.add(updateColumnName);
+ }
+ if (CollectionUtils.isEmpty(addColumn)) {
+ autofillCUTime(param, cuTimeDTO, entityClazz);
+ return;
+ }
+ String columnName = addColumn.stream().collect(Collectors.joining(","));
+ String columnValue = addColumn.stream().map(column -> "now()").collect(Collectors.joining(","));
+ SqlSource sqlSource = mappedStatement.getSqlSource();
+ SqlSource decoderSqlSource = new AutoFillCUTimeInsertSqlSource(sqlSource, columnName, columnValue);
+ BeanUtil.setProperty(mappedStatement, "sqlSource", decoderSqlSource);
+ }
+
+ private void autofillCUTime(Object param, CUTimeDTO cuTimeDTO, Class entityClazz) {
+ if (Objects.isNull(param)) {
+ return;
+ }
+ if (param.getClass().isAssignableFrom(entityClazz)) {
+ Optional.ofNullable(cuTimeDTO.getCreateTimePropertyName())
+ .ifPresent(createColumnName -> BeanUtil.setProperty(param, createColumnName, Calendar.getInstance().getTime()));
+ Optional.ofNullable(cuTimeDTO.getUpdateTimePropertyName())
+ .ifPresent(updateColumnName -> BeanUtil.setProperty(param, updateColumnName, Calendar.getInstance().getTime()));
+ return;
+ }
+ if (param instanceof Collection) {
+ Collection paramColl = (Collection) param;
+ paramColl.stream().forEach(sparam -> autofillCUTime(sparam, cuTimeDTO, entityClazz));
+ return;
+ }
+ if (param instanceof Map) {
+ Map paramMap = (Map) param;
+ Set<Map.Entry> entries = paramMap.entrySet();
+ entries.stream().forEach(entry -> autofillCUTime(entry.getValue(), cuTimeDTO, entityClazz));
+ return;
+ }
+ if (param.getClass().isPrimitive() || param.getClass().isEnum()) {
+ return;
+ }
+ if (param.getClass().isArray()) {
+ Object[] paramArr = (Object[]) param;
+ Arrays.stream(paramArr).forEach(obj -> autofillCUTime(obj, cuTimeDTO, entityClazz));
+ return;
+ }
+ Field[] fields = param.getClass().getDeclaredFields();
+ Arrays.stream(fields).forEach(field -> {
+ Object property = null;
+ try {
+ property = BeanUtil.getProperty(param, field.getName());
+ } catch (Exception e) {
+ log.debug("param[{}]属性【{}】获取属性值失败", param, field.getName(), e);
+ }
+ autofillCUTime(property, cuTimeDTO, entityClazz);
+ });
+ }
+
+ private void autofillUTime(Object param, CUTimeDTO cuTimeDTO, Class entityClazz) {
+ if (Objects.isNull(param) || param.getClass().isPrimitive() || param.getClass().isEnum()) {
+ return;
+ }
+ if (param.getClass().isAssignableFrom(entityClazz)) {
+ Optional.ofNullable(cuTimeDTO.getUpdateTimePropertyName()).ifPresent(updateColumnName -> BeanUtil.setProperty(param, updateColumnName, Calendar.getInstance().getTime()));
+ return;
+ }
+ if (param instanceof Collection) {
+ Collection paramColl = (Collection) param;
+ paramColl.stream().forEach(sparam -> autofillUTime(sparam, cuTimeDTO, entityClazz));
+ return;
+ }
+ if (param instanceof Map) {
+ Map paramMap = (Map) param;
+ Set<Map.Entry> entries = paramMap.entrySet();
+ entries.stream().forEach(entry -> autofillUTime(entry.getValue(), cuTimeDTO, entityClazz));
+ return;
+ }
+ if (param.getClass().isArray()) {
+ Object[] paramArr = (Object[]) param;
+ Arrays.stream(paramArr).forEach(obj -> autofillUTime(obj, cuTimeDTO, entityClazz));
+ return;
+ }
+ Field[] fields = param.getClass().getDeclaredFields();
+ Arrays.stream(fields).forEach(field -> {
+ Object property = null;
+ try {
+ property = BeanUtil.getProperty(param, field.getName());
+ } catch (Exception e) {
+ log.debug("param[{}]属性【{}】获取属性值失败", param, field.getName(), e);
+ }
+ autofillUTime(property, cuTimeDTO, entityClazz);
+ });
+ }
+
+ private CUTimeDTO findCreateAndUpdateTimeColumn(Class entityClazz) {
+ Field[] fields = entityClazz.getDeclaredFields();
+ Map<String, Field> fieldMap = Arrays.stream(fields).collect(Collectors.toMap(Field::getName, field -> field));
+ Set<String> fieldKeys = fieldMap.keySet();
+ CUTimeDTO CUTimeDTO = new CUTimeDTO();
+ Optional<String> cjsjFieldNameOptional = fieldKeys.stream().filter(fieldKey -> Arrays.stream(CJSJ_COLUMN_NAMES)
+ .anyMatch(columnName -> StringUtils.equalsIgnoreCase(columnName, fieldKey))).findFirst();
+ cjsjFieldNameOptional.ifPresent(cjsjFieldName -> CUTimeDTO.setCreateTimePropertyName(cjsjFieldName));
+ CUTimeDTO.setCreateTimeColumnName(getColumnNameByColumnAnno(cjsjFieldNameOptional, fieldMap));
+ Optional<String> gxsjFieldNameOptional = fieldKeys.stream().filter(fieldKey -> Arrays.stream(GXSJ_COLUMN_NAMES)
+ .anyMatch(columnName -> StringUtils.equalsIgnoreCase(columnName, fieldKey))).findFirst();
+ gxsjFieldNameOptional.ifPresent(gxsjFieldName -> CUTimeDTO.setUpdateTimePropertyName(gxsjFieldName));
+ CUTimeDTO.setUpdateTimeColumnName(getColumnNameByColumnAnno(gxsjFieldNameOptional, fieldMap));
+ return CUTimeDTO;
+ }
+
+ private String getColumnNameByColumnAnno(Optional<String> fieldNameOptional, Map<String, Field> fieldMap) {
+ String columnName = null;
+ if (fieldNameOptional.isPresent()) {
+ String fieldName = fieldNameOptional.get();
+ Field field = fieldMap.get(fieldName);
+ Column column = field.getAnnotation(Column.class);
+ columnName = column.name();
+ }
+ return columnName;
+ }
+
+ private Class findEntityClazz(Class mapperClazz) {
+ if (!mapperClazz.isInterface()) {
+ return null;
+ }
+ Type[] interfaces = mapperClazz.getGenericInterfaces();
+ if (Objects.isNull(interfaces)) {
+ return null;
+ }
+ Optional<ParameterizedType> baseMapperTypeOptional = Arrays.stream(interfaces)
+ .filter(iface -> iface instanceof ParameterizedType)
+ .map(iface -> (ParameterizedType) iface)
+ .filter(iface -> ((Class) iface.getRawType()).isAssignableFrom(BaseMapper.class))
+ .findFirst();
+ if (!baseMapperTypeOptional.isPresent()) {
+ return null;
+ }
+ ParameterizedType baseMapperType = baseMapperTypeOptional.get();
+ return (Class) baseMapperType.getActualTypeArguments()[0];
+ }
+
+ @Override
+ public Object plugin(Object target) {
+ return Plugin.wrap(target, this);
+ }
+
+ @Override
+ public void setProperties(Properties properties) {
+ }
+
+}
+
+设计的 DTO 用于确定属性对应的创建时间字段属性和更新时间字段属性。
+@Getter
+@Setter
+@Accessors(chain = true)
+@NoArgsConstructor
+public class CUTimeDTO {
+
+ private String createTimePropertyName;
+
+ private String updateTimePropertyName;
+
+ private String createTimeColumnName;
+
+ private String updateTimeColumnName;
+}
+
+包装对应的 SqlSource 在获取最后的 SQL (SqlSource#getBoundSql
)中拼接创建和更新时间脚本。不在具体的 SqlSource 里面完成字段拼接加上预处理字段,是因为 mybatis 支持多种 SqlSource 包含 StaticSqlSource
、ProviderSqlSource
、RawSqlSource
、DynamicSqlSource
,且他们可以组合出现,可见还是有一定的复杂度的。所以才选择用包装类完成字段填充。这种是不建议自动填充那种包含不同值的字段的,因为这样会让预处理 SQL 没有发挥作用。
@AllArgsConstructor
+public class AutoFillUTimeUpdateSqlSource implements SqlSource {
+
+ private SqlSource sqlSource;
+
+ private String columnName;
+
+ private String columnValue;
+
+
+ @Override
+ public BoundSql getBoundSql(Object parameterObject) {
+ BoundSql boundSql = this.sqlSource.getBoundSql(parameterObject);
+ replaceBoundSql(boundSql);
+ return boundSql;
+ }
+ private void replaceBoundSql(BoundSql boundSql) {
+ String sql = boundSql.getSql();
+ String newSql = StringUtils.replaceIgnoreCase(sql, "set ", "set " + columnName + "=" + columnValue + ",");
+ BeanUtil.setProperty(boundSql, "sql", newSql);
+ }
+}
+
+@AllArgsConstructor
+public class AutoFillCUTimeInsertSqlSource implements SqlSource {
+
+ private SqlSource sqlSource;
+
+ private String columnName;
+
+ private String columnValue;
+
+ @Override
+ public BoundSql getBoundSql(Object parameterObject) {
+ BoundSql boundSql = this.sqlSource.getBoundSql(parameterObject);
+ replaceBoundSql(boundSql);
+ return boundSql;
+ }
+
+ private void replaceBoundSql(BoundSql boundSql) {
+ String sql = boundSql.getSql();
+ Pattern pattern = Pattern.compile("\\(");
+ Matcher matcher = pattern.matcher(sql);
+ String newSql = sql;
+ if (matcher.find()) {
+ int index = matcher.start();
+ newSql = sql.substring(0, index + 1) + columnName + "," + sql.substring(index + 1);
+ }
+
+ int index = StringUtils.indexOfIgnoreCase(newSql, "values");
+ int index1 = index + "values".length();
+ while(index1 < newSql.length() && index1 > 0) {
+ index1 = index1 + 1;
+ char next = newSql.charAt(index1);
+ if (next == ' ' || next == '\\' || next == 'n') {
+ continue;
+ }
+ if (next == '(') {
+ break;
+ }
+ index1 = StringUtils.indexOfIgnoreCase(newSql, "values", index1);
+ }
+ if (index1 == -1) {
+ return;
+ }
+ String replace = StringUtils.substring(newSql, index, index1 + 1);
+ newSql = newSql.replace(replace, replace + columnValue + ",");
+ BeanUtil.setProperty(boundSql, "sql", newSql);
+ }
+}
+
+为什么不让项目直接集成 mybatis-plus 修改 pojo 就能快速解决问题,不用这么复杂。当然我统一这个思路,但这个思路适合于 pojo 少,且使用 @Table
、@Column
等数据库型的注解的项目。否则,在大项目中还是工作量及风险还是比较高。但这不影响我推荐使用 mybatis-plus。
mybatis 提供 Interceptor 接口以插件方式提供扩展能力。互联网上大都是对数据表映射类对象中关于时间属性设置当前时间的解决方案。但这种方法无法解决 mapper.xml 写更新 SQL 或 @XXXProvider 拼 SQL 的方式插入或更新数据表。但是依托于数据表映射类本身没有问题,因为需要知道创建时间和更新时间对应的数据库字段信息,这是光拦截到 SQL 而无法判断时间相关的字段是否存在并赋值。
如果你项目中使用了 mybatis-plus 组件,恭喜你做这个决定你足够明智。 mybatis-plus 提供 MetaObjectHandler 抽象类实现公共字段自动写入能力。其大体思路是针对 @XXXProvider 拼 SQL 时将实体中标记需要自动填充的字段拼入 SQL 中,通过 metaObjectHandler 对实体属性字段填充相应值,最后带有自动填充字段的 PrepareStatement SQL 插入/更新数据表数据。
但,项目上使用自写 BaseMapper<E,ID>
接口和 @XXXProvider 注解实现 BaseMapperSqlSourceBuilder 类完成 SQL 拼接。但未提供对公用字段自动写入能力。
Interceptor 拦截的位置是执行 SQL 之前,也就是 @Signature(type = Executor.class, method="update", args={MappedStatement.class, Object.class})
,在 SQL 里拼接时间的字段和字段值。字段值可以直接设置的 now()
数据库函数,缺点是强依赖数据库。这个缺点需要通过 driver 信息找确切的数据库类型,切换时间函数。时间字段信息则是通过 BaseMapper<E,ID>
获取泛型 E 指向的 Class,通过属性名匹配(没办法老代码只能匹配属性名)或注解匹配找到时间字段。SQL 里拼接时间字段是通过包装 SqlSource 通过 SqlSource#getBoundSql
替换最终 SQL 和当前时间函数。
mappedStatement#getId()
,id 的值对应类全路径,从这个类全路径获取类信息并确定 BaseMapper<E,ID>
E 指向的泛型/** * 表的创建时间和更新时间会随着表的更新或插入行为进行赋值. 因为需要确定表中是否有创建时间或更新时间且确定时间字段名,所以需要使用的地方 * 的 mapper 继承 {@link BaseMapper}<br> * 思路,从 BaseMapper 的泛型 T 获取实体类,从实体类里面解析出创建时间和更新时间字段对应的数据库字段名,这里创建时间和更新时间是通过名称 * 匹配的,大小写不论包含匹配.针对插入行为会增加创建时间和更新时间,针对更新行为会更新更新时间.<br> * <lu> * <li>创建时间,dCjsj、cjsj、dtCjsj、dCjrq、cjrq、dtCjrq、createTime、dCreateTime、dtCreateTime</li> * <li>更新时间,dGxsj, gxsj, dtGxsj, dXgsj, xgsj, dtXgsj, dZhxgsj、zhxgsj、dtZhxgsj、updateTime、dUpdateTime、dtUpdateTime</li> * </lu> * * @author liulili * @date 2024/1/25 11:24 */@Slf4j@Component@Intercepts(@Signature(type = Executor.class, method="update", args={MappedStatement.class, Object.class}))public class AutofillCreateOrUpdateTimeInterceptor implements Interceptor { private final String[] CJSJ_COLUMN_NAMES = new String[] {"cjsj", "dCjsj", "dtCjsj", "cjrq", "dCjrq", "dtCjrq", "createTime", "dCreateTime", "dtCreateTime"}; private final String[] GXSJ_COLUMN_NAMES = new String[] {"gxsj", "dGxsj", "dtGxsj", "xgsj", "dXgsj", "dtXgsj", "zhxgsj", "dZhxgsj", "dtZhxgsj", "updateTime", "dUpdateTime", "dtUpdateTime"}; @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement mappedStatement = (MappedStatement) args[0]; StatementType statement = mappedStatement.getStatementType(); if (statement == StatementType.CALLABLE) { log.debug("【自动填充创建或修改时间】不支持在存储过程类型业务"); return invocation.proceed(); } SqlCommandType command = mappedStatement.getSqlCommandType(); String id = mappedStatement.getId(); String className = StringUtils.substring(id, 0, id.lastIndexOf(".")); Class mapperClazz = null; try { mapperClazz = Class.forName(className); } catch (Throwable e) { if (log.isDebugEnabled()) { log.debug("className[{}]不是Class无法继续【自动填充创建或修改时间】的工作", className, e); } else if (log.isInfoEnabled()) { log.info("className[{}]不是Class无法继续【自动填充创建或修改时间】的工作", className); } return invocation.proceed(); } Class entityClazz = findEntityClazz(mapperClazz); if (Objects.isNull(entityClazz)) { log.debug("class[{}]非接口/未继承BaseMapper接口", entityClazz); return invocation.proceed(); } CUTimeDTO cuTimeDTO = findCreateAndUpdateTimeColumn(entityClazz); if (StringUtils.isBlank(cuTimeDTO.getUpdateTimeColumnName()) && StringUtils.isBlank(cuTimeDTO.getCreateTimeColumnName())) { log.debug("class[{}]无匹配的创建时间和更新时间字段, 请参考 AutofillCreateOrUpdateTimeInterceptor#CJSJ_COLUMN_NAMES 和 AutofillCreateOrUpdateTimeInterceptor#GXSJ_COLUMN_NAMES", entityClazz); return invocation.proceed(); } if (command == SqlCommandType.INSERT) { autofillInsert(mappedStatement, args[1], entityClazz, cuTimeDTO); } else if (command == SqlCommandType.UPDATE) { autofillUpdate(mappedStatement, args[1], entityClazz, cuTimeDTO); } return invocation.proceed(); } private void autofillUpdate(MappedStatement mappedStatement, Object param, Class entityClazz, CUTimeDTO cuTimeDTO) { String updateTimeColumn = cuTimeDTO.getUpdateTimeColumnName(); if (StringUtils.isBlank(updateTimeColumn)) { return; } BoundSql boundSql = mappedStatement.getBoundSql(param); String sql = boundSql.getSql(); if (StringUtils.containsIgnoreCase(sql, updateTimeColumn)) { autofillUTime(param, cuTimeDTO, entityClazz); return; } SqlSource sqlSource = mappedStatement.getSqlSource(); SqlSource decoderSqlSource = new AutoFillUTimeUpdateSqlSource(sqlSource, updateTimeColumn, "now()"); BeanUtil.setProperty(mappedStatement, "sqlSource", decoderSqlSource); } private void autofillInsert(MappedStatement mappedStatement, Object param, Class entityClazz, CUTimeDTO cuTimeDTO) { String createColumnName = cuTimeDTO.getCreateTimeColumnName(); String updateColumnName = cuTimeDTO.getUpdateTimeColumnName(); BoundSql boundSql = mappedStatement.getBoundSql(param); String sql = boundSql.getSql(); List<String> addColumn = new ArrayList<>(2); if (StringUtils.isNotBlank(createColumnName) && !StringUtils.containsIgnoreCase(sql, createColumnName)) { addColumn.add(createColumnName); } if (StringUtils.isNotBlank(updateColumnName) && !StringUtils.containsIgnoreCase(sql, updateColumnName)) { addColumn.add(updateColumnName); } if (CollectionUtils.isEmpty(addColumn)) { autofillCUTime(param, cuTimeDTO, entityClazz); return; } String columnName = addColumn.stream().collect(Collectors.joining(",")); String columnValue = addColumn.stream().map(column -> "now()").collect(Collectors.joining(",")); SqlSource sqlSource = mappedStatement.getSqlSource(); SqlSource decoderSqlSource = new AutoFillCUTimeInsertSqlSource(sqlSource, columnName, columnValue); BeanUtil.setProperty(mappedStatement, "sqlSource", decoderSqlSource); } private void autofillCUTime(Object param, CUTimeDTO cuTimeDTO, Class entityClazz) { if (Objects.isNull(param)) { return; } if (param.getClass().isAssignableFrom(entityClazz)) { Optional.ofNullable(cuTimeDTO.getCreateTimePropertyName()) .ifPresent(createColumnName -> BeanUtil.setProperty(param, createColumnName, Calendar.getInstance().getTime())); Optional.ofNullable(cuTimeDTO.getUpdateTimePropertyName()) .ifPresent(updateColumnName -> BeanUtil.setProperty(param, updateColumnName, Calendar.getInstance().getTime())); return; } if (param instanceof Collection) { Collection paramColl = (Collection) param; paramColl.stream().forEach(sparam -> autofillCUTime(sparam, cuTimeDTO, entityClazz)); return; } if (param instanceof Map) { Map paramMap = (Map) param; Set<Map.Entry> entries = paramMap.entrySet(); entries.stream().forEach(entry -> autofillCUTime(entry.getValue(), cuTimeDTO, entityClazz)); return; } if (param.getClass().isPrimitive() || param.getClass().isEnum()) { return; } if (param.getClass().isArray()) { Object[] paramArr = (Object[]) param; Arrays.stream(paramArr).forEach(obj -> autofillCUTime(obj, cuTimeDTO, entityClazz)); return; } Field[] fields = param.getClass().getDeclaredFields(); Arrays.stream(fields).forEach(field -> { Object property = null; try { property = BeanUtil.getProperty(param, field.getName()); } catch (Exception e) { log.debug("param[{}]属性【{}】获取属性值失败", param, field.getName(), e); } autofillCUTime(property, cuTimeDTO, entityClazz); }); } private void autofillUTime(Object param, CUTimeDTO cuTimeDTO, Class entityClazz) { if (Objects.isNull(param) || param.getClass().isPrimitive() || param.getClass().isEnum()) { return; } if (param.getClass().isAssignableFrom(entityClazz)) { Optional.ofNullable(cuTimeDTO.getUpdateTimePropertyName()).ifPresent(updateColumnName -> BeanUtil.setProperty(param, updateColumnName, Calendar.getInstance().getTime())); return; } if (param instanceof Collection) { Collection paramColl = (Collection) param; paramColl.stream().forEach(sparam -> autofillUTime(sparam, cuTimeDTO, entityClazz)); return; } if (param instanceof Map) { Map paramMap = (Map) param; Set<Map.Entry> entries = paramMap.entrySet(); entries.stream().forEach(entry -> autofillUTime(entry.getValue(), cuTimeDTO, entityClazz)); return; } if (param.getClass().isArray()) { Object[] paramArr = (Object[]) param; Arrays.stream(paramArr).forEach(obj -> autofillUTime(obj, cuTimeDTO, entityClazz)); return; } Field[] fields = param.getClass().getDeclaredFields(); Arrays.stream(fields).forEach(field -> { Object property = null; try { property = BeanUtil.getProperty(param, field.getName()); } catch (Exception e) { log.debug("param[{}]属性【{}】获取属性值失败", param, field.getName(), e); } autofillUTime(property, cuTimeDTO, entityClazz); }); } private CUTimeDTO findCreateAndUpdateTimeColumn(Class entityClazz) { Field[] fields = entityClazz.getDeclaredFields(); Map<String, Field> fieldMap = Arrays.stream(fields).collect(Collectors.toMap(Field::getName, field -> field)); Set<String> fieldKeys = fieldMap.keySet(); CUTimeDTO CUTimeDTO = new CUTimeDTO(); Optional<String> cjsjFieldNameOptional = fieldKeys.stream().filter(fieldKey -> Arrays.stream(CJSJ_COLUMN_NAMES) .anyMatch(columnName -> StringUtils.equalsIgnoreCase(columnName, fieldKey))).findFirst(); cjsjFieldNameOptional.ifPresent(cjsjFieldName -> CUTimeDTO.setCreateTimePropertyName(cjsjFieldName)); CUTimeDTO.setCreateTimeColumnName(getColumnNameByColumnAnno(cjsjFieldNameOptional, fieldMap)); Optional<String> gxsjFieldNameOptional = fieldKeys.stream().filter(fieldKey -> Arrays.stream(GXSJ_COLUMN_NAMES) .anyMatch(columnName -> StringUtils.equalsIgnoreCase(columnName, fieldKey))).findFirst(); gxsjFieldNameOptional.ifPresent(gxsjFieldName -> CUTimeDTO.setUpdateTimePropertyName(gxsjFieldName)); CUTimeDTO.setUpdateTimeColumnName(getColumnNameByColumnAnno(gxsjFieldNameOptional, fieldMap)); return CUTimeDTO; } private String getColumnNameByColumnAnno(Optional<String> fieldNameOptional, Map<String, Field> fieldMap) { String columnName = null; if (fieldNameOptional.isPresent()) { String fieldName = fieldNameOptional.get(); Field field = fieldMap.get(fieldName); Column column = field.getAnnotation(Column.class); columnName = column.name(); } return columnName; } private Class findEntityClazz(Class mapperClazz) { if (!mapperClazz.isInterface()) { return null; } Type[] interfaces = mapperClazz.getGenericInterfaces(); if (Objects.isNull(interfaces)) { return null; } Optional<ParameterizedType> baseMapperTypeOptional = Arrays.stream(interfaces) .filter(iface -> iface instanceof ParameterizedType) .map(iface -> (ParameterizedType) iface) .filter(iface -> ((Class) iface.getRawType()).isAssignableFrom(BaseMapper.class)) .findFirst(); if (!baseMapperTypeOptional.isPresent()) { return null; } ParameterizedType baseMapperType = baseMapperTypeOptional.get(); return (Class) baseMapperType.getActualTypeArguments()[0]; } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { }}
设计的 DTO 用于确定属性对应的创建时间字段属性和更新时间字段属性。
@Getter@Setter@Accessors(chain = true)@NoArgsConstructorpublic class CUTimeDTO { private String createTimePropertyName; private String updateTimePropertyName; private String createTimeColumnName; private String updateTimeColumnName;}
包装对应的 SqlSource 在获取最后的 SQL (SqlSource#getBoundSql
)中拼接创建和更新时间脚本。不在具体的 SqlSource 里面完成字段拼接加上预处理字段,是因为 mybatis 支持多种 SqlSource 包含 StaticSqlSource
、ProviderSqlSource
、RawSqlSource
、DynamicSqlSource
,且他们可以组合出现,可见还是有一定的复杂度的。所以才选择用包装类完成字段填充。这种是不建议自动填充那种包含不同值的字段的,因为这样会让预处理 SQL 没有发挥作用。
@AllArgsConstructorpublic class AutoFillUTimeUpdateSqlSource implements SqlSource { private SqlSource sqlSource; private String columnName; private String columnValue; @Override public BoundSql getBoundSql(Object parameterObject) { BoundSql boundSql = this.sqlSource.getBoundSql(parameterObject); replaceBoundSql(boundSql); return boundSql; } private void replaceBoundSql(BoundSql boundSql) { String sql = boundSql.getSql(); String newSql = StringUtils.replaceIgnoreCase(sql, "set ", "set " + columnName + "=" + columnValue + ","); BeanUtil.setProperty(boundSql, "sql", newSql); }}
@AllArgsConstructorpublic class AutoFillCUTimeInsertSqlSource implements SqlSource { private SqlSource sqlSource; private String columnName; private String columnValue; @Override public BoundSql getBoundSql(Object parameterObject) { BoundSql boundSql = this.sqlSource.getBoundSql(parameterObject); replaceBoundSql(boundSql); return boundSql; } private void replaceBoundSql(BoundSql boundSql) { String sql = boundSql.getSql(); Pattern pattern = Pattern.compile("\\("); Matcher matcher = pattern.matcher(sql); String newSql = sql; if (matcher.find()) { int index = matcher.start(); newSql = sql.substring(0, index + 1) + columnName + "," + sql.substring(index + 1); } int index = StringUtils.indexOfIgnoreCase(newSql, "values"); int index1 = index + "values".length(); while(index1 < newSql.length() && index1 > 0) { index1 = index1 + 1; char next = newSql.charAt(index1); if (next == ' ' || next == '\\' || next == 'n') { continue; } if (next == '(') { break; } index1 = StringUtils.indexOfIgnoreCase(newSql, "values", index1); } if (index1 == -1) { return; } String replace = StringUtils.substring(newSql, index, index1 + 1); newSql = newSql.replace(replace, replace + columnValue + ","); BeanUtil.setProperty(boundSql, "sql", newSql); }}
为什么不让项目直接集成 mybatis-plus 修改 pojo 就能快速解决问题,不用这么复杂。当然我统一这个思路,但这个思路适合于 pojo 少,且使用 @Table
、@Column
等数据库型的注解的项目。否则,在大项目中还是工作量及风险还是比较高。但这不影响我推荐使用 mybatis-plus。
项目上一直使用 CAS + 应用 session + nginx IP hash 组合方式实现伪集群部署。但这种方式也有一定的缺点,请求不够平均,应用使用异步处理方式,还必须将结果返回给发起的应用,否则前端无法拿到结果。这些都是 IP 绑定固定应用导致的。2023 年为止,在网上搜索到主要解决方式有两个: 1. session 共享(需要依赖 redis)2. 签发 JWT 授权。不想引入 redis,所以选择签发 JWT 授权。技术选型上使用 spring-scurity
+ CAS
+ oauth2
组合方式。
使用 spring-security
+ CAS
+ Oauth2
组合方式,spring-boot 中提供了很多 starter 可以使用。以下使用 maven 仓库管理为例
<!-- spring-security 基础 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><!-- CAS 相关 --><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-cas</artifactId></dependency><!-- security-jwt相关 --><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-jose</artifactId></dependency><!-- JWT 签发 --><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-resource-server</artifactId></dependency>
spring-boot-starter-security
,是 spring-security
的基础包,主要包含 spring-security-config
和 spring-security-web
spring-security-cas
,是 spring-security
的 CAS 相关,包含 CAS validation 及验证通过或不通过的处理spring-security-oauth2-jose
,是 spring-security
的 token 验证相关spring-security-oauth2-resource-server
,是 spring-security
的 token 签发及 web token 验证。集成之前,建议先看看 spring-security 的架构,😸 很容易理解。当然集成一个组件,需要有集成思路或步骤,
spring-security 目前有支持的集中方式,
AbstractPreAuthenticatedProcessingFilter
)其中假设委托人已经由外部系统进行了身份验证,实现类完成简单的校验CasAuthenticationFilter
)UsernamePasswordAuthenticationFilter
)应用本地数据库验证,非独立验证服务BearerTokenAuthenticationFilter
)OAuth2 JWT 签发的 token 应用服务验证AbstractAuthenticationProcessingFilter
)实现此类来完成定制验证,比如约定好请求授权的验证方式。当然,此处只是选择 Filter 并非真正验证的位置。所以,spring-security 支持的 CAS 或 UsernamePassword 验证方式也是把你的验证器默认设置了。
principal,被验证主体。credentials,被验证证书,也可以是密码。
如果是使用 CAS 或 UsernamePassword 验证,可以跳过这里,因为组装待验证元素,有默认实现。就拿 CAS 来比如
// CASAuthenticationFilterpublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)throws AuthenticationException, IOException { //,,, UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated( username, password); //,,,}// UsernamePasswordAuthenticationTokenpublic class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken { public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {//,,,,this.principal = principal;this.credentials = credentials;setAuthenticated(false);}}
如果是使用 AbstractPreAuthenticatedProcessingFilter
时,则需要覆盖方法 getPreAuthenticatedPrincipal()
和 getPreAuthenticatedPrincipal()
来确定主体和证书。
🎃 如果是使用 BearerTokenAuthenticationFilter
时,默认是从请求中获取 Authorization header 值。我在实现时使用 cookie 方式,只需要实现 BearerTokenResolver
接口。
🎃 如果是使用 AbstractAuthenticationProcessingFilter
时,就自己在 attemptAuthentication()
方法中实现。
如果是使用 CAS 或 UsernamePassword 验证,可以跳过这里,因为组装令牌的事情,Filter 里已经实现。就拿 CAS 来比如
// CASAuthenticationFilterpublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)throws AuthenticationException, IOException { //...boolean serviceTicketRequest = serviceTicketRequest(request, response);String username = serviceTicketRequest ? CAS_STATEFUL_IDENTIFIER : CAS_STATELESS_IDENTIFIER;String password = obtainArtifact(request);//...UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated( username, password);//...}
🔆 在令牌未被验证之前,令牌的初始化必须指定令牌并未完成验证。即 authenticated 属性为 false 。
🎃 如果是其他验证方式,则需要自己组装校验令牌,继承 AbstractAuthenticationToken
类。
如果是使用 CAS 验证,需要选择一下 AbstractCasProtocolUrlBasedTicketValidator
验证器。当然你也可以实现此 abstract 类,完成 ticket 验证。验证器的调用方是 CasAuthenticationProvider
。为什么在此介绍 CasAuthenticationProvider
?🎃 因为基本上所有的验证都一定是实现 AuthenticationProvider
接口。🎃 用户权限信息的组装,是实现 AuthenticationUserDetailsService<T extends Authentication>
接口,从缓存或是数据库中查询用户或权限点信息组装 UserDetails。
🔆 验证通过后,可以通过在 Controller 方法上使用 @AuthenticationPrincipal
注解,或请求线程里 SecurityContextHolder.getContext().getAuthentication()
来获取用户授权信息。@AuthenticationPrincipal
注解对应 UserDetails 对象,@CurrentSecurityContext
注解对应 SecurityContext 对象。
验证成功或失败后的处理方式,一般有几种,重定向首页或登录页面,注册或撤销 JWT token,放行后面的 filter 或 Controller。🎃 而 JWT token 签发注销的功能,只需实现 AuthenticationSuccessHandler
和 AuthenticationFailureHandler
两个接口。
🎃 配置组装通过继承 WebSecurityConfigurerAdapter
类,覆盖 init(WebSecurity builder)
方法完成 Filter、provider、handler 等注入。此处不多说,看代码应该就懂了。
以下以 CAS + OAuth2 组合方式的完整代码片段
public class TuscCasTicketValidator extends AbstractCasProtocolUrlBasedTicketValidator { public TuscCasTicketValidator(final String casServerUrlPrefix) { // 设定 CAS server super(casServerUrlPrefix); } protected String getUrlSuffix() { // 验证路径 return "validate"; } protected Assertion parseResponseFromServer(final String response) throws TicketValidationException { // 判定验证通过成果及结果反馈 }}
// bearer 验证需要public class CookieBearerTokenResolver implements BearerTokenResolver { private static final String COOKIE_NAME_BEARER = "bearer"; @Override public String resolve(HttpServletRequest request) { if (request.getCookies() == null) { return null; } return Arrays.stream(request.getCookies()) .filter(cookie -> StringUtils.equalsAnyIgnoreCase(cookie.getName(), COOKIE_NAME_BEARER)) .map(Cookie::getValue).findFirst().orElse(null); }}
public class MakeTokenHandler extends FilterAuthSuccessHandler { public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { //... Jwt jwt = jwtEncoder.encode( JwtEncoderParameters.from(jwsHeader, jwtClaimsSetBuilder.build())); String token = jwt.getTokenValue();Cookie cookie = new Cookie("bearer", token);cookie.setPath("/");cookie.setMaxAge(cookieTimeoutSecond);cookie.setHttpOnly(false);response.addCookie(cookie); }}public class DefaultAuthFailHandler implements AuthenticationFailureHandler, LogoutSuccessHandler {public void onAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { log.error("", exception); if (exception instanceof InvalidBearerTokenException) { Cookie cookie = new Cookie("bearer", null); cookie.setMaxAge(0); response.addCookie(cookie); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("text/plain;charset=utf-8"); try (PrintWriter writer = response.getWriter()) { writer.write("登录已过期或被推出,需要重新登录验证!"); } return; } // 记住登出或访问前的地址 response.sendRedirect("登录页面地址"); }}
// 重新签发 tokenpublic class JwtRenewFilter extends OncePerRequestFilter {protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //... String bearerToken = bearerTokenResolver.resolve(request); Jwt jwt = jwtDecoder.decode(bearerToken); Instant expiresAt = jwt.getExpiresAt(); if (expiresAt.isBefore(Instant.now().plusSeconds(60))) { String ticket = jwt.getClaimAsString("st"); try { casTicketValidator.validate(ticket, serviceUrl); renewJwt(jwt, response, jwtEncoder); } catch (TicketValidationException e) { //... } } }}
@Configuration@EnableWebSecurity@AutoConfigureAfter(LoginBaseConfiguration.class)public class LoginConfiguration extends WebSecurityConfigurerAdapter { @Resource private HttpSecurity httpSecurity; @Resource private AuthenticationManager authenticationManager; @Resource private MakeTokenHandler makeTokenHandler @Resource private DefaultAuthFailHandler defaultAuthFailHandler; @Resource private AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> webAuthenticationDetailsSource; @Value("${xxxx}") private String applicationServerUrl; @Value("${xxxx}") private String CASServerUrl; // 12h @Value("${login.jwt.cookieTimeoutSecond:43200}") private int cookieTimeoutSecond; @Value("${login.jwt.casDurationSecond:600}") private int casDurationSecond; @Override public void init(WebSecurity builder) throws Exception { BearerTokenAuthenticationFilter bearerTokenFilter = new BearerTokenAuthenticationFilter(authenticationManager); bearerTokenFilter.setAuthenticationDetailsSource( dzdaWebAuthenticationDetailsSource); BearerTokenResolver bearerTokenResolver = new CookieBearerTokenResolver(); bearerTokenFilter.setBearerTokenResolver(makeTokenHandler); bearerTokenFilter.setAuthenticationFailureHandler(defaultAuthFailHandler); AbstractAuthenticationProcessingFilter casAuthenticationFilter = new CasAuthenticationFilter(); casAuthenticationFilter.setAuthenticationFailureHandler(defaultAuthFailHandler); casAuthenticationFilter.setAuthenticationManager(authenticationManager); casAuthenticationFilter.setFilterProcessesUrl("/login/cas"); casAuthenticationFilter.setAuthenticationDetailsSource( dzdaWebAuthenticationDetailsSource); casAuthenticationFilter.setAuthenticationSuccessHandler( dzdaCasAuthenticationSuccessHandler); JwtRenewFilter jwtRenewFilter = new JwtRenewFilter(). setBearerTokenResolver(bearerTokenResolver) .setCasTicketValidator(new TuscCasTicketValidator(casServerUrl)) .setServiceUrl(applicationServerUrl) .setCookieTimeoutSecond(cookieTimeoutSecond) .setCasDurationSecond(casDurationSecond) .setLocalDurationSecond(localDurationSecond); httpSecurity.authenticationManager(authenticationManager) .addFilterBefore(casAuthenticationFilter, X509AuthenticationFilter.class) .addFilterBefore(jwtRenewFilter, BearerTokenAuthenticationFilter.class) .addFilterBefore(bearerTokenFilter, X509AuthenticationFilter.class) .authorizeHttpRequests().anyRequest().authenticated() .and().anonymous().disable() .logout() .deleteCookies("bearer", "JESSIONID") .invalidateHttpSession(true) .logoutUrl("logoutroute") .logoutSuccessHandler(new DefaultAuthFailHandler( applicationServerUrl, casServerUrl)) .and().csrf().disable().cors(); builder.addSecurityFilterChainBuilder(httpSecurity); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("*")); configuration.setAllowedMethods(Arrays.asList("*")); configuration.setAllowedHeaders(Arrays.asList("*")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } @Bean public AuthenticationProvider casAuthenticationProvider(LoginServiceImpl loginService) { CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider(); casAuthenticationProvider.setAuthenticationUserDetailsService(new XXXCasUserDetailsService(loginService)); casAuthenticationProvider.setTicketValidator(new TuscCasTicketValidator(ssoUrl)); ServiceProperties serviceProperties = new ServiceProperties(); serviceProperties.setService(applicationServerUrl); casAuthenticationProvider.setServiceProperties(serviceProperties); casAuthenticationProvider.setKey("casAuthenticationProvider"); return casAuthenticationProvider; }}
🔆 需要明确 Filter 的先后顺序,顺序不对可能会造成验证 bug,比如 renewFilter 应该在 BearerTokenAuthenticationFilter 之前。否则 renewFilter 就没有意义了。
]]>