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

目录

一、背景

二、思路

A) 普通方式 普通方式)

B) 适合bootstrap.properties方式 适合bootstrap.properties方式)

三、示例

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

A) 普通方式(连接RocketMQ) 普通方式(连接RocketMQ))

B) 适合bootstrap.properties方式 适合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代码如下:

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);

    @Override
    public 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代码如下:

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";

    @Autowired
    ConfigurableApplicationContext springContext;

    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(GjsRocketMQProperties.class);

    @Override
    public 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);
    }

    @Override
    public 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);
    }

    @Override
    public 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配置中心类似如下:

bash 复制代码
#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=true

spring.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=true

spring.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代码如下:

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());
    }


    @Override
    public 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+"]不满足密码密文的长度及大小写要求(视为明文,不解密),保持不变");
        }
    }

    @Override
    public 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进行动态使用等情形。

相关推荐
头发那是一根不剩了10 分钟前
java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter
java
小白起 v43 分钟前
三天学完微服务其二
java·微服务·架构
Archy_Wang_11 小时前
ASP.NET Core实现微服务--什么是微服务
后端·微服务·asp.net
huiyunfei1 小时前
MinorGC FullGC
java·jvm·算法
Code侠客行1 小时前
MDX语言的正则表达式
开发语言·后端·golang
编程|诗人1 小时前
TypeScript语言的正则表达式
开发语言·后端·golang
XWM_Web1 小时前
JavaAPI.02.包装类与正则表达式
java·开发语言·学习·eclipse
BinaryBardC1 小时前
R语言的正则表达式
开发语言·后端·golang
CyberScriptor1 小时前
C#语言的字符串处理
开发语言·后端·golang
PangPiLoLo1 小时前
架构学习——互联网常用架构模板
java·学习·微服务·云原生·架构·系统架构·nosql