使用SpringCloud Alibaba结合Springboot3.0 搭建一个demo
Springboot 3 出来也有一段时间了，Springcloud alibaba也在适配当中，本demo使用Springboot 3 + Spingcloud alibaba + nacos等组件，完成一个项目工程的简单搭建组件Nacos : 2.0.3Maven：3.0.8依赖版本springboot :3.0.4springcloud:2022.0.1springcloud alibaba :2.2.10-RC1spring-cloud-starter-alibaba-nacos-discovery:2022.0.0.0-RC1spring-cloud-starter-alibaba-nacos-config：2022.0.0.0-RC1springcloud alibaba 2.2.10-RC1 版本提供的Nacos依赖，经测试无法成功注册，故使用2022.0.0.0-RC1版本项目POM<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.0.4</version> <relativePath/> </parent> <groupId>com.piemoe</groupId> <artifactId>springboot3</artifactId> <version>0.0.1-SNAPSHOT</version> <name>springboot3</name> <description>springboot3</description> <properties> <java.version>17</java.version> </properties> ​ <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>2022.0.1</version> <type>pom</type> <scope>import</scope> </dependency> ​ <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>2.2.10-RC1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> ​ <dependencies> ​ <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> ​ <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <version>2022.0.0.0-RC1</version> </dependency> ​ ​ <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <version>2022.0.0.0-RC1</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency> ​ ​ </dependencies> ​ <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> ​ </project> ​启动类@SpringBootApplication public class Springboot3Application { ​ public static void main(String[] args) { SpringApplication.run(Springboot3Application.class, args); } ​ }配置文件bootstrap.ymlserver: port: 13000 ​ spring: main: banner-mode: off application: name: demo profiles: active: local ​ rpc: addr: 127.0.0.1:8848 ​ logging: file: name: logs/${spring.application.name}/all.logbootstrap-local.ymlspring: cloud: nacos: discovery: server-addr: ${rpc.addr} namespace: ${spring.profiles.active} config: server-addr: ${rpc.addr} namespace: ${spring.profiles.active} file-extension: ymlNacos配置demo: name: xxxxx项目已提交到github，有兴趣的可以去看下 仓库
前言数据脱敏是一种数据保护技术，它可以对敏感数据进行处理，使其失去原始意义，从而保护数据隐私。通俗地说，就是将原始数据中敏感信息的部分替换或删除，例如：手机号码中的一部分数字替换为星号，或是姓名中的一些字母用其他字母代替等等。数据脱敏可以保护个人隐私，防止个人信息被泄露、滥用，同时也符合现代法律法规对个人信息保护的要求，是网络安全和信息保护中不可或缺的一环。本文将通过使用注解完成对字段的自定义脱敏功能。正文本文将介绍2种脱敏的方式，分别为：注解方式脱敏和lambda方式脱敏注解脱敏定义脱敏注解@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @JacksonAnnotationsInside @JsonSerialize(using = SensitiveSerialize.class) public @interface Dm { /** * 脱敏信息类型 */ DmTypeEnum type(); /** * 前置不需要打码的长度 * 只有 {@link DmTypeEnum} 为 {@link DmTypeEnum#CUSTOMER} 时可用 */ int prefixNoMaskLen() default 0; /** * 后置不需要打码的长度 * 只有 {@link DmTypeEnum} 为 {@link DmTypeEnum#CUSTOMER} 时可用 */ int suffixNoMaskLen() default 0; /** * 用什么打码 * 只有 {@link DmTypeEnum} 为 {@link DmTypeEnum#CUSTOMER} 时可用 */ char symbol() default '*'; }type()方法为可以支持实现的脱敏信息类型，通过使用hutool工具，可以看到它支持以下枚举，故拉取出来形成类型枚举：public enum DmTypeEnum { /** * 用户自定义 */ CUSTOMER, /** * 用户id */ BACK_ID, /** * 中文名 */ CHINESE_NAME, /** * 身份证号 */ ID_CARD, /** * 座机号 */ FIXED_PHONE, /** * 手机号 */ MOBILE_PHONE, /** * 地址 */ ADDRESS, /** * 电子邮件 */ EMAIL, /** * 密码 */ PASSWORD, /** * 中国大陆车牌，包含普通车辆、新能源车辆 */ CAR_LICENSE, /** * 银行卡 */ BANK_CARD }关键语句在@JsonSerialize(using = SensitiveSerialize.class)上，@JsonSerialize 是一个注解，它可以用来指定 Java 对象序列化成 JSON 格式时的序列化规则。具体而言，它可以指定序列化时属性名的命名方式、日期格式化等等。它的作用是使得 Java 对象可以方便地转化为 JSON 格式，这对于 Web 开发、移动应用开发等场景非常有用，故此我们在这里自定义一个实现，并在注解中引用。@NoArgsConstructor @AllArgsConstructor @Slf4j public class SensitiveSerialize<T> extends JsonSerializer<T> implements ContextualSerializer { /** * 脱敏类型 */ private DmTypeEnum sensitiveTypeEnum; /** * 前几位不脱敏 */ private Integer prefixNoMaskLen; /** * 最后几位不脱敏 */ private Integer suffixNoMaskLen; /** * 打码字符 */ private char symbol; @Override public void serialize(final T value, final JsonGenerator jsonGenerator, final SerializerProvider serializerProvider) throws IOException { Object desensitized = DmUtils.desensitized(value.toString(), sensitiveTypeEnum, prefixNoMaskLen, suffixNoMaskLen, symbol); if (desensitized instanceof String) { jsonGenerator.writeString(desensitized.toString()); return; } if (desensitized instanceof Number) { jsonGenerator.writeNumber(DmUtils.backId()); return; } log.error("[数据脱敏]未知的信息类型：{}", sensitiveTypeEnum); jsonGenerator.writeString(StrUtil.EMPTY); } @Override public JsonSerializer<?> createContextual(final SerializerProvider serializerProvider, final BeanProperty beanProperty) throws JsonMappingException { if (beanProperty != null) { if ( Objects.equals(beanProperty.getType().getRawClass(), String.class) || Objects.equals(beanProperty.getType().getSuperClass().getRawClass(), Number.class) ) { Dm sensitive = beanProperty.getAnnotation(Dm.class); if (sensitive == null) { sensitive = beanProperty.getContextAnnotation(Dm.class); } if (sensitive != null) { return new SensitiveSerialize<T>(sensitive.type(), sensitive.prefixNoMaskLen(), sensitive.suffixNoMaskLen(), sensitive.symbol()); } } return serializerProvider.findValueSerializer(beanProperty.getType(), beanProperty); } return serializerProvider.findNullValueSerializer(null); } }脱敏工具类（对照hutool脱敏方法）@Slf4j public class DmUtils { /** * 脱敏，使用默认的脱敏策略 * * desensitized("100", DesensitizedUtils.DesensitizedType.USER_ID)) = "0" * desensitized("段正淳", DesensitizedUtils.DesensitizedType.CHINESE_NAME)) = "段**" * desensitized("51343620000320711X", DesensitizedUtils.DesensitizedType.ID_CARD)) = "5***************1X" * desensitized("09157518479", DesensitizedUtils.DesensitizedType.FIXED_PHONE)) = "0915*****79" * desensitized("18049531999", DesensitizedUtils.DesensitizedType.MOBILE_PHONE)) = "180****1999" * desensitized("北京市海淀区马连洼街道289号", DesensitizedUtils.DesensitizedType.ADDRESS)) = "北京市海淀区马********" * desensitized("duandazhi-jack@gmail.com.cn", DesensitizedUtils.DesensitizedType.EMAIL)) = "d*************@gmail.com.cn" * desensitized("1234567890", DesensitizedUtils.DesensitizedType.PASSWORD)) = "**********" * desensitized("苏D40000", DesensitizedUtils.DesensitizedType.CAR_LICENSE)) = "苏D4***0" * desensitized("11011111222233333256", DesensitizedUtils.DesensitizedType.BANK_CARD)) = "1101 **** **** **** 3256" * * * @param message 字符串 * @param desensitizedType 脱敏类型;可以脱敏：用户id、中文名、身份证号、座机号、手机号、地址、电子邮件、密码 * @param startInclude 开始排除 类型为 CUSTOMER 时可用 * @param endExclude 结束排除 类型为 CUSTOMER 时可用 * @param symbol 脱敏字符 默认为'*' 类型为 CUSTOMER 时可用 * @return 脱敏之后的字符串 */ public static Object desensitized(Object message, DmTypeEnum desensitizedType, int startInclude, int endExclude, char symbol) { if (message instanceof Number && StrUtil.isBlank(message.toString())) { return -1L; } if (StrUtil.isBlank(message.toString())) { return StrUtil.EMPTY; } String newStr; switch (desensitizedType) { case CUSTOMER: newStr = customer(String.valueOf(message), startInclude, endExclude, symbol); break; case BACK_ID: log.info("[数据脱敏]后台用户id脱敏，值为：{}", message); return backId(); case CHINESE_NAME: newStr = chineseName(String.valueOf(message)); break; case ID_CARD: newStr = idCardNum(String.valueOf(message), 1, 2); break; case FIXED_PHONE: newStr = fixedPhone(String.valueOf(message)); break; case MOBILE_PHONE: newStr = mobilePhone(String.valueOf(message)); break; case ADDRESS: newStr = address(String.valueOf(message), 8); break; case EMAIL: newStr = email(String.valueOf(message)); break; case PASSWORD: newStr = password(String.valueOf(message)); break; case CAR_LICENSE: newStr = carLicense(String.valueOf(message)); break; case BANK_CARD: newStr = bankCard(String.valueOf(message)); break; default: newStr = ""; } return newStr; } public static String replace(CharSequence str, int startInclude, int endExclude, char symbol) { return CharSequenceUtil.replace(str, startInclude, endExclude, symbol); } public static String customer(String customer, int startInclude, int endExclude, char replacedChar) { if (StrUtil.isBlank(customer)) { return ""; } if (startInclude > customer.length() || endExclude > customer.length() || (startInclude + endExclude) > customer.length()) { log.warn("[信息脱敏]自定义长度不正确"); return replace(customer, startInclude, customer.length() - 2, replacedChar); } return replace(customer, startInclude, endExclude, replacedChar); } /** * 【后台用户id】不对外提供backId * * @return 脱敏后的主键 */ public static Long backId() { return -1L; } /** * 【中文姓名】只显示第一个汉字，其他隐藏为2个星号，比如：李** * * @param fullName 姓名 * @return 脱敏后的姓名 */ public static String chineseName(String fullName) { if (StrUtil.isBlank(fullName)) { return StrUtil.EMPTY; } return StrUtil.hide(fullName, 1, fullName.length()); } /** * 【身份证号】前1位 和后2位 * * @param idCardNum 身份证 * @param front 保留：前面的front位数；从1开始 * @param end 保留：后面的end位数；从1开始 * @return 脱敏后的身份证 */ public static String idCardNum(String idCardNum, int front, int end) { //身份证不能为空 if (StrUtil.isBlank(idCardNum)) { return StrUtil.EMPTY; } //需要截取的长度不能大于身份证号长度 if ((front + end) > idCardNum.length()) { return StrUtil.EMPTY; } //需要截取的不能小于0 if (front < 0 || end < 0) { return StrUtil.EMPTY; } return StrUtil.hide(idCardNum, front, idCardNum.length() - end); } /** * 【固定电话 前四位，后两位 * * @param num 固定电话 * @return 脱敏后的固定电话； */ public static String fixedPhone(String num) { if (StrUtil.isBlank(num)) { return StrUtil.EMPTY; } return StrUtil.hide(num, 4, num.length() - 2); } /** * 【手机号码】前三位，后4位，其他隐藏，比如135****2210 * * @param num 移动电话； * @return 脱敏后的移动电话； */ public static String mobilePhone(String num) { if (StrUtil.isBlank(num)) { return StrUtil.EMPTY; } return StrUtil.hide(num, 3, num.length() - 4); } /** * 【地址】只显示到地区，不显示详细地址，比如：北京市海淀区**** * * @param address 家庭住址 * @param sensitiveSize 敏感信息长度 * @return 脱敏后的家庭地址 */ public static String address(String address, int sensitiveSize) { if (StrUtil.isBlank(address)) { return StrUtil.EMPTY; } int length = address.length(); return StrUtil.hide(address, length - sensitiveSize, length); } /** * 【电子邮箱】邮箱前缀仅显示第一个字母，前缀其他隐藏，用星号代替，@及后面的地址显示，比如：d**@126.com * * @param email 邮箱 * @return 脱敏后的邮箱 */ public static String email(String email) { if (StrUtil.isBlank(email)) { return StrUtil.EMPTY; } int index = StrUtil.indexOf(email, '@'); if (index <= 1) { return email; } return StrUtil.hide(email, 1, index); } /** * 【密码】密码的全部字符都用*代替，比如：****** * * @param password 密码 * @return 脱敏后的密码 */ public static String password(String password) { if (StrUtil.isBlank(password)) { return StrUtil.EMPTY; } return StrUtil.repeat('*', password.length()); } /** * 【中国车牌】车牌中间用*代替 * eg1：null -》 "" * eg1："" -》 "" * eg3：苏D40000 -》 苏D4***0 * eg4：陕A12345D -》 陕A1****D * eg5：京A123 -》 京A123 如果是错误的车牌，不处理 * * @param carLicense 完整的车牌号 * @return 脱敏后的车牌 */ public static String carLicense(String carLicense) { if (StrUtil.isBlank(carLicense)) { return StrUtil.EMPTY; } // 普通车牌 if (carLicense.length() == 7) { carLicense = StrUtil.hide(carLicense, 3, 6); } else if (carLicense.length() == 8) { // 新能源车牌 carLicense = StrUtil.hide(carLicense, 3, 7); } return carLicense; } /** * 银行卡号脱敏 * eg: 1101 **** **** **** 3256 * * @param bankCardNo 银行卡号 * @return 脱敏之后的银行卡号 */ public static String bankCard(String bankCardNo) { if (StrUtil.isBlank(bankCardNo)) { return bankCardNo; } bankCardNo = StrUtil.trim(bankCardNo); if (bankCardNo.length() < 9) { return bankCardNo; } final int length = bankCardNo.length(); final int midLength = length - 8; final StringBuilder buf = new StringBuilder(); buf.append(bankCardNo, 0, 4); for (int i = 0; i < midLength; ++i) { if (i % 4 == 0) { buf.append(CharUtil.SPACE); } buf.append('*'); } buf.append(CharUtil.SPACE).append(bankCardNo, length - 4, length); return buf.toString(); } }lambda脱敏注解方式脱敏的好处是可以在变量上更方便的进行脱敏，但是这种方式对数据格式支持不高，一般只支持基本的数据类型，无法对实体内嵌套的数据进行脱敏，故此可以通过lambda方式进行脱敏。首先自定义一个函数public interface DmParam<Parent, Children> extends Serializable { /** * 执行函数 * * @param parameter 参数 * @return 函数执行结果 * @throws Exception 自定义异常 */ Children call(Parent parameter) throws Exception; /** * 执行函数，异常包装为RuntimeException * * @param parameter 参数 * @return 函数执行结果 */ default Children callWithServiceException(Parent parameter) { try { return call(parameter); } catch (Exception e) { throw new ServiceException("信息脱敏失败"); } } }既然通过lambda方式获取字段，那么反射也是必须的public class FieldUtil { /** * 通过lambda获取字段 * * @param func 方法 * @return {@link Field} */ public static <T> Field getField(DmParam<T, ?> func) { FieldMeta fieldMeta = getFieldMeta(func); return ReflectUtil.getField(fieldMeta.getGetInstantiatedClass(), fieldMeta.getPropertyName()); } /** * 通过lambda设置字段名称 * * @param func 方法 * @param val 设置的值 */ public static <T> void setField(Object object, DmParam<T, ?> func, String val) { FieldMeta fieldMeta = getFieldMeta(func); ReflectUtil.setFieldValue(object, fieldMeta.getPropertyName(), val); } /** * 通过lambda获取字段名称 * * @param func 方法 * @return {@link String} */ public static <T> FieldMeta getFieldMeta(DmParam<T, ?> func) { // 反射读取 Method writeReplaceMethod; try { writeReplaceMethod = func.getClass().getDeclaredMethod("writeReplace"); // 设置可访问 writeReplaceMethod.setAccessible(true); SerializedLambda serializedLambda; serializedLambda = (SerializedLambda) writeReplaceMethod.invoke(func); String implMethodName = serializedLambda.getImplMethodName(); String propertyName = methodToProperty(implMethodName); String clazzPath = serializedLambda.getImplClass().replace('/', '.'); Class<String> clazz = ClassUtil.getClass(clazzPath); return new FieldMeta(propertyName, implMethodName, clazz); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { log.error("[FieldUtil]反射获取字段失败", e); throw new ServiceException(e); } } /** * 通过lambda获取字段名称 * * @param func 方法 * @return {@link String} */ public static <T> String getFieldName(DmParam<T, ?> func) { // 反射读取 Method writeReplaceMethod; try { writeReplaceMethod = func.getClass().getDeclaredMethod("writeReplace"); // 设置可访问 writeReplaceMethod.setAccessible(true); SerializedLambda serializedLambda; serializedLambda = (SerializedLambda) writeReplaceMethod.invoke(func); String implMethodName = serializedLambda.getImplMethodName(); return methodToProperty(implMethodName); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { log.error("[FieldUtil]反射获取字段失败", e); throw new ServiceException(e); } } /** * 根据方法名转换为字段 * * @param implMethodName 方法名 * @return {@link String} */ public static String methodToProperty(String implMethodName) { final String defaultIs = "is"; final String defaultGet = "get"; final String defaultSet = "set"; if (implMethodName.startsWith(defaultIs)) { implMethodName = implMethodName.substring(2); } else if (implMethodName.startsWith(defaultGet) || implMethodName.startsWith(defaultSet)) { implMethodName = implMethodName.substring(3); } else { throw new ServiceException("Error parsing property name '" + implMethodName + "'. Didn't start with 'is', 'get' or 'set'."); } if (implMethodName.length() == 1 || (implMethodName.length() > 1 && !Character.isUpperCase(implMethodName.charAt(1)))) { implMethodName = implMethodName.substring(0, 1).toLowerCase(Locale.ENGLISH) + implMethodName.substring(1); } return implMethodName; } }定义脱敏方法：对于单个实体public class DmLambdaWrapper<T> { T val; @SuppressWarnings("rawtype") public DmLambdaWrapper(T val) { this.val = val; } /** * 获取脱敏实例 * * @param val 待脱敏的实体 * @return {@link DmLambdaWrapper<T>} */ public static <T> DmLambdaWrapper<T> getInstance(T val) { return new DmLambdaWrapper<>(val); } /** * 关联单个字段 * * @param field 关联字段 * @return {@link DmLambdaWrapper<R>} */ public <R> DmLambdaWrapper<R> ivkOne(DmParam<T, R> field) { R call = field.callWithServiceException(val); return new DmLambdaWrapper<>(call); } /** * 关联集合 * * @param listMapper 关联集合字段 * @param predicate 脱敏预言 * @return {@link DmListLambdaWrapper} */ public <C, S extends Collection<C>> DmListLambdaWrapper<Collection<C>, C> ivkList(DmParam<T, S> listMapper, Predicate<C> predicate) { S cs = listMapper.callWithServiceException(val); List<C> collect = cs.stream().filter(predicate).collect(Collectors.toList()); return new DmListLambdaWrapper<>(collect); } /** * 关联集合-无预言（集合内所有对应字段都将脱敏） * * @param listMapper 关联集合字段 * @return {@link DmListLambdaWrapper} */ public <C, S extends Collection<C>> DmListLambdaWrapper<Collection<C>, C> ivkList(DmParam<T, S> listMapper) { S cs = listMapper.callWithServiceException(val); return new DmListLambdaWrapper<>(cs); } /** * 脱敏字符串 * * @param dmType 脱敏类型 * @param mapper 脱敏字段 * @return {@link DmLambdaWrapper<T>} */ public <R extends String> DmLambdaWrapper<T> dmStr(DmTypeEnum dmType, DmParam<T, R> mapper) { R call = mapper.callWithServiceException(val); if (null != call) { String desensitized = DmUtils.desensitized(call, dmType, 1, 3, '*').toString(); FieldUtil.setField(val, mapper, desensitized); // 反射获取字段名称 } return new DmLambdaWrapper<>(val); } /** * 脱敏多个字段 相同脱敏类型多字段性能要优于 {@link DmLambdaWrapper#dmStr(DmTypeEnum, DmParam)} * * @param dmType 脱敏类型 * @param mappers 脱敏字段 * @return {@link DmLambdaWrapper<T>} * @author Mc * @date 2022/12/16 17:32 */ @SafeVarargs public final <R extends String> DmLambdaWrapper<T> dmStr(DmTypeEnum dmType, final DmParam<T, R>... mappers) { for (DmParam<T, R> mapper : mappers) { R call = mapper.callWithServiceException(val); if (null != call) { String desensitized = DmUtils.desensitized(call, dmType, 1, 3, '*').toString(); FieldUtil.setField(val, mapper, desensitized); // 反射获取字段名称 } } return new DmLambdaWrapper<>(val); } }对于List集合脱敏@Slf4j public class DmListLambdaWrapper<T extends Collection<F>, F> { T listVal; public DmListLambdaWrapper(T listVal) { this.listVal = listVal; } /** * 关联集合 带过滤 * * @param listMapper 集合Mapper * @param predicate 过滤预言 * @return {@link DmListLambdaWrapper} * @author Mc * @date 2022/12/16 17:24 */ public <C, S extends Collection<C>> DmListLambdaWrapper<Collection<C>, C> ivkList(DmParam<T, S> listMapper, Predicate<C> predicate) { S cs = listMapper.callWithServiceException(listVal); List<C> collect = cs.stream().filter(predicate).collect(Collectors.toList()); return new DmListLambdaWrapper<>(collect); } /** * 挂念集合 不带过滤 * * @param listMapper 集合Mapper * @return {@link DmListLambdaWrapper} * @author Mc * @date 2022/12/16 17:26 */ public <C, S extends Collection<C>> DmListLambdaWrapper<Collection<C>, C> ivkList(DmParam<T, S> listMapper) { S cs = listMapper.callWithServiceException(listVal); return new DmListLambdaWrapper<>(cs); } /** * 脱敏字符串字段 * * @param dmType 脱敏类型 * @param mapper 脱敏字段 * @return {@link DmListLambdaWrapper} * @author Mc * @date 2022/12/16 17:26 */ public <C extends String> DmListLambdaWrapper<Collection<F>, F> dmList2Str(DmTypeEnum dmType, DmParam<F, C> mapper) { for (F f : listVal) { C message = mapper.callWithServiceException(f); if (null != message) { String desensitized = DmUtils.desensitized(message, dmType, 1, 3, '*').toString(); FieldUtil.setField(f, mapper, desensitized); // 反射获取字段名称 } } return new DmListLambdaWrapper<>(listVal); } /** * 脱敏多个字符串字段，相同类型性能优先于多次使用 {@link DmListLambdaWrapper#dmList2Str(DmTypeEnum, DmParam)} * * @param dmType 脱敏类型 * @param mappers 脱敏字段 * @return {@link DmListLambdaWrapper} * @author Mc * @date 2022/12/16 17:27 */ @SafeVarargs public final <C extends String> DmListLambdaWrapper<Collection<F>, F> dmList2Str(DmTypeEnum dmType, final DmParam<F, C>... mappers) { for (F f : listVal) { for (DmParam<F, C> mapper : mappers) { C message = mapper.callWithServiceException(f); if (null != message) { String desensitized = DmUtils.desensitized(message, dmType, 1, 3, '*').toString(); FieldUtil.setField(f, mapper, desensitized); // 反射获取字段名称 } } } return new DmListLambdaWrapper<>(listVal); } }其他的方式可以自由拓展用法：DmLambdaWrapper.getInstance(user.getUserDetail()) .dmStr(DmTypeEnum.MOBILE_PHONE, UserDetail::getMobile) .dmStr(DmTypeEnum.EMAIL, UserDetail::getEmail);上面的例子将user里的userDetail字段中的mobile、email进行了相对应的数据脱敏。
前言项目用到了敏感词过滤（数据清洗），之前是通过查询敏感词库进行模糊匹配然后过滤的，但这样的查表的效率很低，且跨微服务调用时时间损耗会更高，故此记录改造正文首先想到的是将敏感词从库表里放入redis缓存，这样可以有效避免重复查库，其次通过redis可以较少微服务调用，直接从redis缓存使用。但是一段语句中，是否存在敏感词，直接匹配redis缓存中的敏感词库的话，效率依旧不高，故此我们可以引入知名的DFA算法。DFA算法介绍DFA(确定有限状态自动机) 算法是一种用于解决文本匹配问题的算法。它通过比较输入文本和预定义的状态机的状态转换路径，来判断是否存在匹配。DFA 通常用于字符串匹配。它利用预先创建好的有限状态自动机，自动机包含一个有限的状态集合和一个输入符号集合，自动机从一个状态转移到另一个状态，根据输入符号，自动机判断出输入符号是否与预定义的字符串匹配。 其主要思想是将匹配过程建模为状态机，并使用状态机的转移过程进行匹配。下面是一些使用 DFA 的例子：字符串匹配：在一个输入文本中查找一个特定的字符串是否存在。如果它存在，就返回它在文本中的位置。如果不存在，则返回-1。词法分析：将输入的字符流识别成包含令牌、标记或关键字的单词流。例如，在编译器中，它用于将代码解析成令牌流。自然语言处理：用于命名实体识别等自然语言处理任务。在这些任务中，它可以使用 DFA 对文本进行分词，并构建一个有限状态自动机，以识别关键字或语法结构。过滤器：用于过滤输入文本中的某些内容。在这些任务中，它可以使用 DFA 对文本进行匹配和替换，以过滤或修改输入文本中的内容。更多的介绍可以看这篇文章：基于DFA敏感词查询的算法简析 - 李晓晖 - 博客园 (cnblogs.com)DFA实现站在巨人的肩膀上才能看的更高，故此直接使用hutools工具的DFA算法！DFA查找 (hutool.cn)敏感词过滤实现本人根据自己日常编码的使用需求，大致分为2种使用场景：方法内过滤这个很好理解，就是方法体内进行过滤，这个直接使用hutool提供的方法结合redis使用即可，不再赘述。方法调用时过滤作为入参参数过滤，即方法调用时过滤根据2个场景又可以分成2种风格：遇到敏感词即禁止（截止模式）替换敏感词为其他符号（置换模式）敏感词首先定义一个敏感词库，使用hash的方式将其存入redis，这部分实现很简单，不再赘述。截止模式此模式为遇到敏感词即报错提示，通过统一异常管理可以快捷通知用户内容包含敏感词首先定义一个注解：/** * 自定义敏感词过滤注解 * 若包含敏感词，则返回false 不替换敏感词 效率更高 * @author mc */ @Target(value = { ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER }) @Retention(RetentionPolicy.RUNTIME) @Documented @Constraint(validatedBy = SwfValidator.class) public @interface SwfParam { String message() default "内容含有敏感词汇，请检查！"; }这个注解可以在方法、字段、构造器和入参字段字段使用，message为报错的信息，@Constraint(validatedBy = SwfValidator.class)为检验方法，是具体的过滤方法。/** * 自定义敏感词校验注解实现 * * @author mc */ public class SwfValidator implements ConstraintValidator<SwfParam, String> { @Resource ISwfService swfService; @Override public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { return containsKeyWord(value); } public boolean containsKeyWord(String value) { WordTree sensitiveWordTree = new WordTree(); Set<String> sensitiveWordSet = swfService.getRemoteSensitiveWordMap(true); sensitiveWordTree.addWords(sensitiveWordSet); String match = sensitiveWordTree.match(value); if (StrUtil.isNotBlank(match)) { Console.log("敏感词为：{}",match); } return null == match || match.isEmpty(); } }ISwfService为通过redis获取敏感词的接口，具体实现省略。置换模式此模式为遇到敏感词不报错，通过将敏感词置换为固定的字符来实现敏感词过滤还是使用注解package com.xinwei.swf.annotation; import com.xinwei.swf.enums.SwfModeEnum; import com.xinwei.swf.enums.SwfRuleEnum; import java.lang.annotation.*; /** * 自定义敏感词过滤注解 * 适用于方法参数过滤, 用于“替换”敏感词 * @author mc */ @Target(value = { ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Swf { /** * 待过滤敏感词 */ String text() default ""; /** * 默认以该文本替换敏感词 */ String replace() default "*"; /** * 过滤参数位置 从0开始 */ int[] paramPosition(); /** * 过滤模式 */ SwfModeEnum mode() default SwfModeEnum.THROW_EXCEPTION; /** * 自定义异常消息,仅当过滤模式为SwfModeEnum.THROW_EXCEPTION 启用 */ String exceptionMsg() default ""; /** * 自定义过滤词汇 */ String[] customWords() default {}; /** * 自定义过滤规则 */ SwfRuleEnum rule() default SwfRuleEnum.BOTH; /** * 是否强制过滤 * 默认强制过滤，若不存在过滤词，则抛出错误 * 非强制过滤若无过滤词，则不过滤 */ boolean isForce() default true; }通过注解方法可以提供很多功能，如replace()可以将敏感词替换为制定的字符。mode()方法可以选择截止模式或置换模式，customWords()可以提供手动的额外敏感词库，rule()是选择通过数据库或redis换取敏感词的方式。/** * 过滤模式 * * @author mc * @date 2022年05月11日 11:19 */ @Getter public enum SwfModeEnum { /** * 抛出异常 */ THROW_EXCEPTION(0), /** * 替换敏感词 */ REPLACE(1); private final int value; SwfModeEnum(int value) { this.value = value; } } /** * 自定义过滤规则 * * @author mc */ @Getter public enum SwfRuleEnum { /** * 过滤全部数据库+自定义过滤词汇 */ BOTH(0), /** * 只过滤数据库的敏感词汇 */ REMOTE(1), /** * 只过滤自定义的敏感词汇 */ CUSTOM(2); private final int value; SwfRuleEnum(int value) { this.value = value; } }通过切面配合注解使用@Aspect @Component @Slf4j public class SwfAspect { @Resource ISwfService swfService; @Value("${spring.messages.enabled:false}") private String messagesEnabled; @Value("${spring.application.name}") private String serviceName; @Around("@annotation(swf)") public Object doBefore(ProceedingJoinPoint point, Swf swf) { return handleSwf(point, swf); } @SneakyThrows private Object handleSwf(ProceedingJoinPoint point, Swf swf) { final String trueStr = "true"; SwfRuleEnum rule = swf.rule(); boolean forceFiltering = swf.isForce(); String[] customWords = swf.customWords(); Set<String> sensitiveWordSet; WordTree sensitiveWordTree = new WordTree(); //获取数据库或redis 敏感词 sensitiveWordSet = swfService.getRemoteSensitiveWordMap(forceFiltering); //规则判断 if (rule == SwfRuleEnum.BOTH) { sensitiveWordTree.addWords(sensitiveWordSet); //若有自定义敏感词 if (customWords.length > 0) { sensitiveWordTree.addWords(customWords); } } else if (rule == SwfRuleEnum.REMOTE) { sensitiveWordTree.addWords(sensitiveWordSet); } else { sensitiveWordTree.addWords(customWords); } Object[] args = point.getArgs(); int[] paramPositionArray = swf.paramPosition(); for (int position : paramPositionArray) { Object arg = args[position]; String text = String.valueOf(arg); List<String> strings = sensitiveWordTree.matchAll(text); if (SwfModeEnum.THROW_EXCEPTION.equals(swf.mode())) { if (CollectionUtil.isNotEmpty(strings)) { log.error("["+serviceName+"]敏感词为：{}", strings.toString()); if (trueStr.equals(messagesEnabled)) { throw new GlobalException(swf.exceptionMsg()); } else { throw new GlobalException("内容包含敏感词"); } } } else { for (String string : strings) { text = text.replace(string, swf.replace()); } arg = text; args[position] = arg; if (CollectionUtil.isNotEmpty(strings)) { log.error("["+serviceName+"]敏感词为：{}", strings); } } } return point.proceed(args); } }@Value("${spring.messages.enabled:false}")是i18n的配置，可以忽略。
使用Lock4j完成一个分布式锁和幂等的Spring Boot Starter组件
前言分布式锁​ 分布式锁是在分布式系统中保证同步和协调的一种机制。它通常用于协调多个进程或节点之间的访问和资源使用，以防止并发冲突和数据损坏。​ 分布式锁的实现通常采用一种称为互斥体的同步原语。互斥体是一种锁定机制，可以用来确保一个进程或节点在任何时候都具有对某个资源的独占访问权限。在分布式环境中，互斥体必须经过适当的设计和实现，以确保它能够正确地工作和协调不同的节点和进程。​ 常见的分布式锁实现方式包括：基于Redis的分布式锁、ZooKeeper的分布式锁、数据库乐观锁等。这些实现方式中，Redis和ZooKeeper常常被使用在大规模的分布式系统中，因为它们提供了可靠的数据存储和高效的访问控制机制。而数据库乐观锁则主要用于小规模分布式系统中，因为它比较简单直接，但是更加依赖于网络的性能。幂等​ 幂等是指对同一操作，多次执行所产生的影响与执行一次的影响相同。也就是说，即使同一操作被执行多次，也不会对系统产生额外的影响。在计算机科学中，幂等性是一种非常重要的概念，常常用于设计和实现高可靠性、可复用性、可扩展性的分布式系统和网络服务。常见的幂等操作包括查询数据库、修改状态、发送邮件等。选型[scode type='info']本次使用lock4j+redisson实现分布式锁和幂等组件，其中，redisson的版本不应该过高，要和Spring Boot版本对应，否则无法正常启动。[/scode]Spring Boot：2.6lock4j: 2.2.3官方仓库：lock4j: 基于Spring AOP 的声明式和编程式分布式锁，支持RedisTemplate、Redisson、Zookeeper (gitee.com)正文首先搭建一个Spring Boot 2.6.3 的项目，搭建过程省略。引入lock4j+redisson依赖<dependency> <groupId>com.baomidou</groupId> <artifactId>lock4j-redisson-spring-boot-starter</artifactId> <version>2.2.3</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency>分布式锁首先对lock4j进行一些配置，默认的lock4j在缓存中是以lock4j为前缀的key，但是为了保持项目的一致（比如某个微服务就以它的项目名称为开头）就需要修改其默认的key前缀。@AutoConfigureBefore(LockAutoConfiguration.class) public class MyLock4jConfig { // 引入默认的配置 private final Lock4jProperties properties; // 修改lock4j前缀 自定义默认使用key 项目名称:lock4j:*** public MyLock4jConfig(Lock4jProperties properties, ApplicationName applicationName) { properties.setLockKeyPrefix(applicationName.getServiceName() + ":lock4j"); this.properties = properties; } @Bean @ConditionalOnMissingBean @SuppressWarnings("all") // 这里加入 @ConditionalOnMissingBean注解，目的是为了项目代码里可以灵活重写 // @SuppressWarnings("all")可以抑制idea的警告 public LockTemplate lockTemplate(List<LockExecutor> executors) { LockTemplate lockTemplate = new LockTemplate(); lockTemplate.setProperties(properties); lockTemplate.setExecutors(executors); return lockTemplate; } // 默认的锁失败策略 这里进行自定义 @Bean public DefaultLockFailureStrategy lockFailureStrategy() { return new DefaultLockFailureStrategy(); } }ApplicationName是自定义的获取项目spring.application.name的配置，具体如下：@Configuration @Data public class ApplicationName { @Value("${spring.application.name}") private String serviceName; }实现自定义锁失败策略，实则就是打印语句并抛出一个错误，要不要捕获并打印错误取决于你的项目。GlobalException是我自定义的错误。@Slf4j public class DefaultLockFailureStrategy implements LockFailureStrategy { @Override public void onLockFailure(String key, Method method, Object[] arguments) { log.debug("[onLockFailure][线程：{} 获取锁失败 key：{} 获取失败：{}]", Thread.currentThread().getName(), key, arguments); throw new GlobalException(REQUEST_LOCKED); } }resource/META-INF/spring.factories中加入配置，实际路径按照你的MyLock4jConfig的路径命名。org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.xxx.common.protection.lock4j.config.MyLock4jConfig,\ com.xxx.web.name.ApplicationName幂等组件幂等我们使用注解来实现，新建一个名为Idempotent的注解@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { /** * 幂等的超时时间，默认为 1 秒 */ int timeout() default 1; /** * 时间单位，默认为 SECONDS 秒 */ TimeUnit timeUnit() default TimeUnit.SECONDS; /** * 提示信息，正在执行中的提示 */ String message() default "请求过于频繁，请稍后再试"; /** * 使用的 Key 解析器 */ Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class; /** * 使用的 Key 参数 */ String keyArg() default ""; }接下来新建配合注解的切面逻辑@Aspect @Slf4j public class IdempotentAspect { private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers; private final IdempotentRedisDAO idempotentRedisDAO; public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) { this.keyResolvers = CollUtil.toMap(keyResolvers, new HashMap<>(), IdempotentKeyResolver::getClass); this.idempotentRedisDAO = idempotentRedisDAO; } @Before(value = "@annotation(idempotent)") public void before(JoinPoint joinPoint, Idempotent idempotent) { // 获取 key 解析器 IdempotentKeyResolver idempotentKeyResolver = keyResolvers.get(idempotent.keyResolver()); Assert.notNull(idempotentKeyResolver, "对应解析器不存在"); // 解析key String key = idempotentKeyResolver.resolver(joinPoint, idempotent); // 锁定key boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit()); // 锁定失败 if (!success) { log.info("[before][方法({}) 参数({}) 重复请求，幂等组件已过滤]", joinPoint.getSignature().getName(), JSONUtil.toJsonStr(joinPoint.getArgs())); throw new GlobalException(REQUEST_LOCKED); } } }IdempotentRedisDAO提供一个redis键的获取方法，规则是当键已存在时，就不能获取，不存在时新建键，这样当再来相同请求时，会因为已存在键而直接返回错误信息。定义这个键，第一个补位时项目名称，第二个补位是对应幂等的接口public interface Lock4jRedisKeyConstants { String IDEMPOTENT_KEY = "%s:idempotent:%s"; }IdempotentRedisDAO的实现方式有很多，这里使用setIfAbsent()方法@AllArgsConstructor public class IdempotentRedisDAO { public static final String IDEMPOTENT_KEY = Lock4jRedisKeyConstants.IDEMPOTENT_KEY; private final ApplicationName applicationName; private final StringRedisTemplate redisTemplate; public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) { String redisKey = formatKey(key); return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit); } private String formatKey(String key) { return String.format(IDEMPOTENT_KEY, applicationName.getServiceName(), key); } }实现接口信息幂等，实际使用接口方法名称+入参共同作为幂等校验信息，这样短时间内相同方法+相同参数就会被拦截。此外，由于方法名称+入参的字符串可能会太长，会影响redis的效率，故此将其进行MD5加密。之前自定义的Idempotent注解使用的 Key 解析器可以扩展，这里实现了2种，一种是利用SPEL进行校验，匹配入参中的某些字段，另一种是方法名称+入参实现，匹配所有入参。SPEL方式public class SPelIdempotentKeyResolver implements IdempotentKeyResolver { private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer(); private final ExpressionParser expressionParser = new SpelExpressionParser(); @Override public String resolver(JoinPoint joinPoint, Idempotent idempotent) { // 获得被拦截方法参数名列表 Method method = getMethod(joinPoint); Object[] args = joinPoint.getArgs(); String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method); // 准备 Spring EL 表达式解析的上下文 StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); if (ArrayUtil.isNotEmpty(parameterNames)) { for (int i = 0; i < parameterNames.length; i++) { evaluationContext.setVariable(parameterNames[i], args[i]); } } // 解析参数 Expression expression = expressionParser.parseExpression(idempotent.keyArg()); return expression.getValue(evaluationContext, String.class); } private static Method getMethod(JoinPoint point) { // 处理，声明在类上的情况 MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); if (!method.getDeclaringClass().isInterface()) { return method; } // 处理，声明在接口上的情况 try { return point.getTarget().getClass().getDeclaredMethod( point.getSignature().getName(), method.getParameterTypes()); } catch (NoSuchMethodException e) { throw new RuntimeException(e); } } }全部匹配方式public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver { @Override public String resolver(JoinPoint joinPoint, Idempotent idempotent) { String methodName = joinPoint.getSignature().toString(); // 参数 Object[] args = joinPoint.getArgs(); // 参数窜 String argsStr = StrUtil.join(",", args); // 使用md5 加密 保证key不会过长 return SecureUtil.md5(methodName + argsStr); } }
前言首先来了解下什么是Dockerfile：Dockerfile是一个包含命令和指令的脚本文件，用于构建Docker镜像。它包含了构建镜像的全部指令，可以使得从头开始构建一个完整的Docker镜像变得十分容易。以下是Dockerfile的使用方法：创建Dockerfile文件：在项目目录中使用文本编辑器创建Dockerfile文件并编写相应的指令。编写Dockerfile指令：使用Dockerfile指令来构建镜像，例如FROM、RUN、COPY、CMD等等。构建镜像：使用docker build命令构建镜像。例如：docker build -t imagename .运行容器：使用docker run命令运行容器。例如：docker run -d -p 8080:8080 imagename。打包和分享镜像：可以使用docker save和docker load命令来打包和分享镜像。以上是对dockerfile的一般理解和使用方法。前提一个正常可打包的Spring Bootweb项目一个可用的云端镜像仓库一个可以本地连接使用的docker正文创建Dockerfile首先在项目的根目录里创建Dockerfile文件，通过这个文件我们可以将我们的jar包打包进Docker镜像，随着镜像启动的同时，将我们的jar包也启动# 该镜像需要依赖的基础镜像 FROM openjdk:8u322-oracle # 将当前目录下的jar包复制到docker容器的/目录下 ADD target/my-demo.jar /my-demo.jar # 运行过程中创建一个my-demo.jar文件 RUN bash -c 'touch /my-demo.jar' # 声明服务运行在10316端口 EXPOSE 10318 # 指定docker容器启动时运行jar包 ENTRYPOINT ["java","-jar","/my-demo.jar","-Dfile.encoding=utf-8"] # 指定维护者的名字 MAINTAINER xinwei以上的操作是使用jdk8版本的程序，如果使用其他如jdk17版本，可以直接改变jdk的镜像版本即可。这样我们就可以利用命令docker build -t my-demo .将程序打包在本地里，随着容器启动，容器内的jar包也会启动。但是这只是在我们的本地打包成docker镜像，如果手动迁移镜像到云服务器上，或者项目运行的服务器和镜像服务器不是同一台机器，手动操作就很繁琐了，故此继续改造maven，使得maven可以直接打包成镜像并上传到仓库。改造Maven首先介绍一个打包docker镜像的maven插件——Docker Maven Plugin，这时一个旨在为Java应用程序的开发人员提供方便和高效的Docker容器化过程的插，它允许Maven用户使用标准Maven构建方式来创建和构建Docker镜像，并支持将配置文件和资源文件打包到Docker镜像中，以便于部署和运行Java应用程序。Docker Maven Plugin提供了一系列目标和插件，包括：docker:build：用于构建Docker镜像的目标。docker:start和docker:stop：用于启动和停止Docker容器的目标。docker:push：用于将Docker镜像推送到远程仓库的目标。dockerfile:build：使用自定义Dockerfile文件构建Docker镜像的目标。dockerfile:tag：用于将Docker镜像标记为指定的标签的目标。dockerfile:push：将Docker镜像推送到指定的Docker仓库的目标。Docker Maven Plugin还提供了一些其他功能，如能够自动化地在Docker容器中运行Java应用程序，并提供了一些命令行参数和配置选项，以帮助开发人员定制和管理Docker镜像和容器。github地址：fabric8io/docker-maven-plugin: Maven plugin for running and creating Docker images (github.com)将其改造在pom文件里： <build> <finalName>${project.artifactId}</finalName> <resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> </resource> </resources> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.9.0</version> <configuration> ${java.version} <target>${java.version}</target> <encoding>${project.build.sourceEncoding}</encoding> <annotationProcessorPaths> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </path> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>${mapstruct.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring-boot-maven-plugin.version}</version> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>io.fabric8</groupId> <artifactId>docker-maven-plugin</artifactId> <version>0.40.1</version> <configuration> <pushRegistry>swr.cn-south-1.xxxxcloud.com</pushRegistry> <images> <image> <name>swr.cn-south-1.xxxxcloud.com/${jenkins.service.name}/${project.name}</name> <build> <dockerFileDir>${project.basedir}</dockerFileDir> <tags> <tag>latest</tag> <tag>%T</tag> </tags> </build> </image> </images> </configuration> </plugin> </plugins> </build>其中，<tags> <tag>latest</tag> <tag>%T</tag> </tags>这里的意思是同时打包2个镜像，一个镜像使用版本latest，一个镜像使用时间戳来标记版本，这样，我们的流水线只需要使用image:latest即一直使用latest版本即可，因为相同版本的镜像会覆盖，这样我们的latest就会保持最新，同时还会有一个时间戳版本的镜像用于回滚。还有一个要注意的点就是使用这个插件的机器需要提前登录镜像仓库，即使用docker login xxx命令的操作，这个操作各云服务镜像仓库都给了详细的说明，这样我们就获取了打包到云上仓库的功能了，配合流水线启动美滋滋。最后我们只需要在流水线脚本或者本地机器上使用mvn clean package docker:build docker:push命令即可完成docker镜像从打包到推送的全过程了。
前言springcloud项目通常会遇到以下2个问题：配置项目redis组件时，有时会遇到一些序列化的问题，如当使用jackson的时，使用@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)注解，会报这个实体不是一个数组的问题集成通用redis依赖组件，可以让所有的项目共同依赖默认一个redis过期时间，更好的利用redis资源正文项目依赖Springboot : 2.6.3Springcloud: 2021.0.1Springcoud Alibaba: 2021.0.1.0redis依赖<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>framework-common</artifactId> <groupId>com.xxx</groupId> <version>2.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> ​ <artifactId>framework-common-redis</artifactId> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies> </project>配置redis序列@Configuration @AutoConfigureBefore(RedisAutoConfiguration.class) public class BaseRedisConfig { ​ @Bean @ConditionalOnMissingBean(name = "redisTemplate") public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(factory); redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setHashKeySerializer(RedisSerializer.string()); redisTemplate.setValueSerializer(RedisSerializer.json()); redisTemplate.setHashValueSerializer(RedisSerializer.json()); return redisTemplate; } ​ } ​ /** * redis 默认缓存时间 */ @Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) { RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory); //设置Redis缓存有效期为7天 RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() // 使的reids注解缓存的key中 "::"2个冒号转换成":"一个冒号，统一redis key的风格 .computePrefixWith(name -> name + ":") .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer())).entryTtl(Duration.ofDays(7)); return new RedisCacheManager(redisCacheWriter, redisCacheConfiguration); } }代码解释@AutoConfigureBefore注解是Spring Boot的一个注解，用于指定一个配置类在另外一个配置类之前自动配置，以确保它们的顺序正确。它的作用是控制自动配置的顺序，以便在自动配置时确定配置类的优先级。使用@AutoConfigureBefore注解需要在配置类上加上该注解，并指定目标配置类的全限定名或Class对象。例如，我们可以在自定义的Redis配置类上使用@AutoConfigureBefore注解，指定目标为框架提供的默认Redis配置类，使自定义Redis配置类在框架默认Redis配置类之前进行自动配置。示例代码如下所示：@Configuration @AutoConfigureBefore(RedisAutoConfiguration.class) public class CustomRedisConfiguration { // 自定义 Redis 配置 }在上述代码中，我们在配置类CustomRedisConfiguration上使用@AutoConfigureBefore注解，指定目标为Spring Boot框架提供的默认Redis配置类RedisAutoConfiguration，以确保自定义的Redis配置类在框架默认Redis配置类之前自动配置。总而言之，@AutoConfigureBefore注解可以帮助我们控制自动配置的顺序，确保在自动配置时配置类的优先级正确。RedisSerializer.string()实际为StringRedisSerializer.UTF_8，获取一个简单的 String byte[]（和返回）序列化程序，使用 UTF-8 作为默认值，简单来讲，就是key必须是String类型，这个在使用BoundXXXOperations方法时，若key不为String类型，则会抛出错误。redisTemplate.setValueSerializer(RedisSerializer.json()); redisTemplate.setHashValueSerializer(RedisSerializer.json());以上两句代码指将普通数据类型的value和哈希结构的value值使用json序列化，看下具体内容：static RedisSerializer<Object> json() { return new GenericJackson2JsonRedisSerializer(); }实际上是通过了jackson来进行序列化和逆序列化。使用json序列化的好处是可以使用@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)来序列化bean，这个注解在入库或者缓存后，会加上@class，使得逆序列化更为准确，且可实现多态逆序列化。computePrefixWith(name -> name + ":")可以让使得springboot-data-reids注解缓存的key中的::两个冒号转换成:一个冒号，统一redis key的风格，更好的管理redis键值。xxx::date::1234 转变成了 xxx:data:1234添加spring.factoriesspring.factories 是 Spring 框架中的一个特殊文件，在 Spring Boot 中被广泛使用。它的作用是使开发者可以为自己的应用程序添加自定义配置，例如注册自定义的 Bean 或者自定义的 Starter。具体地说，spring.factories 文件中包含了多个键值对，其中键是某个接口或者类的全限定名，而值是对应该接口或类实现的工厂类的全限定名。当 Spring Boot 启动时，会扫描项目中的所有 spring.factories 文件，并读取其中的配置信息，最终根据这些配置信息初始化并启动Spring 上下文。Spring Boot 中的 Starter 就是通过使用 spring.factories 实现的。我们可以构建一个 Starter（一个所需依赖的组合）并定义其spring.factories 文件来注册自定义的自动配置。这样，当使用该 Starter 的应用程序启动时，Spring Boot就会根据 spring.factories 中的配置信息自动装配所需的 Bean，为提供了开发者非常方便的方式来为自己的应用程序添加自定义配置。在resource下新建META-INF目录，在META-INF目录下新建spring.factories文件，在文件里加入之前自定义的BaseRedisConfig.org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.xxx.common.redis.config.BaseRedisConfig打包maven clean install使用其他项目引入依赖framework-common-redisyml文件配置redis：spring: redis: database: 0 port: 6379 host: localhost password: L1lvlrGZV6Kl0bhS lettuce: pool: # 连接池最大连接数 max-active: 200 # 连接池最大阻塞等待时间（使用负值表示没有限制） max-wait: -1ms # 连接池中的最大空闲连接 max-idle: 10 # 连接池中的最小空闲连接 min-idle: 0ps：若使用 Spring boot3则pom文件为spring: data: redis: database: 0 port: 6379 host: localhost password: L1lvlrGZV6Kl0bhS lettuce: pool: # 连接池最大连接数 max-active: 200 # 连接池最大阻塞等待时间（使用负值表示没有限制） max-wait: -1ms # 连接池中的最大空闲连接 max-idle: 10 # 连接池中的最小空闲连接 min-idle: 0