当前位置: 首页 > news >正文

Spring配置文件中:密码明文改为密文处理方式(通用方法)

目录

一、背景    

二、思路

A) 普通方式

B) 适合bootstrap.properties方式

三、示例

A) 普通方式(连接Redis集群)

A) 普通方式(连接RocketMQ)

B) 适合bootstrap.properties方式

四、总结


一、背景    

  SpringBoot和SpringCloud中涉及多个配置文件,配置文件中对于密码默认是明文方式,这种方式在生产环境一般是不被允许的。为避免配置文件中出现明文,应当在配置文件中配置为密文,然后在启动时在程序内部完成解密。

    本文提供了通用的处理方式,可以适配以下几类配置文件:

  • 本地bootstrap.properties   在Spring的Bean创建之前的配置
  • 本地application.properties   在Spring的配置,包括带profile环境的配置
  • 配置中心上的配置(例如nacos上的Data ID)   

   为了适应配置文件涉及密码由明文改为密文,需要分为两步:

①将配置文件中涉及密文的配置项配置为密文字符串(需自己加密计算得到);

②在Spring启动中读取密文字符串并解密还原。

二、思路

       对于以上第②步Spring启动时的处理,由于以上配置文件在Spring加载的时机和生命周期不同,有两种处理方式:

A) 普通方式

      由于Spring中的对本地application.properties或者配置中心上的配置(例如nacos上的Data ID)在Spring Bean创建过程中,会有对应的配置Bean(通过注解@Configuration申明的Java类),Spring会自动根据读取解析配置文件并赋值给Bean。

      因此,若需要对密文字符串并解密还原,可以对配置Bean(通过注解@Configuration申明的Java类)进行继承,Override重写对应的set方法,完成解密。

B) 适合bootstrap.properties方式

      对于Spring Cloud,在bootstrap阶段还未创建Bean,所以以上Override重写对应的set方法并不适用。所以对于bootstrap.properties配置文件。可通过实现EnvironmentPostProcessor接口,来捕获Environment配置,解密后将配置新值设置到Environment中。

三、示例

A) 普通方式(连接Redis集群)

      下面以连接Redis集群为例进行说明,连接Redis集群的配置项可以在本地application.properties或者配置中心上的配置(例如nacos上的Data ID),且其中spring.redis.password配置项值已经设置为密文。

      下面代码对配置Bean(通过注解@Configuration申明的Java类RedisProperties)进行继承,Override重写对应的set方法。Java代码如下:

package 包指定忽略,请自定;import 忽略解密计算工具类SystemSecurityAlgorithm,请自定;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.util.StringUtils;/*** 连接Redis集群的配置类【通过@Configuration覆盖原Bean机制】:*   1、连接Redis的连接password不得出现明文,故需在properties配置文件中配置为加密密文(加密算法Java类为:SystemSecurityAlgorithm),然后在启动时通过本类解密*   2、贵金属应用服务采用多数据中心DataCenter部署。而每逻辑中心均有独立的Redis集群。 应用服务应连接同逻辑中心内的Redis集群,既北京的应用服务不应该连接合肥Redis集群*     既:对于同服务的不同实例,应根据服务实例所在逻辑中心(具体见枚举ServiceConstant.DataCenter定义的逻辑中心)连接相同逻辑中心下的Redis集群。*     因此:*        a).以Spring标准Redis连接配置为基础,对nodes值中各个IP端口配置,在各IP前增加一个大写字母:该IP所在DataCenter数据中心的英文代码*        b).以Spring标准Redis连接配置为基础,对password值改为可配多个密码,以逗号分隔,每个密码前增加一个大写字母,该密码是连接哪个Redis集群的DataCenter数据中心的英文代码* 为支持以上,定制化开发本类,实现处理最终还原至Spring标准连接Redis的配置,以供lettuce创建连接池。*  -----------------------------------------------------------* 机制适用性:* 除了通过@Configuration覆盖原Bean机制,还有通过实现EnvironmentPostProcessor接口机制。两种机制适用性说明如下:*   bootstrap.properties配置文件(bootstrap阶段,还未创建Bean) →→适合→→ 【实现EnvironmentPostProcessor接口机制】*   本地application.properties配置文件(正常SpringBoot启动,通过@Configuration注解的Bean) →→适合→→ 【实现EnvironmentPostProcessor接口机制】和【通过@Configuration覆盖原Bean机制】均可*   从Nacos等配置中心获取得到的配置文件 →→适合→→ 【通过@Configuration覆盖原Bean机制】**/
@Configuration
@Primary // 由于默认RedisProperties作为配置类会自动创建Bean。 为避免存在两个同类型(RedisProperties)Bean,所以本类通过注解Primary,使得只有本类生效。相当于替代默认RedisProperties
public class GjsRedisProperties extends RedisProperties {private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(GjsRedisProperties.class);@Overridepublic void setPassword(String orginPassword) {if(StringUtils.hasText(orginPassword)) {// 对密文解密并设置if (StringUtils.hasText(orginPassword) && orginPassword.length() >= 32 ) { // 如果满足密码密文的长度及大小写要求,视为密文,解密String padStr = SystemSecurityAlgorithm.decryptStr(orginPassword);log.debug("连接Redis配置项spring.redis.password: 解密前orginPassword=[{}], 解密后padStr=[{}]", orginPassword, padStr); //为避免密码泄露,仅debug才输出明文log.info("连接Redis配置项spring.redis.password: 对密文orginPassword=[{}]已完成解密", orginPassword);super.setPassword(padStr);} else { // 不满足密码密文的长度及大小写要求(视为明文,不解密),保持不变log.warn("连接Redis配置项spring.redis.password的:orginPassword=[{}]不满足密码密文的长度及大小写要求(视为明文,不解密),保持不变", orginPassword);super.setPassword(orginPassword);}}}
}

A) 普通方式(连接RocketMQ)

      下面以连接RocketMQ为例进行说明,连接RocketMQ的配置项可以在本地application.properties或者配置中心上的配置(例如nacos上的Data ID),且其中rocketmq.producer.secret-keyrocketmq.consumer.secret-key配置项值已经设置为密文。

      下面代码对配置Bean(通过注解@Configuration申明的Java类RocketMQProperties)进行继承,Override重写对应的set方法。Java代码如下:

package 包指定忽略,请自定;import 忽略解密计算工具类SystemSecurityAlgorithm,请自定;
import org.apache.rocketmq.spring.autoconfigure.RocketMQProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.util.StringUtils;import java.util.HashMap;
import java.util.Map;/*** 连接RocketMQ的配置类【通过@Configuration覆盖原Bean机制】:*   因连接RocketMQ的secret-key不得出现明文,故需在properties配置文件中配置为加密密文(加密算法Java类为:SystemSecurityAlgorithm),然后在启动时通过本类解密*  -----------------------------------------------------------* 机制适用性:* 除了通过@Configuration覆盖原Bean机制,还有通过实现EnvironmentPostProcessor接口机制。两种机制适用性说明如下:*   bootstrap.properties配置文件(bootstrap阶段,还未创建Bean) →→适合→→ 【实现EnvironmentPostProcessor接口机制】*   本地application.properties配置文件(正常SpringBoot启动,通过@Configuration注解的Bean) →→适合→→ 【实现EnvironmentPostProcessor接口机制】和【通过@Configuration覆盖原Bean机制】均可*   从Nacos等配置中心获取得到的配置文件 →→适合→→ 【通过@Configuration覆盖原Bean机制】**/
@Configuration
@Primary // 由于默认RocketMQProperties作为配置类会自动创建Bean。 为避免存在两个同类型(RocketMQProperties)Bean,所以本类通过注解Primary,使得只有本类生效。相当于替代默认RocketMQProperties
public class GjsRocketMQProperties extends RocketMQProperties {final private String KEYNAME_PRODUCER_SECRET = "rocketmq.producer.secret-key";final private String KEYNAME_CONSUMER_SECRET = "rocketmq.consumer.secret-key";@AutowiredConfigurableApplicationContext springContext;private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(GjsRocketMQProperties.class);@Overridepublic void setProducer(Producer producer) {final String orginSecretKey = producer.getSecretKey();// 对密文解密并设置if (StringUtils.hasText(orginSecretKey) && orginSecretKey.length() >= 32) { // 如果满足密码密文的长度及大小写要求,视为密文,解密String padStr = SystemSecurityAlgorithm.decryptStr(orginSecretKey);log.debug("连接RocketMQ配置项{}: 解密前orginSecretKey=[{}], 解密后padStr=[{}]", KEYNAME_PRODUCER_SECRET, orginSecretKey, padStr); //为避免密码泄露,仅debug才输出明文log.info("连接RocketMQ配置项{}: 对密文orginSecretKey=[{}]已完成解密", KEYNAME_PRODUCER_SECRET, orginSecretKey);producer.setSecretKey(padStr);// 由于RocketMQ在构建DefaultRocketMQListenerContainer过程中,会从Spring的Environment中获取配置。// 附调用关系简要说明如下://     org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer.afterPropertiesSet()//       org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer.initRocketMQPushConsumer()//         org.apache.rocketmq.spring.support.RocketMQUtil.getRPCHookByAkSk()//           org.springframework.core.env.AbstractEnvironment.resolveRequiredPlaceholders()//             ......//               org.springframework.boot.context.properties.source.ConfigurationPropertySourcesPropertyResolver.findPropertyValue()// 因此一并修改环境中的值,使其能取得新值modifyEnvironmentValue(springContext.getEnvironment(), KEYNAME_PRODUCER_SECRET, padStr);} else { // 不满足密码密文的长度及大小写要求(视为明文,不解密),保持不变log.warn("连接RocketMQ配置项rocketmq.producer.secret-key值=[{}]不满足密码密文的长度及大小写要求(视为明文,不解密),保持不变", orginSecretKey);}super.setProducer(producer);}@Overridepublic void setConsumer(PushConsumer pushConsumer) {final String orginSecretKey = pushConsumer.getSecretKey();// 对密文解密并设置if (StringUtils.hasText(orginSecretKey) && orginSecretKey.length() >= 32 ) { // 如果满足密码密文的长度及大小写要求,视为密文,解密String padStr = SystemSecurityAlgorithm.decryptStr(orginSecretKey);log.debug("连接RocketMQ配置项{}: 解密前orginSecretKey=[{}], 解密后padStr=[{}]", KEYNAME_CONSUMER_SECRET, orginSecretKey, padStr); //为避免密码泄露,仅debug才输出明文log.info("连接RocketMQ配置项{}: 对密文orginSecretKey=[{}]已完成解密", KEYNAME_CONSUMER_SECRET, orginSecretKey);pushConsumer.setSecretKey(padStr);// 由于RocketMQ在构建DefaultRocketMQListenerContainer过程中,会从Spring的Environment中获取配置。// 附调用关系简要说明如下://     org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer.afterPropertiesSet()//       org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer.initRocketMQPushConsumer()//         org.apache.rocketmq.spring.support.RocketMQUtil.getRPCHookByAkSk()//           org.springframework.core.env.AbstractEnvironment.resolveRequiredPlaceholders()//             ......//               org.springframework.boot.context.properties.source.ConfigurationPropertySourcesPropertyResolver.findPropertyValue()// 因此一并修改环境中的值,使其能取得新值modifyEnvironmentValue(springContext.getEnvironment(), KEYNAME_CONSUMER_SECRET, padStr);} else { // 不满足密码密文的长度及大小写要求(视为明文,不解密),保持不变log.warn("连接RocketMQ配置项{}的值=[{}]不满足密码密文的长度及大小写要求(视为明文,不解密),保持不变", KEYNAME_CONSUMER_SECRET, orginSecretKey);}super.setConsumer(pushConsumer);}@Overridepublic void setPullConsumer(PullConsumer pullConsumer) {final String orginSecretKey = pullConsumer.getSecretKey();// 对密文解密并设置if (StringUtils.hasText(orginSecretKey) && orginSecretKey.length() >= 32 ) { // 如果满足密码密文的长度及大小写要求,视为密文,解密String padStr = SystemSecurityAlgorithm.decryptStr(orginSecretKey);log.debug("连接RocketMQ配置项{}: 解密前orginSecretKey=[{}], 解密后padStr=[{}]", KEYNAME_CONSUMER_SECRET, orginSecretKey, padStr); //为避免密码泄露,仅debug才输出明文log.info("连接RocketMQ配置项{}: 对密文orginSecretKey=[{}]已完成解密", KEYNAME_CONSUMER_SECRET, orginSecretKey);pullConsumer.setSecretKey(padStr);// 由于RocketMQ在构建DefaultRocketMQListenerContainer过程中,会从Spring的Environment中获取配置。// 附调用关系简要说明如下://     org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer.afterPropertiesSet()//       org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer.initRocketMQPushConsumer()//         org.apache.rocketmq.spring.support.RocketMQUtil.getRPCHookByAkSk()//           org.springframework.core.env.AbstractEnvironment.resolveRequiredPlaceholders()//             ......//               org.springframework.boot.context.properties.source.ConfigurationPropertySourcesPropertyResolver.findPropertyValue()// 因此一并修改环境中的值,使其能取得新值modifyEnvironmentValue(springContext.getEnvironment(), KEYNAME_CONSUMER_SECRET, padStr);} else { // 不满足密码密文的长度及大小写要求(视为明文,不解密),保持不变log.warn("连接RocketMQ配置项{}的值=[{}]不满足密码密文的长度及大小写要求(视为明文,不解密),保持不变", KEYNAME_CONSUMER_SECRET, orginSecretKey);}super.setPullConsumer(pullConsumer);}/*** 对Spring的Environment的配置项的值修改为新值* @param environment Spring的Environment对象* @param keyName 配置项名* @param newValue 新值*/private void modifyEnvironmentValue(ConfigurableEnvironment environment, final String keyName, String newValue) {if(!environment.containsProperty(keyName)) {log.warn("当前Spring的environment中不存在名为{}的配置项", keyName);return;}if(environment.getProperty(keyName, "").equals(newValue)) {log.debug("当前Spring的environment中配置项{}的值已与新值相同,无需修改", keyName);return;}Map<String, Object> map = new HashMap<>(); //用于存放新值map.put(keyName, newValue);// 若有map有值,则把该map作为PropertySource加入列表中,以实现:把environment中对应key的value覆盖为新值// 必须加到First并且不能存在两个相同的Name的MapPropertySource,值覆盖才能生效environment.getPropertySources().addFirst(new MapPropertySource("modifyEnvironmentValue-"+keyName, map));log.info("已对Spring的Environment的配置项{}的值修改为新值", keyName);}
}

B) 适合bootstrap.properties方式

      下面以连接Nacos配置中心为例进行说明,需要在本地bootstrap.properties配置文件中指定连接Nacos配置中心的Nacos用户名、密码、服务端地址、Data ID等信息。bootstrap.properties配置文件有关连接Nacos配置中心类似如下:

#Nacos配置中心及注册中心的authenticate鉴权用户名和密码(需Nacos服务端开启auth鉴权)
spring.cloud.nacos.username=nacos
spring.cloud.nacos.password=760dee29f9fc82af0cc1d6074879dc39
#Nacos配置中心服务端的地址和端口(形式ip:port,ip:port,...) 。注:nacos-client1.x会按顺序选其中地址进行连接(前个连接失败则自动选后一个)。nacos-client2.x会随机选其中地址进行连接(若连接失败则自动另选)
spring.cloud.nacos.config.server-addr=ip1:8848,ip2:8848,ip3:8848,ip4:8848#Data ID的前缀(如果不设置,则默认取 ${spring.application.name})
#spring.cloud.nacos.config.prefix=
#默认指定为开发环境
#spring.profiles.active=
#Nacos命名空间,此处不设置,保持默认
#spring.cloud.nacos.config.namespace=
#配置组(如果不设置,则默认为DEFAULT_GROUP)
spring.cloud.nacos.config.group=G_CONFIG_GJS_SERVICE
#指定文件后缀(如果不设置,则默认为properties)
spring.cloud.nacos.config.file-extension=properties#以下为全局Data ID
spring.cloud.nacos.config.shared-configs[0].data-id=NacosRegDiscoveryInfo.properties
spring.cloud.nacos.config.shared-configs[0].group=G_CONFIG_GJS_GLOBALSHARED
spring.cloud.nacos.config.shared-configs[0].refresh=truespring.cloud.nacos.config.shared-configs[1].data-id=XXXXX.properties
spring.cloud.nacos.config.shared-configs[1].group=G_CONFIG_GJS_GLOBALSHARED
spring.cloud.nacos.config.shared-configs[1].refresh=truespring.cloud.nacos.config.shared-configs[2].data-id=YYYYY.properties
spring.cloud.nacos.config.shared-configs[2].group=G_CONFIG_GJS_GLOBALSHARED
spring.cloud.nacos.config.shared-configs[2].refresh=true

其中spring.cloud.nacos.password配置项值已经设置为密文。

      下面的代码通过实现EnvironmentPostProcessor接口,来捕获配置,并将配置新值设置到Environment中。Java代码如下:

package 包指定忽略,请自定;import 忽略解密计算工具类SystemSecurityAlgorithm,请自定;
import org.apache.commons.logging.Log;
import org.springframework.boot.ConfigurableBootstrapContext;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.boot.logging.DeferredLogFactory;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.util.StringUtils;import java.util.HashMap;
import java.util.Map;/*** 本类通过实现EnvironmentPostProcessor接口,实现在Spring启动过程中从environment中读取指定的key值,处理后,然后把environment中对应key的value覆盖为新值。* 通过本类已经实现对bootstrap阶段的配置文件处理:*   因连接Nacos的password不得出现明文,故bootstrap配置文件中为加密密文(加密算法Java类为:SystemSecurityAlgorithm),然后在启动时通过本类解密* -----------------------------------------------------------* 注意:*   a) 需要在META-INF下的spring.factories文件中配置本类后,本类才会生效(才被Spring扫描识别到)*   b) 因为本类是通过实现EnvironmentPostProcessor接口方式,所以本类在SpringCloud启动过程中会被调用两次:*         首先是在bootstrap配置文件加载后(SpringCloud为支持配置中心的bootstrap阶段)*         其次是在application配置文件加载后(SpringBoot的正常启动时加载配置文件阶段)* 机制适用性:* 除了通过实现EnvironmentPostProcessor接口机制,还有通过@Configuration覆盖原Bean机制。两种机制适用性说明如下:*   bootstrap.properties配置文件(bootstrap阶段,还未创建Bean) →→适合→→ 【实现EnvironmentPostProcessor接口机制】*   本地application.properties配置文件(正常SpringBoot启动,通过@Configuration注解的Bean) →→适合→→ 【实现EnvironmentPostProcessor接口机制】和【通过@Configuration覆盖原Bean机制】均可*   从Nacos等配置中心获取得到的配置文件 →→适合→→ 【通过@Configuration覆盖原Bean机制】**/
public class GjsEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {/*** The default order for the processor.  值越小,优先级越高* 因bootstrap配置文件是通过{@link org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor}完成加载处理* 由于本EnvironmentPostProcessor类需等待SpringCloud对bootstrap配置文件后才能执行,所以本EnvironmentPostProcessor类优先级需更低*/public static final int ORDER = Ordered.HIGHEST_PRECEDENCE + 50;private final DeferredLogFactory logFactory;private final Log logger;public GjsEnvironmentPostProcessor(DeferredLogFactory logFactory,ConfigurableBootstrapContext bootstrapContext) {this.logFactory = logFactory;this.logger = logFactory.getLog(getClass());}@Overridepublic int getOrder() {return ORDER;}/*** 从environment中读取指定的key,并进行解密,解密后的结果放入map对象中* @param environment 已经有的Spring环境* @param keyName 指定的key名* @param map 若完成解密,则将解密后的结果放入map对象*/private void decodePwd(ConfigurableEnvironment environment, String keyName, Map<String, Object> map ) {if(!environment.containsProperty(keyName)) {this.logger.debug("EnvironmentPostProcessor 当前Spring的environment中不存在名为"+keyName+"的配置项");return;}final String origalValue = environment.getProperty(keyName);// 对密文解密并设置if (StringUtils.hasText(origalValue) && origalValue.length() >= 32) { // 如果满足密码密文的长度及大小写要求,视为密文,解密String padStr = SystemSecurityAlgorithm.decryptStr(origalValue);this.logger.debug("EnvironmentPostProcessor 配置项"+keyName+"原值=["+origalValue+"], 解密后值=["+padStr+"]"); //为避免在日志中密码泄露,仅debug才输出明文this.logger.info("EnvironmentPostProcessor 配置项"+keyName+"原值=["+origalValue+"]已完成解密");map.put(keyName, padStr);}else {this.logger.warn("EnvironmentPostProcessor 配置项"+keyName+"值=["+origalValue+"]不满足密码密文的长度及大小写要求(视为明文,不解密),保持不变");}}@Overridepublic void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {this.logger.debug("EnvironmentPostProcessor before PropertySources size=" + environment.getPropertySources().size());this.logger.debug("EnvironmentPostProcessor before PropertySources : " + environment.getPropertySources());Map<String, Object> map = new HashMap<>(); //用于存放新值decodePwd(environment, "spring.cloud.nacos.password", map);if(!map.isEmpty()) {// 若有map有值,则把该map作为PropertySource加入列表中,以实现:把environment中对应key的value覆盖为新值// 必须加到First并且不能存在两个相同的Name的MapPropertySource,值覆盖才能生效environment.getPropertySources().addFirst(new MapPropertySource("afterDecodePassword", map));}this.logger.debug("EnvironmentPostProcessor after PropertySources size=" + environment.getPropertySources().size());this.logger.debug("EnvironmentPostProcessor after PropertySources : " + environment.getPropertySources());}}

四、总结

      通过以上两种方式,可解决Spring各类配置文件对配置密文的适配和处理。

      同时不仅仅用于密文,凡是需对配置文件的内容在启动时进行改变情况都可以按以上方式进行处理。例如启动时对配置项值中多个IP进行动态使用等情形。

http://www.lryc.cn/news/520195.html

相关文章:

  • Linux下ext2文件系统
  • BUUCTF:web刷题记录(1)
  • 【微服务】面试题 6、分布式事务
  • 【2024年华为OD机试】(C卷,100分)- 分割均衡字符串 (Java JS PythonC/C++)
  • Spring Data Elasticsearch简介
  • GESP202312 四级【小杨的字典】题解(AC)
  • 键盘过滤驱动
  • dolphinscheduler2.0.9升级3.1.9版本问题记录
  • 【权限管理】Apache Shiro学习教程
  • 9.4 visualStudio 2022 配置 cuda 和 torch (c++)
  • python特殊参数
  • Ubuntu系统Qt的下载、安装及入门使用,图文详细,内容全面
  • elasticsearch集群部署
  • 初学stm32 --- DAC模数转换器工作原理
  • 保证Mysql数据库到ES的数据一致性的解决方案
  • Flutter Xcode 16+ iOS 18.1 使用image_pickers无法弹出选择图片的视图问题
  • socket网络编程-TC/IP方式
  • 《分布式光纤测温:解锁楼宇安全的 “高精度密码”》
  • C语言基本知识复习浓缩版:数组
  • Python贪心
  • rk3568 内核态OOM内存泄漏kmemleak使用
  • ASP.NET Core - 日志记录系统(二)
  • 阿里云直播互动Web
  • 解锁无证身份核验:开启便捷安全新征程
  • [DO374] Ansible 配置文件
  • 【杂谈】-50+个生成式人工智能面试问题(四)
  • RuoYi Cloud项目解读【四、项目配置与启动】
  • 51c~Pytorch~合集5
  • 【芯片封测学习专栏 -- 什么是 Chiplet 技术】
  • Java SpringBoot + Vue + Uniapp 集成JustAuth 最快实现多端三方登录!(QQ登录、微信登录、支付宝登录……)