詳解Mybatis攔截器安全加解密MySQL數(shù)據(jù)實戰(zhàn)
需求背景
- 公司為了通過一些金融安全指標(政策問題)和防止數(shù)據(jù)泄漏,需要對用戶敏感數(shù)據(jù)進行加密,所以在公司項目中所有存儲了用戶信息的數(shù)據(jù)庫都需要進行數(shù)據(jù)加密改造。包括Mysql、redis、mongodb、es、HBase等。
- 因為在項目中是使用springboot+mybatis方式連接數(shù)據(jù)庫進行增刪改查,并且項目是中途改造數(shù)據(jù)。所以為了不影響正常業(yè)務,打算這次改動盡量不侵入到業(yè)務代碼,加上mybatis開放的各種攔截器接口,所以就以此進行改造數(shù)據(jù)。
- 本篇文章講述如何在現(xiàn)有項目中盡量不侵入業(yè)務方式進行Mysql加密數(shù)據(jù),最后為了不降低查詢性能使用了注解,所以最后還是部分侵入業(yè)務。
Mybatis攔截器
Mybatis只能攔截指定類里面的方法:Executor、ParameterHandler、StatementHandler、ResultSetHandler。
- Executor:攔截執(zhí)行器方法;
- ParameterHandler:攔截參數(shù)方法;
- StatementHandler:攔截sql構建方法;
- ResultSetHandler:攔截查詢結果方法;
Mybatis提供的攔截器接口Interceptor
public interface Interceptor { Object intercept(Invocation invocation) throws Throwable; default Object plugin(Object target) { return Plugin.wrap(target, this); } default void setProperties(Properties properties) { // NOP } }
- Object intercept():代理對象都會調用的方法,這里可以執(zhí)行自定義攔截處理;
- Object plugin():可以用于判斷攔截器執(zhí)行類型;
- void setProperties():指定配置文件的屬性;
自定義攔截器中除了要實現(xiàn)Interceptor接口,還需要添加@Intercepts注解指定攔截對象。@Intercepts注解需配合@Signature注解使用
@Intercepts注解可以指定多個@Signature,type指定攔截類,method指定攔截方法,args攔截方法里的參數(shù)類型。
/** * @author Clinton Begin */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Intercepts { Signature[] value(); } /** * @author Clinton Begin */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target({}) public @interface Signature { Class<?> type(); String method(); Class<?>[] args(); }
案例實戰(zhàn)
依據(jù)上述的mybatis攔截器的使用,下面就把實戰(zhàn)案例代碼提供一下。
Mybatis自定義攔截器
- 在業(yè)務代碼里用戶信息是以明文傳遞的,所以為了不改動業(yè)務代碼,那么需要攔截器在插入或查詢數(shù)據(jù)庫數(shù)據(jù)前先加密,查詢結果解密操作。
- 首先搭建一個springboot的項目,這里指定兩個mybatis攔截器,一個攔截請求參數(shù),一個攔截響應數(shù)據(jù),并把攔截器注入到spring容器內。
/** * 對mybatis入?yún)⑦M行攔截加密 * @author zrh */ @Slf4j @Component @Intercepts(@Signature(type = ParameterHandler.class, method = "setParameters", args = {PreparedStatement.class})) public class MybatisEncryptInterceptor implements Interceptor { @Resource private com.mysql.web.mybatis.Interceptor.MybatisCryptHandler handler; @Override public Object intercept (Invocation invocation) { return invocation; } @SneakyThrows @Override public Object plugin (Object target) { if (target instanceof ParameterHandler) { // 對請求參數(shù)進行加密操作 handler.parameterEncrypt((ParameterHandler) target); } return target; } @Override public void setProperties (Properties properties) { } }
注意:ResultSetHandler對象對增刪改方法沒有攔截,需要增加Executor對象;
/** * 對mybatis查詢結果進行攔截解密,并對請求參數(shù)進行攔截解密還原操作 * @author zrh */ @Slf4j @Component @Intercepts({ @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class}), @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}), }) public class MybatisDecryptInterceptor implements Interceptor { @Resource private MybatisCryptHandler handler; @Override public Object intercept (Invocation invocation) throws Exception { // 獲取執(zhí)行mysql執(zhí)行結果 Object result = invocation.proceed(); if (invocation.getTarget() instanceof Executor) { // 對增刪改操作方法的請求參數(shù)進行解密還原操作 checkEncryptByUpdate(invocation.getArgs()); return result; } // 對查詢方法的請求參數(shù)進行解密還原操作 checkEncryptByQuery(invocation.getTarget()); // 對查詢結果進行解密 return handler.resultDecrypt(result); } @Override public Object plugin (Object target) { return Plugin.wrap(target, this); } @Override public void setProperties (Properties properties) { } /** * 對請求參數(shù)進行解密還原操作 * @param target */ private void checkEncryptByQuery (Object target) { try { final Class<?> targetClass = target.getClass(); final Field parameterHandlerFiled = targetClass.getDeclaredField("parameterHandler"); parameterHandlerFiled.setAccessible(true); final Object parameterHandler = parameterHandlerFiled.get(target); final Class<?> parameterHandlerClass = parameterHandler.getClass(); final Field parameterObjectField = parameterHandlerClass.getDeclaredField("parameterObject"); parameterObjectField.setAccessible(true); final Object parameterObject = parameterObjectField.get(parameterHandler); handler.decryptFieldHandler(parameterObject); } catch (Exception e) { log.error("對請求參數(shù)進行解密還原操作異常:", e); } } /** * 對請求參數(shù)進行解密還原操作 * @param args */ private void checkEncryptByUpdate (Object[] args) { try { Arrays.stream(args).forEach(handler::decryptFieldHandler); } catch (Exception e) { log.error("對請求參數(shù)進行解密還原操作異常:", e); } } }
在上述攔截器中,除了對入?yún)⑦M行加密和查詢結果解密操作外,還多了一步對請求參數(shù)進行解密還原操作。
這是因為對請求參數(shù)進行加密操作時改動的是原對象,如果不還原解密數(shù)據(jù),這個對象如果在后續(xù)還有其他操作,那就會使用密文,導致數(shù)據(jù)紊亂。
這里其實想過不改動原對象,而是把原請求對象克隆一份,在克隆對象上進行加密,然后在去查詢數(shù)據(jù)庫??上Э赡苁亲约簩ybatis不夠熟悉吧,試了很久也不能把mybatis內的原對象替換為克隆對象,所以才就想了這個還原解密參數(shù)的方式。
如果對請求參數(shù)對象和查詢結果對象里的所有字段都進行加解密,那上述配置就基本完成。但在本次安全加解密需求中只針對指定字段(如手機號和真實姓名),現(xiàn)在這種全量字段加解密就不行,而且性能也低,畢竟加解密是很耗費服務器CPU運算資源的。
所以需要增加注解,在指定對象的屬性字段才進行加解密。
/** * <p>作用于類:標識當前實體需要進行結果解密操作. * <p>作用于字段:標識當前實體的字段需要進行加解密操作. * <p>作用于方法:標識當前mapper方法會被切面進行攔截,并進行數(shù)據(jù)的加解密操作. * <p>注意:如果作用于字段,那當前類必須先標注該注解,因為會優(yōu)先判斷類是否需要加解密,然后在判斷字段是否需要加解密,否則只作用于字段不會起效 * * @author zrh * @date 2022/1/4 */ @Documented @Inherited @Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface Crypt { /** * 默認字段需要解密 */ boolean decrypt () default true; /** * 默認字段需要加密 */ boolean encrypt () default true; /** * 字段為對象時有用,默認當前對象不需要進行加解密 */ boolean subObject () default false; /** * 需要進行加密的字段列下標 */ int[] encryptParamIndex () default {}; }
其注解使用方式如下:
AesTools是對數(shù)據(jù)進行AES對稱加解密工具類
/** * AES加密工具 * * @author zrh * @date 2022/1/3 */ @Slf4j public final class AesTools { private AesTools () { } private static final String KEY_ALGORITHM = "AES"; private static final String ENCODING = "UTF-8"; private static final String DEFAULT_CIPHER_ALGORITHM = "AES/ECB/PKCS5Padding"; private static Cipher ENCODING_CIPHER = null; private static Cipher DECRYPT_CIPHER = null; /** * 秘鑰 */ private static final String KEY = "cab041-3c46-fed5"; static { try { // 初始化cipher ENCODING_CIPHER = Cipher.getInstance(DEFAULT_CIPHER_ALGORITHM); DECRYPT_CIPHER = Cipher.getInstance(DEFAULT_CIPHER_ALGORITHM); //轉化成JAVA的密鑰格式 SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes("ASCII"), KEY_ALGORITHM); ENCODING_CIPHER.init(Cipher.ENCRYPT_MODE, keySpec); DECRYPT_CIPHER.init(Cipher.DECRYPT_MODE, keySpec); } catch (Exception e) { log.error("初始化mybatis -> AES加解密參數(shù)異常:", e); } } /** * AES加密 * @param content 加密內容 * @return */ public static String encryptECB (String content) { if (StringUtils.isEmpty(content)) { return content; } String encryptStr = content; try { byte[] encrypted = ENCODING_CIPHER.doFinal(content.getBytes(ENCODING)); encryptStr = Base64.getEncoder().encodeToString(encrypted); } catch (Exception e) { log.info("mybatis -> AES加密出錯:{}", content); } return encryptStr; } /** * AES解密 * @param content 解密內容 * @return */ public static String decryptECB (String content) { if (StringUtils.isEmpty(content)) { return content; } String decryptStr = content; try { byte[] decrypt = DECRYPT_CIPHER.doFinal(Base64.getDecoder().decode(content)); decryptStr = new String(decrypt, ENCODING); } catch (Exception e) { log.info("mybatis -> AES解密出錯:{}", content); } return decryptStr; } }
MybatisCryptHandler是對請求入?yún)ο蠛筒樵兘Y果對象進行加解密操作工具類。
代碼稍許復雜,但實現(xiàn)邏輯簡單,主要為了防止重復加密,內置緩存,對遞歸對象掃描檢索,反射+注解獲取需要加解密字段等。
/** * @author zrh * @date 2022/1/2 */ @Slf4j @Component public class MybatisCryptHandler { private final static ThreadLocal<List> THREAD_LOCAL = ThreadLocal.withInitial(() -> new ArrayList()); private static final List<Field> EMPTY_FIELD_ARRAY = new ArrayList(); /** * Cache for {@link Class#getDeclaredFields()}, allowing for fast iteration. */ private static final Map<Class<?>, List<Field>> declaredFieldsCache = new ConcurrentHashMap<>(256); /** * 參數(shù)對外加密方法 * @param handler */ public void parameterEncrypt (ParameterHandler handler) { Object parameterObject = handler.getParameterObject(); if (null == parameterObject || parameterObject instanceof String) { return; } encryptFieldHandler(parameterObject); removeLocal(); } /** * 參數(shù)加密規(guī)則方法 * @param sourceObject */ private void encryptFieldHandler (Object sourceObject) { if (null == sourceObject) { return; } if (sourceObject instanceof Map) { ((Map<?, Object>) sourceObject).values().forEach(this::encryptFieldHandler); return; } if (sourceObject instanceof List) { ((List<?>) sourceObject).stream().forEach(this::encryptFieldHandler); return; } Class<?> clazz = sourceObject.getClass(); if (!clazz.isAnnotationPresent(Crypt.class)) { return; } if (checkLocal(sourceObject)) { return; } setLocal(sourceObject); try { Field[] declaredFields = clazz.getDeclaredFields(); // 獲取滿足加密注解條件的字段 final List<Field> collect = Arrays.stream(declaredFields).filter(this::checkEncrypt).collect(Collectors.toList()); for (Field item : collect) { item.setAccessible(true); Object value = item.get(sourceObject); if (null != value && value instanceof String) { item.set(sourceObject, AesTools.encryptECB((String) value)); } } } catch (Exception e) { } } /** * 解析注解 - 加密密方法 * @param field * @return */ private boolean checkEncrypt (Field field) { Crypt crypt = field.getAnnotation(Crypt.class); return null != crypt && crypt.encrypt(); } /** * 查詢結果對外解密方法 * @param resultData */ public Object resultDecrypt (Object resultData) { if (resultData instanceof List) { return ((List<?>) resultData).stream().map(this::resultObjHandler).collect(Collectors.toList()); } return resultObjHandler(resultData); } /** * 查詢結果解密規(guī)則方法 * @param result */ private Object resultObjHandler (Object result) { if (null == result) { return null; } Class<?> clazz = result.getClass(); //獲取所有要解密的字段 Field[] declaredFields = getAllFieldsCache(clazz); Arrays.stream(declaredFields).forEach(item -> { try { item.setAccessible(true); Object value = item.get(result); if (null != value && value instanceof String) { item.set(result, AesTools.decryptECB((String) value)); } } catch (Exception e) { log.error("DecryptException -> checkDecrypt:", e); } }); Arrays.stream(declaredFields).filter(item -> checkSubObject(item)).forEach(item -> { item.setAccessible(true); try { Object data = item.get(result); if (data instanceof List) { ((List<?>) data).forEach(this::resultObjHandler); } } catch (IllegalAccessException e) { log.error("DecryptException -> checkSubObject:{}", e); } }); return result; } /** * 解析注解 - 解密方法 * @param field * @return */ private static boolean checkDecrypt (Field field) { Crypt crypt = field.getAnnotation(Crypt.class); return null != crypt && crypt.decrypt(); } /** * 解析注解 - 子對象 * @param field * @return */ private static boolean checkSubObject (Field field) { Crypt crypt = field.getAnnotation(Crypt.class); return null != crypt && crypt.subObject(); } /** * 對請求參數(shù)進行解密還原, * @param requestObject */ public void decryptFieldHandler (Object requestObject) { if (null == requestObject) { return; } if (requestObject instanceof Map) { ((Map<?, Object>) requestObject).values().forEach(this::decryptFieldHandler); return; } if (requestObject instanceof List) { ((List<?>) requestObject).stream().forEach(this::decryptFieldHandler); return; } Class<?> clazz = requestObject.getClass(); if (!clazz.isAnnotationPresent(Crypt.class)) { return; } try { Field[] declaredFields = clazz.getDeclaredFields(); // 獲取滿足加密注解條件的字段 final List<Field> collect = Arrays.stream(declaredFields).filter(this::checkEncrypt).collect(Collectors.toList()); for (Field item : collect) { item.setAccessible(true); Object value = item.get(requestObject); if (null != value && value instanceof String) { item.set(requestObject, AesTools.decryptECB((String) value)); } } } catch (Exception e) { } } /** * 統(tǒng)一管理內存 * @param o * @return */ private boolean checkLocal (Object o) { return THREAD_LOCAL.get().contains(o); } private void setLocal (Object o) { THREAD_LOCAL.get().add(o); } private void removeLocal () { THREAD_LOCAL.get().clear(); } /** * 獲取本類及其父類的屬性的方法 * @param clazz 當前類對象 * @return 字段數(shù)組 */ private static Field[] getAllFields (Class<?> clazz) { List<Field> fieldList = new ArrayList<>(); while (clazz != null) { fieldList.addAll(new ArrayList<>(Arrays.asList(clazz.getDeclaredFields()))); clazz = clazz.getSuperclass(); } Field[] fields = new Field[fieldList.size()]; return fieldList.toArray(fields); } /** * 獲取本類及其父類的屬性的方法 * @param clazz 當前類對象 * @return 字段數(shù)組 */ private static Field[] getAllFieldsCache (Class<?> clazz) { List<Field> fieldList = new ArrayList<>(); while (clazz != null) { if (clazz.isAnnotationPresent(Crypt.class)) { fieldList.addAll(getDeclaredFields(clazz)); } clazz = clazz.getSuperclass(); } Field[] fields = new Field[fieldList.size()]; return fieldList.toArray(fields); } private static List<Field> getDeclaredFields (Class<?> clazz) { List<Field> result = declaredFieldsCache.get(clazz); if (result == null) { try { // 獲取滿足注解解密條件的字段 result = Arrays.stream(clazz.getDeclaredFields()).filter(MybatisCryptHandler::checkDecrypt).collect(Collectors.toList()); // 放入本地緩存 declaredFieldsCache.put(clazz, (result.isEmpty() ? EMPTY_FIELD_ARRAY : result)); } catch (Exception e) { log.error("getDeclaredFields:", e); } } return result; } }
數(shù)據(jù)表準備
用戶的敏感信息包括有手機號、真實姓名、身份證、銀行卡號、支付寶賬號等幾種。下面使用手機號和姓名字段進行加解密案例。
先準備一張Mysql數(shù)據(jù)表,表里有兩個手機號和兩個姓名字段,可以用于安全加解密對比。
CREATE TABLE `phone_data` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵', `phone` varchar(122) DEFAULT NULL COMMENT '明文手機號', `user_phone` varchar(122) DEFAULT NULL COMMENT '密文手機號', `name` varchar(122) DEFAULT NULL COMMENT '明文姓名', `real_name` varchar(122) DEFAULT NULL COMMENT '密文姓名', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='測試加解密數(shù)據(jù)表';
項目demo搭建
首先搭建一個springboot的項目,把一些基礎配置類創(chuàng)建:如controller、service、mapper、xml、entity,為了快速簡易的demo示例,這里去掉service層
/** * @Author: ZRH * @Date: 2022/1/5 13:47 */ @Data public class PhoneData { private Integer id; private String phone; private String userPhone; private String name; private String realName; public static PhoneData build (String phone) { return build(null, phone); } public static PhoneData build (Integer id, String phone) { final PhoneData phoneData = new PhoneData(); phoneData.setId(id); phoneData.setPhone(phone); phoneData.setUserPhone(phone); phoneData.setName(phone); phoneData.setRealName(phone); return phoneData; } } /** * @Author: ZRH * @Date: 2022/1/5 11:55 */ @Slf4j @RestController public class AopMapperController { @Autowired private PhoneDataMapper phoneDataMapper; /** * 添加示例接口 * @param phone * @return */ @PostMapping("/aop/insert") public String insert (@RequestParam String phone) { PhoneData build = PhoneData.build(phone); phoneDataMapper.insert(build); log.info(" 插入的原數(shù)據(jù) = {}", JSON.toJSONString(build)); return "ok"; } /** * 更新示例接口 * @param id * @param phone * @return */ @PostMapping("/aop/update") public String update (@RequestParam Integer id, @RequestParam String phone) { PhoneData build = PhoneData.build(id, phone); phoneDataMapper.updateById(build); log.info(" 插入的原數(shù)據(jù) = {}", JSON.toJSONString(build)); return "ok"; } /** * 查詢示例接口 * @param phone * @return */ @GetMapping("/aop/select") public String select (@RequestParam String phone) { final PhoneData build = PhoneData.build(phone); // 對象類型入?yún)⒉樵儗ο髷?shù)據(jù) List<PhoneData> selectList = phoneDataMapper.selectList(build); log.info(" selectList = {}", JSON.toJSONString(selectList)); return "ok"; } } /** * @Author: ZRH * @Date: 2021/11/25 13:48 */ @Mapper public interface PhoneDataMapper { /** * 新增數(shù)據(jù) * @param phoneData */ @Insert("insert into phone_data (phone, user_phone, name, real_name) values (#{phone}, #{userPhone}, #{name}, #{realName})") void insert (PhoneData phoneData); /** * 更新數(shù)據(jù) * @param phoneData */ @Update("update phone_data set phone = #{phone}, user_phone = #{userPhone}, name = #{name}, real_name = #{realName} where id = #{id}") void updateById (PhoneData phoneData); /** * 無參查詢對象類型數(shù)據(jù) * @return */ @Select("select id, phone, user_phone userPhone, name, real_name realName from phone_data where user_phone = #{userPhone}") List<PhoneData> selectList (PhoneData phoneData); }
項目啟動,訪問添加、更新、查詢接口,其sql日志打印出結果如下:
2022-01-07 14:46:35.348 DEBUG 6688 --- [ XNIO-1 task-1] c.m.web.mapper.PhoneDataMapper.insert : ==> Preparing: insert into phone_data (phone, user_phone, name, real_name) values (?, ?, ?, ?) 2022-01-07 14:46:35.348 DEBUG 6688 --- [ XNIO-1 task-1] c.m.web.mapper.PhoneDataMapper.insert : ==> Parameters: 15222222222(String), ZHlSotVArLBAviP2KWi3Cg==(String), 15222222222(String), ZHlSotVArLBAviP2KWi3Cg==(String) 2022-01-07 14:46:35.421 DEBUG 6688 --- [ XNIO-1 task-1] c.m.web.mapper.PhoneDataMapper.insert : <== Updates: 1 2022-01-07 14:46:35.422 INFO 6688 --- [ XNIO-1 task-1] c.m.web.controller.AopMapperController : 插入的原數(shù)據(jù) = {"name":"15222222222","phone":"15222222222","realName":"15222222222","userPhone":"15222222222"} 2022-01-07 14:46:54.470 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.updateById : ==> Preparing: update phone_data set phone = ?, user_phone = ?, name = ?, real_name = ? where id = ? 2022-01-07 14:46:54.470 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.updateById : ==> Parameters: 15222222222(String), ZHlSotVArLBAviP2KWi3Cg==(String), 15222222222(String), ZHlSotVArLBAviP2KWi3Cg==(String), 1(Integer) 2022-01-07 14:46:54.540 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.updateById : <== Updates: 1 2022-01-07 14:46:54.540 INFO 6688 --- [ XNIO-1 task-1] c.m.web.controller.AopMapperController : 插入的原數(shù)據(jù) = {"id":1,"name":"15222222222","phone":"15222222222","realName":"15222222222","userPhone":"15222222222"} 2022-01-07 14:46:55.754 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.selectList : ==> Preparing: select id, phone, user_phone userPhone, name, real_name realName from phone_data where user_phone = ? 2022-01-07 14:46:55.754 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.selectList : ==> Parameters: ZHlSotVArLBAviP2KWi3Cg==(String) 2022-01-07 14:46:55.790 DEBUG 6688 --- [ XNIO-1 task-1] c.m.w.mapper.PhoneDataMapper.selectList : <== Total: 1 2022-01-07 14:46:55.790 INFO 6688 --- [ XNIO-1 task-1] c.m.web.controller.AopMapperController : selectList = [{"id":1,"name":"15222222222","phone":"15222222222","realName":"15222222222","userPhone":"15222222222"}]
MySQL數(shù)據(jù)庫中的數(shù)據(jù)
總結
總結一下上述實現(xiàn)邏輯:
- 在Mybatis自定義攔截器中,對各種對數(shù)據(jù)庫的查詢參數(shù)進行攔截,判斷當前對象內字段是否需要加密,如果有注解就進行加密操作,否則就不操作。再對數(shù)據(jù)庫查詢出的結果進行攔截,判斷查詢結果對象內字段是否需要解密,如果有注解就進行解密操作,否則就不操作。
- 并且增加了一步對本次請求參數(shù)進行參數(shù)還原解密操作。
- 這樣通過類和字段上增加注解,就完成自動安全加解密操作。
上述攔截器實現(xiàn)方式有一定局限性:
- 整個代碼邏輯還可以在優(yōu)化,比如之前對請求參數(shù)還原解密方式的優(yōu)化。
- 主要實現(xiàn)邏輯是在MybatisCryptHandler處理工具類中,當前方式現(xiàn)只能處理請求參數(shù)和查詢結果是對象類而不是字符串類型,在下篇文章中會介紹如何針對字符串進行過濾攔截。
到此這篇關于詳解Mybatis攔截器安全加解密MySQL數(shù)據(jù)實戰(zhàn)的文章就介紹到這了,更多相關Mybatis攔截器安全加解密MySQL內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Java定時任務schedule和scheduleAtFixedRate的異同
本文主要介紹了Java定時任務schedule和scheduleAtFixedRate的異同,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2023-05-05JDK12的新特性之CompactNumberFormat詳解
這篇文章主要介紹了JDK12的新特性之CompactNumberFormat,本文通過實例代碼給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-05-05elasticsearch索引index數(shù)據(jù)功能源碼示例
這篇文章主要為大家介紹了elasticsearch索引index功能源碼示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-04-04使用SpringMVC訪問Controller接口返回400BadRequest
這篇文章主要介紹了使用SpringMVC訪問Controller接口返回400BadRequest,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-03-03