多租户中间件适配

多租户中间件适配

这里笔者分享下在进行多租户改造的时候,对中间件的适配

这里改造的中间件有:

  • mysql
  • redis
  • redisson

后续适配更多中间件,将持续更新

mysql

采用的是每个租户单独一个DB的方案

shell 复制代码
┌─────────────────────────────────────────────┐
│                 请求入口                      │
│    (携带租户标识: tenant_id/club_id)          │
└───────────────┬─────────────────────────────┘
                │
┌───────────────▼─────────────────────────────┐
│          租户上下文拦截器                      │
│   (从Header/Cookie/Subdomain提取租户ID)      │
└───────────────┬─────────────────────────────┘
                │
┌───────────────▼─────────────────────────────┐
│         动态数据源路由器                       │
│   (根据租户ID选择对应的DataSource)           │
└───────────────┬─────────────────────────────┘
                │
        ┌───────┴───────┐
        ▼               ▼
┌─────────────┐ ┌─────────────┐
│  Club_A     │ │  Club_B     │
│  数据源      │ │  数据源      │
│  DB + Redis │ │  DB + Redis │
└─────────────┘ └─────────────┘

核心表结构设计

sql 复制代码
-- 租户表
CREATE TABLE IF NOT EXISTS fx_tenant (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    tenant_id VARCHAR(64) UNIQUE NOT NULL COMMENT '租户ID(唯一标识)',
    tenant_name VARCHAR(128) NOT NULL COMMENT '租户名称',
    tenant_code VARCHAR(64) COMMENT '租户编码(用于子域名)',
    status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE, EXPIRED, DISABLED',
    strategy VARCHAR(32) NOT NULL DEFAULT 'SEPARATE_DATABASE' COMMENT '隔离策略: SEPARATE_DATABASE, SINGLE_DATABASE, SCHEMA',

    -- 数据库配置(独立数据库模式)
    db_config JSON COMMENT '数据库配置: {"jdbcUrl": "...", "username": "...", "password": "..."}',

    -- 缓存配置
    cache_config JSON COMMENT '缓存配置: {"prefix": "tenant:xxx:", "suffix": false}',

    -- 其他配置
    expire_at DATETIME COMMENT '过期时间(NULL表示永久)',
    max_connections INT DEFAULT 100 COMMENT '最大连接数',
    max_storage_gb INT DEFAULT 10 COMMENT '最大存储空间(GB)',

    -- 元数据
    extra JSON COMMENT '扩展信息(JSON格式)',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    deleted_at DATETIME COMMENT '删除时间(软删除)',

    INDEX idx_tenant_id (tenant_id),
    INDEX idx_tenant_code (tenant_code),
    INDEX idx_status (status),
    INDEX idx_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户表';
步骤1:定义租户上下文
java 复制代码
// TenantContext.java - 租户上下文管理器
public class TenantContext {
    private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
    
    public static void setTenantId(String tenantId) {
        currentTenant.set(tenantId);
    }
    
    public static String getTenantId() {
        return currentTenant.get();
    }
    
    public static void clear() {
        currentTenant.remove();
    }
}

// TenantConstant.java - 常量定义
public class TenantConstant {
    public static final String TENANT_HEADER = "X-Tenant-ID";
    public static final String TENANT_PARAM = "tenantId";
    public static final String DEFAULT_TENANT = "default";
}
步骤2:拦截器提取租户信息
java 复制代码
// TenantInterceptor.java
@Component
public class TenantInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                           HttpServletResponse response, 
                           Object handler) {
        // 1. 从Header获取
        String tenantId = request.getHeader(TenantConstant.TENANT_HEADER);
        
        // 2. 从请求参数获取(可选)
        if (StringUtils.isEmpty(tenantId)) {
            tenantId = request.getParameter(TenantConstant.TENANT_PARAM);
        }
        
        // 3. 从子域名获取(可选)
        if (StringUtils.isEmpty(tenantId)) {
            tenantId = extractTenantFromDomain(request.getServerName());
        }
        
        // 4. 验证租户是否存在且有效
        if (StringUtils.isNotEmpty(tenantId) && tenantService.isValidTenant(tenantId)) {
            TenantContext.setTenantId(tenantId);
        } else {
            // 返回错误或使用默认租户
            throw new TenantNotFoundException("租户不存在或已被禁用");
        }
        
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, 
                              HttpServletResponse response, 
                              Object handler, 
                              Exception ex) {
        // 清理线程上下文,防止内存泄漏
        TenantContext.clear();
    }
}

思路:设计一个公共库master存多租户信息,启动的时候读取,然后构建动态db

这里需要注意,注入的DataSource是多租户的,需要单独创建公共库master的多租户信息

java 复制代码
@Slf4j
@Configuration
@EnableConfigurationProperties(TenantProperties.class)
@ConditionalOnProperty(prefix = "fxclub.tenant", name = "enabled", havingValue = "true")
@Import(WebMvcConfig.class)
public class TenantAutoConfiguration {

    private final TenantProperties properties;

    public TenantAutoConfiguration(TenantProperties properties) {
        this.properties = properties;
        log.info("多租户功能已启用,策略: {}", properties.getStrategy());
    }

    /**
     * 配置事务管理器
     */
    @Bean
    @Primary
    public PlatformTransactionManager transactionManager(DataSource tenantDataSource) {
        log.info("初始化租户事务管理器");
        return new DataSourceTransactionManager(tenantDataSource);
    }

    /**
     * 配置动态数据源
     */
    @Bean
    @Primary
    public DataSource tenantDataSource(DataSourceStrategy dataSourceStrategy) {
        log.info("初始化租户动态数据源,策略: {}", dataSourceStrategy.getType());

        // 创建默认数据源
        DataSource defaultDataSource = createDefaultDataSource();
        dataSourceStrategy.setDefaultDataSource(defaultDataSource);
        // 创建动态数据源
        TenantDynamicDataSource dynamicDataSource = new TenantDynamicDataSource(
            defaultDataSource,
            new HashMap<>(),
            dataSourceStrategy
        );

        log.info("租户动态数据源初始化完成");
        return dynamicDataSource;
    }
}
java 复制代码
/**
 * 动态数据源路由器 根据租户上下文动态切换到对应的数据源
 *
 * @author Eureka
 * @since 2025/12/28
 */
@Slf4j
public class TenantDynamicDataSource extends AbstractRoutingDataSource {

    private final DataSourceStrategy dataSourceStrategy;
    /**
     * 返回管理多租户配置的默认数据源
     */
    @Getter
    private final DataSource defaultDataSource;

    public TenantDynamicDataSource(DataSource defaultDataSource,
        Map<Object, Object> targetDataSources,
        DataSourceStrategy strategy) {
        this.dataSourceStrategy = strategy;
        this.defaultDataSource = defaultDataSource;
        super.setDefaultTargetDataSource(defaultDataSource);
        super.setTargetDataSources(targetDataSources);
    }

    @Override
    protected Object determineCurrentLookupKey() {
        String tenantId = TenantContext.getTenantId();

        if (tenantId == null || tenantId.isEmpty()) {
            log.debug("未获取到租户ID,使用默认数据源");
            return "default";
        }

        log.debug("当前租户ID: {}", tenantId);
        return tenantId;
    }

    @Override
    protected DataSource determineTargetDataSource() {
        String tenantId = TenantContext.getTenantId();

        if (tenantId == null || tenantId.isEmpty()) {
            DataSource defaultDataSource = dataSourceStrategy.getDefaultDataSource();
            if (defaultDataSource == null) {
                throw new RuntimeException("租户ID不能为空,且未配置默认数据源");
            }
            return defaultDataSource;
        }

        // 使用策略获取租户数据源
        DataSource dataSource = dataSourceStrategy.getTenantDataSource(tenantId);

        if (dataSource == null) {
            log.warn("未找到租户数据源: tenantId={}", tenantId);
            throw new RuntimeException("未找到租户数据源: tenantId=" + tenantId);
        }

        return dataSource;
    }
}

redis

spring.data.redis 在项目启动的时候就会自动装配到spring容器中,需要使用注解@AutoConfigureBefore(RedisAutoConfiguration.class)在redis自动装配前,进行自定义注入,否则无法判断哪个地方注册bean会快一些

这里我采用的方式,是自定义key序列化器的方式

java 复制代码
package com.fx.club.tenant.core.cache;

import com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer;
import com.fx.club.tenant.api.config.TenantProperties;
import com.fx.club.tenant.api.config.TenantProperties.Cache;
import com.fx.club.tenant.api.context.TenantContext;
import com.fx.club.tenant.core.util.TenantUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.List;
import java.util.Optional;

/**
 * 多租户 Redis 自动配置
 *
 * <p>通过自定义 Redis 序列化器实现租户隔离,在 Key 前添加租户前缀:
 * <pre>
 *   原始 Key: "user:123"
 *   序列化后: "tenant:tenant_a:user:123" (租户 tenant_a)
 * </pre>
 *
 * <p>特性:
 * <ul>
 *   <li>自动添加租户前缀,无需修改业务代码</li>
 *   <li>支持系统 Key 白名单(通过 fxclub.tenant.cache.ignore-cache-key-prefix 配置)</li>
 *   <li>可自定义前缀格式(通过 fxclub.tenant.cache.key-prefix 配置)</li>
 *   <li>支持动态开关(通过 fxclub.tenant.cache.enabled 配置)</li>
 *   <li>为 Spring Cache 提供多租户支持的 CacheManager</li>
 * </ul>
 *
 * <p>配置示例:
 * <pre>
 *   fxclub:
 *     tenant:
 *       cache:
 *         enabled: true
 *         key-prefix: "tenant:"
 *         ignore-cache-key-prefix:
 *           - "system:"
 *           - "public:"
 * </pre>
 *
 * @author Eureka <liangfengyuan1024@gmail.com>
 * @since 2025/12/28 14:47
 */
@Configuration
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties({RedisProperties.class, TenantProperties.class})
@AutoConfigureBefore(RedisAutoConfiguration.class)  // 在自动配置之前执行
@ConditionalOnProperty(prefix = "fxclub.tenant.cache", name = "enabled", havingValue = "true", matchIfMissing = true)
public class TenantRedisAutoConfiguration {

    /**
     * RedisTemplate 替换默认的RedisTemplate
     *
     * @param redisConnectionFactory
     * @return
     */
    @Bean
    @Primary
    public RedisTemplate<String, Object> redisTemplate(
        RedisConnectionFactory redisConnectionFactory) {

        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);

        // 自定义序列化
        RedisSerializer<String> keySerializer = new TenantKeySerializer();
        GenericJackson2JsonRedisSerializer valueSerializer =
            new GenericJackson2JsonRedisSerializer();

        template.setKeySerializer(keySerializer);
        template.setValueSerializer(valueSerializer);
        template.setHashKeySerializer(keySerializer);
        template.setHashValueSerializer(valueSerializer);

        // 开启事务支持
        template.setEnableTransactionSupport(true);

        template.afterPropertiesSet();
        return template;
    }

    /**
     * 多租户 CacheManager
     * 用于支持 @Cacheable 等注解的租户隔离
     *
     * @param redisConnectionFactory Redis 连接工厂
     * @return 多租户 CacheManager
     */
    @Bean
    @Primary
    public CacheManager tenantCacheManager(RedisConnectionFactory redisConnectionFactory) {
        // 使用非阻塞模式的缓存写入器
        RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory);

        // 构建默认缓存配置
        RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            // 禁止缓存 null 值,避免缓存穿透 这里先允许,兼容admin的代码,但是还是建议使用 unless = "#result == null"
//            .disableCachingNullValues()
            .computePrefixWith(cacheName -> "cache:" + cacheName + ":")
            // 使用 FastJSON 序列化缓存值,支持复杂对象(包括 LocalDateTime)
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericFastJsonRedisSerializer()));

        // 返回多租户缓存管理器,支持 cacheName#ttl 格式与租户隔离
        return new TenantRedisCacheManager(redisCacheWriter, defaultCacheConfig);
    }

    @Bean
    @ConditionalOnMissingBean(name = "stringRedisTemplate")
    @Primary
    public StringRedisTemplate stringRedisTemplate(
        RedisConnectionFactory redisConnectionFactory) {

        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);

        // 设置自定义的Key序列化器
        template.setKeySerializer(new TenantKeySerializer());

        template.afterPropertiesSet();
        return template;
    }

    /**
     * 租户 Key 序列化器
     *
     * <p>在序列化时自动添加租户前缀,实现租户隔离
     * <p>在反序列化时自动移除租户前缀,还原原始 Key
     */
    @Component
    static class TenantKeySerializer implements RedisSerializer<String> {

        @Autowired
        private TenantProperties tenantProperties;

        private final StringRedisSerializer delegate = new StringRedisSerializer();

        @Override
        public byte[] serialize(String key) {
            if (key == null) return null;

            String tenantId = StringUtils.firstNonBlank(TenantContext.getTenantId(), TenantUtil.getHeaderTenantId());
            if (StringUtils.isEmpty(tenantId) || isSystemKey(key)) {
                return delegate.serialize(key);
            }

            String keyPrefix = Optional.ofNullable(tenantProperties)
                .map(TenantProperties::getCache)
                .map(TenantProperties.Cache::getKeyPrefix)
                .orElse("tenant:");
            // 如果已经包含了租户前缀,则不需要重复添加
            String prefixedKey = key;
            if (!key.contains(tenantId)) {
                prefixedKey = String.format("%s%s:%s", keyPrefix, tenantId, key);
            }
            return delegate.serialize(prefixedKey);
        }

        @Override
        public String deserialize(byte[] bytes) {
            String prefixedKey = delegate.deserialize(bytes);
            if (prefixedKey == null) return null;

            // 解析租户前缀
            return parseOriginalKey(prefixedKey);
        }

        private String parseOriginalKey(String prefixedKey) {
            String tenantId = StringUtils.firstNonBlank(TenantContext.getTenantId(), TenantUtil.getHeaderTenantId());
            if (StringUtils.isEmpty(tenantId)) {
                return prefixedKey;
            }

            String keyPrefix = Optional.ofNullable(tenantProperties)
                .map(TenantProperties::getCache)
                .map(TenantProperties.Cache::getKeyPrefix)
                .orElse("tenant:");
            String prefix = keyPrefix + tenantId + ":";

            if (prefixedKey.startsWith(prefix)) {
                return prefixedKey.substring(prefix.length());
            }
            return prefixedKey;
        }

        /**
         * 系统Key列表
         */
        private boolean isSystemKey(String key) {
            List<String> ignoreCacheKeyPrefix = Optional.ofNullable(tenantProperties)
                .map(TenantProperties::getCache)
                .map(Cache::getIgnoreCacheKeyPrefix)
                .orElse(Collections.emptyList());
            for (String cacheKeyPrefix : ignoreCacheKeyPrefix) {
                if (key.startsWith(cacheKeyPrefix)) {
                    return true;
                }
            }
            return false;
        }
    }
}

Redisson

Redisson多租户适配

Redisson同理,需要在自动装配前完成我们的bean注入,然后这里采用Redisson官方提供的NameMapper来完成多租户适配

java 复制代码
package com.fx.club.tenant.core.cache.redisson;

import com.fx.club.tenant.api.config.TenantProperties;
import java.util.Optional;
import org.apache.commons.lang3.StringUtils;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.ClusterServersConfig;
import org.redisson.config.Config;
import org.redisson.config.SentinelServersConfig;
import org.redisson.config.SingleServerConfig;
import org.redisson.spring.starter.RedissonAutoConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Cluster;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Sentinel;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Ssl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Redisson 多租户配置
 *
 * <p>手动创建带 NameMapper 的 {@link RedissonClient},
 * 在 RedissonAutoConfiguration 之前注册 Bean,使自动装配不再创建默认实例。 所有 Redisson 操作的 Key 自动添加租户前缀。
 *
 * <p>支持三种 Redis 部署模式,根据 {@link RedisProperties} 自动判断:
 * <ul>
 *   <li>集群模式:{@code spring.data.redis.cluster.nodes} 不为空时生效</li>
 *   <li>哨兵模式:{@code spring.data.redis.sentinel} 不为空时生效</li>
 *   <li>单机模式:默认,使用 host:port 或 url</li>
 * </ul>
 *
 * @author Eureka
 * @since 2026/5/3
 */
@Configuration
@AutoConfigureBefore(RedissonAutoConfiguration.class)
public class MultiTenantRedissonConfig {

    @Autowired
    private RedisProperties redisProperties;

    @Autowired
    private TenantProperties tenantProperties;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        String password = StringUtils.isNotBlank(redisProperties.getPassword())
            ? redisProperties.getPassword() : null;
        int timeout = Optional.ofNullable(redisProperties.getTimeout())
            .map(t -> (int) t.toMillis()).orElse(3000);
        int connectTimeout = Optional.ofNullable(redisProperties.getConnectTimeout())
            .map(t -> (int) t.toMillis()).orElse(10000);
        TenantRedissonNameMapper nameMapper = new TenantRedissonNameMapper(tenantProperties);

        if (isClusterMode()) {
            configureCluster(config, password, timeout, connectTimeout, nameMapper);
        } else if (isSentinelMode()) {
            configureSentinel(config, password, timeout, connectTimeout, nameMapper);
        } else {
            configureSingleServer(config, password, timeout, connectTimeout, nameMapper);
        }

        return Redisson.create(config);
    }

    // ==================== 单机模式 ====================

    private void configureSingleServer(Config config, String password,
        int timeout, int connectTimeout,
        TenantRedissonNameMapper nameMapper) {
        int database = Optional.of(redisProperties.getDatabase()).orElse(0);
        SingleServerConfig sc = config.useSingleServer()
            .setAddress(resolveSingleAddress())
            .setPassword(password)
            .setDatabase(database)
            .setTimeout(timeout)
            .setConnectTimeout(connectTimeout)
            .setNameMapper(nameMapper);
    }

    // ==================== 哨兵模式 ====================

    private void configureSentinel(Config config, String password,
        int timeout, int connectTimeout,
        TenantRedissonNameMapper nameMapper) {
        Sentinel sentinel = redisProperties.getSentinel();
        SentinelServersConfig sc = config.useSentinelServers()
            .setMasterName(sentinel.getMaster())
            .setPassword(password)
            .setDatabase(Optional.of(redisProperties.getDatabase()).orElse(0))
            .setTimeout(timeout)
            .setConnectTimeout(connectTimeout)
            .setNameMapper(nameMapper);

        sentinel.getNodes().forEach(node -> {
            String addr = node.startsWith("redis://") ? node : "redis://" + node;
            sc.addSentinelAddress(addr);
        });
    }

    // ==================== 集群模式 ====================

    private void configureCluster(Config config, String password,
        int timeout, int connectTimeout,
        TenantRedissonNameMapper nameMapper) {
        Cluster cluster = redisProperties.getCluster();
        ClusterServersConfig cc = config.useClusterServers()
            .setPassword(password)
            .setTimeout(timeout)
            .setConnectTimeout(connectTimeout)
            .setNameMapper(nameMapper);

        cluster.getNodes().forEach(node -> {
            String addr = node.startsWith("redis://") ? node : "redis://" + node;
            cc.addNodeAddress(addr);
        });
    }

    // ==================== 模式判断 ====================

    private boolean isClusterMode() {
        Cluster cluster = redisProperties.getCluster();
        return cluster != null && cluster.getNodes() != null && !cluster.getNodes().isEmpty();
    }

    private boolean isSentinelMode() {
        Sentinel sentinel = redisProperties.getSentinel();
        return sentinel != null && sentinel.getMaster() != null
            && sentinel.getNodes() != null && !sentinel.getNodes().isEmpty();
    }

    // ==================== 地址解析 ====================

    private String resolveSingleAddress() {
        if (StringUtils.isNotBlank(redisProperties.getUrl())) {
            String url = redisProperties.getUrl();
            if (!url.startsWith("redis://") && !url.startsWith("rediss://")) {
                url = "redis://" + url;
            }
            return url;
        }
        String host = Optional.ofNullable(redisProperties.getHost()).orElse("localhost");
        int port = Optional.of(redisProperties.getPort()).orElse(6379);
        boolean ssl = Optional.ofNullable(redisProperties.getSsl())
            .map(Ssl::isEnabled).orElse(false);
        return (ssl ? "rediss://" : "redis://") + host + ":" + port;
    }
}
java 复制代码
package com.fx.club.tenant.core.cache.redisson;

import com.fx.club.tenant.api.config.TenantProperties;
import com.fx.club.tenant.api.context.TenantContext;
import java.util.Optional;
import org.apache.commons.lang3.StringUtils;
import org.redisson.api.NameMapper;

/**
 * Redisson 多租户 NameMapper
 *
 * <p>与 {@code TenantKeySerializer} 的前缀逻辑对齐。
 * Key 格式:{@code {keyPrefix}{tenantId}:{originalKey}}
 *
 * @author Eureka
 * @since 2026/5/3
 */
public class TenantRedissonNameMapper implements NameMapper {

    private final String keyPrefix;

    public TenantRedissonNameMapper(TenantProperties tenantProperties) {
        this.keyPrefix = Optional.ofNullable(tenantProperties)
            .map(TenantProperties::getCache)
            .map(TenantProperties.Cache::getKeyPrefix)
            .orElse("tenant:");
    }

    @Override
    public String map(String name) {
        if (StringUtils.isBlank(name)) {
            return name;
        }
        String tenantId = TenantContext.getTenantId();
        if (StringUtils.isBlank(tenantId)) {
            return name;
        }
        String fullPrefix = keyPrefix + tenantId + ":";
        if (name.startsWith(fullPrefix)) {
            return name;
        }
        return fullPrefix + name;
    }

    @Override
    public String unmap(String name) {
        if (StringUtils.isBlank(name)) {
            return name;
        }
        String tenantId = TenantContext.getTenantId();
        if (StringUtils.isBlank(tenantId)) {
            return name;
        }
        String fullPrefix = keyPrefix + tenantId + ":";
        if (name.startsWith(fullPrefix)) {
            return name.substring(fullPrefix.length());
        }
        return name;
    }
}

关于多租户防御性编程

在进行多租户改造的时候,发现Redisson通过redis的配置,自动装配进入spring的容器进行使用,这部分刚开始并没有进行多租户改造,导致不同租户之间出现干扰

如果防止后续项目演化的过程中,有没进行多租户适配的中间件在项目中使用呢?从SDK开发者的角度看,需要及时对项目中的中间件进行检查,对没有适配的中间件进行警报,并阻断项目的启动

可以参考下面的代码,如果发现没有多租户改造的中间件,直接阻断启动

java 复制代码
package com.fx.club.tenant.core.checker;

import com.fx.club.tenant.api.config.TenantProperties;
import com.fx.club.tenant.core.cache.TenantRedisCacheManager;
import com.fx.club.tenant.core.datasource.TenantDynamicDataSource;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.CacheManager;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.core.type.classreading.SimpleMetadataReaderFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.stereotype.Component;

/**
 * 多租户兼容性检查器
 *
 * <p>在应用启动完成后,检查所有租户敏感中间件是否已正确适配多租户。
 * 防止引入新中间件时遗漏多租户适配,导致租户间数据泄漏。
 *
 * <h3>检查项:</h3>
 * <ul>
 *   <li><b>DataSource</b> - 是否使用 {@link TenantDynamicDataSource},而非 Spring 默认的 DataSource</li>
 *   <li><b>RedisTemplate</b> - KeySerializer 是否为 TenantKeySerializer(自动添加租户前缀)</li>
 *   <li><b>Redisson</b> - 是否配置了 redissonTenantKeyMapper(分布式锁/集合等操作的 Key 隔离)</li>
 *   <li><b>Sa-Token</b> - SaTokenDao 是否为 TenantSaTokenDao(登录状态/权限隔离)</li>
 *   <li><b>Spring Cache</b> - CacheManager 是否为 {@link TenantRedisCacheManager}(@Cacheable 注解隔离)</li>
 *   <li><b>自动配置扫描</b> - 扫描 classpath 中数据/缓存/会话相关的自动配置,发现未经批准的中间件</li>
 * </ul>
 *
 * <h3>配置示例:</h3>
 * <pre>
 * fxclub:
 *   tenant:
 *     checker:
 *       enabled: true
 *       mode: WARN          # WARN 或 STRICT
 *       approved-auto-configurations:
 *         - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
 * </pre>
 *
 * @author Eureka
 * @since 2026/5/2
 */
@Component
@ConditionalOnProperty(prefix = "fxclub.tenant", name = "enabled", havingValue = "true")
public class MultiTenantCompatibilityChecker implements ApplicationRunner {

    private static final Logger log = LoggerFactory.getLogger(
        MultiTenantCompatibilityChecker.class);

    private static final String SEPARATOR = "=======================================================";

    /**
     * 默认已批准的自动配置类(已有租户适配或不需要适配)
     */
    private static final Set<String> DEFAULT_APPROVED_AUTO_CONFIGS = Set.of(
        // 被 TenantRedisAutoConfiguration 的 @Primary bean 覆盖,KeySerializer 已适配
        "org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration",
        // 被 TenantRedissonCustomizer 通过 NameMapper 适配
        "org.redisson.spring.starter.RedissonAutoConfiguration",
        // 被 TenantRedisCacheManager 覆盖
        "org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration",
        // 使用 TenantDynamicDataSource,通过 DataSource bean 注入
        "org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration",
        // 使用 TenantDynamicDataSource
        "com.baomidou.mybatisplus.spring.boot.MybatisPlusAutoConfiguration",
        // Sa-Token 通过 TenantSaTokenDao 适配
        "cn.dev33.satoken.spring.SaTokenBeanRegister",
        // Druid 连接池由 TenantAutoConfiguration 管理
        "com.alibaba.druid.spring.boot3.autoconfigure.DruidDataSourceAutoConfigure"
    );

    /**
     * 租户敏感的自动配置类关键词(用于扫描未知的中间件)
     */
    private static final List<String> TENANT_SENSITIVE_KEYWORDS = List.of(
        "Redis", "Cache", "DataSource", "Session",
        "Mongo", "Elasticsearch", "Jpa", "Hibernate",
        "Couchbase", "Cassandra", "Neo4j", "Lettuce", "Jedis"
    );

    /**
     * 需要被排除的自动配置类(多租户模式下不应激活)
     */
    private static final Set<String> REQUIRED_EXCLUSIONS = Set.of(
        "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"
    );

    private final ApplicationContext applicationContext;
    private final TenantProperties tenantProperties;

    public MultiTenantCompatibilityChecker(ApplicationContext applicationContext,
        TenantProperties tenantProperties) {
        this.applicationContext = applicationContext;
        this.tenantProperties = tenantProperties;
    }

    @Override
    public void run(ApplicationArguments args) {
        TenantProperties.Checker checkerConfig = tenantProperties.getChecker();
        if (checkerConfig == null || !Boolean.TRUE.equals(checkerConfig.getEnabled())) {
            log.debug("多租户兼容性检查已禁用");
            return;
        }

        log.info(SEPARATOR);
        log.info("开始多租户兼容性检查...");
        log.info(SEPARATOR);

        List<CheckResult> results = new ArrayList<>();

        // 1. Bean 级别检查:验证每个中间件的租户适配状态
        checkDataSource(results);
        checkRedisTemplate(results);
        checkRedisson(results);
        checkSaToken(results);
        checkSpringCache(results);

        // 2. 自动配置扫描:发现未知的租户敏感中间件
        scanAutoConfigurations(results, checkerConfig);

        // 3. 排除检查:验证关键的自动配置类是否已被排除
        checkRequiredExclusions(results);

        // 输出报告
        printReport(results);

        // 处理违规项
        handleViolations(results, checkerConfig);

        log.info(SEPARATOR);
    }

    // ==================== Bean 级别检查 ====================

    /**
     * 检查 DataSource 是否使用 TenantDynamicDataSource
     */
    private void checkDataSource(List<CheckResult> results) {
        String name = "DataSource";
        try {
            DataSource dataSource = applicationContext.getBean(DataSource.class);
            if (dataSource instanceof TenantDynamicDataSource) {
                results.add(
                    CheckResult.adapted(name, "已使用 TenantDynamicDataSource,租户数据已隔离"));
            } else {
                results.add(CheckResult.notAdapted(name,
                    "当前 DataSource 实现为 " + dataSource.getClass().getName()
                        + ",应使用 TenantDynamicDataSource。"
                        + "请在启动类上排除 DataSourceAutoConfiguration,"
                        + "并确保 TenantAutoConfiguration 生效"));
            }
        } catch (Exception e) {
            results.add(CheckResult.notPresent(name));
        }
    }

    /**
     * 检查 RedisTemplate 的 KeySerializer 是否为 TenantKeySerializer
     */
    private void checkRedisTemplate(List<CheckResult> results) {
        String name = "RedisTemplate";
        if (!isClassPresent("org.springframework.data.redis.core.RedisOperations")) {
            results.add(CheckResult.notPresent(name));
            return;
        }

        try {
            RedisTemplate<?, ?> redisTemplate = applicationContext.getBean(RedisTemplate.class);
            RedisSerializer<?> keySerializer = redisTemplate.getKeySerializer();
            String serializerClassName = keySerializer.getClass().getName();

            if (serializerClassName.contains("TenantKeySerializer")) {
                results.add(CheckResult.adapted(name,
                    "KeySerializer 已使用 TenantKeySerializer,Key 自动添加租户前缀"));
            } else {
                results.add(CheckResult.notAdapted(name,
                    "RedisTemplate 的 KeySerializer 为 " + keySerializer.getClass().getName()
                        + ",未使用 TenantKeySerializer。"
                        + "不同租户的缓存数据可能发生混淆,"
                        + "请确保 TenantRedisAutoConfiguration 生效"));
            }
        } catch (Exception e) {
            results.add(CheckResult.notAdapted(name, "获取 RedisTemplate 失败: " + e.getMessage()));
        }
    }

    /**
     * 检查 Redisson 是否配置了多租户 Key 映射
     */
    private void checkRedisson(List<CheckResult> results) {
        String name = "Redisson";
        if (!isClassPresent("org.redisson.api.RedissonClient")) {
            results.add(CheckResult.notPresent(name));
            return;
        }

        try {
            String[] beanNames = applicationContext.getBeanNamesForType(Function.class);
            boolean hasTenantMapper = false;
            for (String beanName : beanNames) {
                if ("redissonTenantKeyMapper".equals(beanName)) {
                    hasTenantMapper = true;
                    break;
                }
            }
            if (hasTenantMapper) {
                results.add(CheckResult.adapted(name,
                    "已配置 redissonTenantKeyMapper,分布式锁/集合等操作的 Key 已隔离"));
            } else {
                results.add(CheckResult.notAdapted(name,
                    "未发现 redissonTenantKeyMapper bean,Redisson 操作的 Key 不会自动添加租户前缀。"
                        + "请确保 RedissonTenantKeyConfiguration 已被正确加载"));
            }
        } catch (Exception e) {
            results.add(CheckResult.notAdapted(name, "检查失败: " + e.getMessage()));
        }
    }

    /**
     * 检查 Sa-Token 的 SaTokenDao 是否为 TenantSaTokenDao
     *
     * <p>使用反射检查,因为 sa-token-core 是 optional 依赖
     */
    private void checkSaToken(List<CheckResult> results) {
        String name = "Sa-Token";
        if (!isClassPresent("cn.dev33.satoken.SaManager")) {
            results.add(CheckResult.notPresent(name));
            return;
        }

        try {
            Class<?> saTokenDaoClass = Class.forName("cn.dev33.satoken.dao.SaTokenDao");
            Object saTokenDao = applicationContext.getBean(saTokenDaoClass);
            String daoClassName = saTokenDao.getClass().getName();

            if (daoClassName.contains("TenantSaTokenDao")) {
                results.add(CheckResult.adapted(name,
                    "SaTokenDao 已使用 TenantSaTokenDao,登录状态/权限已隔离"));
            } else {
                results.add(CheckResult.notAdapted(name,
                    "SaTokenDao 实现为 " + daoClassName
                        + ",未使用 TenantSaTokenDao。"
                        + "不同租户的登录状态、权限信息可能发生混淆,"
                        + "请确保 TenantSaTokenAutoConfiguration 生效"));
            }
        } catch (ClassNotFoundException e) {
            results.add(CheckResult.notPresent(name));
        } catch (Exception e) {
            results.add(CheckResult.notAdapted(name, "获取 SaTokenDao 失败: " + e.getMessage()));
        }
    }

    /**
     * 检查 Spring CacheManager 是否为 TenantRedisCacheManager
     */
    private void checkSpringCache(List<CheckResult> results) {
        String name = "Spring Cache";
        try {
            CacheManager cacheManager = applicationContext.getBean(CacheManager.class);
            if (cacheManager instanceof TenantRedisCacheManager) {
                results.add(CheckResult.adapted(name,
                    "CacheManager 已使用 TenantRedisCacheManager,@Cacheable 注解已隔离"));
            } else {
                results.add(CheckResult.notAdapted(name,
                    "CacheManager 实现为 " + cacheManager.getClass().getName()
                        + ",未使用 TenantRedisCacheManager。"
                        + "@Cacheable 等注解的缓存数据可能发生租户混淆"));
            }
        } catch (Exception e) {
            results.add(CheckResult.notPresent(name));
        }
    }

    // ==================== 自动配置扫描 ====================

    /**
     * 扫描已激活的自动配置类,发现未经批准的租户敏感中间件
     *
     * <p>通过 Spring 的 ConditionEvaluationReport 获取所有已匹配的自动配置类,
     * 筛选出数据/缓存/会话相关的配置,检查是否在批准列表中。
     */
    private void scanAutoConfigurations(List<CheckResult> results,
        TenantProperties.Checker checkerConfig) {
        if (!(applicationContext instanceof ConfigurableApplicationContext cac)) {
            log.debug("ApplicationContext 不是 ConfigurableApplicationContext,跳过自动配置扫描");
            return;
        }

        try {
            // 构建批准列表 = 默认批准 + 用户自定义批准
            Set<String> approved = new HashSet<>(DEFAULT_APPROVED_AUTO_CONFIGS);
            if (checkerConfig.getApprovedAutoConfigurations() != null) {
                approved.addAll(checkerConfig.getApprovedAutoConfigurations());
            }

            // 从 AutoConfigurationMetadata 中读取已激活的自动配置
            List<String> suspicious = findSuspiciousAutoConfigs(cac, approved);

            if (!suspicious.isEmpty()) {
                results.add(CheckResult.notAdapted("自动配置扫描",
                    "发现 " + suspicious.size() + " 个未批准的租户敏感自动配置: "
                        + String.join(", ", suspicious)
                        + "。如果这些已适配多租户,请添加到 fxclub.tenant.checker.approved-auto-configurations 中"));
            } else {
                results.add(CheckResult.adapted("自动配置扫描",
                    "所有已激活的租户敏感自动配置均在批准列表中"));
            }
        } catch (Exception e) {
            log.debug("自动配置扫描失败: {}", e.getMessage());
        }
    }

    /**
     * 从 Spring 容器中查找可疑的自动配置类
     */
    private List<String> findSuspiciousAutoConfigs(ConfigurableApplicationContext cac,
        Set<String> approved) {
        List<String> suspicious = new ArrayList<>();

        // 获取所有已注册的 bean 定义,查找自动配置类
        String[] beanNames = cac.getBeanNamesForType(Object.class);
        for (String beanName : beanNames) {
            // 自动配置的 bean 通常以类全限定名作为 bean name
            if (!beanName.contains(".")) {
                continue;
            }
            if (isTenantSensitiveClassName(beanName) && !approved.contains(beanName)) {
                suspicious.add(beanName);
            }
        }

        // 补充:通过 SimpleMetadataReaderFactory 检查常见的自动配置类路径
        checkCommonAutoConfigPaths(cac, approved, suspicious);

        return suspicious;
    }

    /**
     * 检查常见的自动配置类是否在 classpath 中存在且未批准
     */
    private void checkCommonAutoConfigPaths(ConfigurableApplicationContext cac,
        Set<String> approved,
        List<String> suspicious) {
        // 常见的需要关注租户隔离的自动配置类
        List<String> knownSensitiveConfigs = List.of(
            "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration",
            "org.springframework.boot.autoconfigure.session.SessionAutoConfiguration",
            "org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration",
            "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration",
            "org.springframework.boot.autoconfigure.data.web.SpringDataWebAutoConfiguration",
            "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration",
            "org.springframework.boot.autoconfigure.data.couchbase.CouchbaseRepositoriesAutoConfiguration",
            "org.springframework.boot.autoconfigure.data.cassandra.CassandraRepositoriesAutoConfiguration",
            "org.springframework.boot.autoconfigure.data.neo4j.Neo4jRepositoriesAutoConfiguration",
            "org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration",
            "org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration"
        );

        for (String configClass : knownSensitiveConfigs) {
            if (!approved.contains(configClass) && isClassPresent(configClass)) {
                // 进一步检查该自动配置是否实际激活(是否有对应的 bean)
                if (isAutoConfigActive(cac, configClass)) {
                    suspicious.add(configClass);
                }
            }
        }
    }

    /**
     * 检查自动配置类是否实际激活
     */
    private boolean isAutoConfigActive(ConfigurableApplicationContext cac, String configClassName) {
        try {
            // 尝试读取自动配置类的元数据,检查是否有 @Conditional 相关注解
            SimpleMetadataReaderFactory readerFactory = new SimpleMetadataReaderFactory(
                cac.getBeanFactory().getBeanClassLoader());
            MetadataReader reader = readerFactory.getMetadataReader(
                configClassName.replace('.', '/').replace(
                    configClassName.substring(configClassName.lastIndexOf('.')),
                    ".class"));
            // 简化检查:如果类存在就认为可能激活
            // 实际是否激活取决于 @Conditional 条件,这里做保守估计
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 检查关键的自动配置类是否已被排除
     *
     * <p>多租户模式下,DataSourceAutoConfiguration 必须被排除,
     * 因为数据源由 TenantAutoConfiguration 管理。
     */
    private void checkRequiredExclusions(List<CheckResult> results) {
        // 检查 DataSourceAutoConfiguration 是否被排除
        try {
            // 如果能获取到 DataSourceAutoConfiguration 的 bean,说明它没有被排除
            Map<String, ?> dsBeans = applicationContext.getBeansOfType(
                Class.forName(
                    "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"));
            // 实际上 DataSourceAutoConfiguration 不会直接注册为 bean
            // 我们通过检查是否存在非 TenantDynamicDataSource 的 DataSource 来间接判断
        } catch (ClassNotFoundException e) {
            // DataSourceAutoConfiguration 不在 classpath,无需检查
            return;
        } catch (Exception e) {
            // 忽略
        }

        // 通过更直接的方式检查:如果 DataSourceAutoConfiguration 在 classpath 上,
        // 且 DataSource 不是 TenantDynamicDataSource,则说明可能没有排除
        try {
            if (isClassPresent(
                "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration")) {
                DataSource ds = applicationContext.getBean(DataSource.class);
                if (!(ds instanceof TenantDynamicDataSource)) {
                    results.add(CheckResult.notAdapted("DataSourceAutoConfiguration 排除检查",
                        "DataSourceAutoConfiguration 似乎未被排除,"
                            + "当前 DataSource 不是 TenantDynamicDataSource。"
                            + "请在启动类的 @SpringBootApplication 中添加:"
                            + "exclude = {DataSourceAutoConfiguration.class}"));
                }
            }
        } catch (Exception e) {
            log.debug("DataSourceAutoConfiguration 排除检查失败: {}", e.getMessage());
        }
    }

    // ==================== 工具方法 ====================

    /**
     * 检查类是否在 classpath 中存在
     */
    private boolean isClassPresent(String className) {
        try {
            Class.forName(className, false, getClass().getClassLoader());
            return true;
        } catch (ClassNotFoundException e) {
            return false;
        }
    }

    /**
     * 判断类名是否属于租户敏感的中间件
     */
    private boolean isTenantSensitiveClassName(String className) {
        for (String keyword : TENANT_SENSITIVE_KEYWORDS) {
            if (className.contains(keyword)) {
                return true;
            }
        }
        return false;
    }

    // ==================== 报告输出 ====================

    /**
     * 输出检查报告
     */
    private void printReport(List<CheckResult> results) {
        int passCount = 0;
        int failCount = 0;
        int skipCount = 0;

        for (CheckResult result : results) {
            switch (result.status) {
                case ADAPTED -> {
                    log.info("  [PASS] {} - {}", result.name, result.message);
                    passCount++;
                }
                case NOT_ADAPTED -> {
                    log.warn("  [FAIL] {} - {}", result.name, result.message);
                    failCount++;
                }
                case NOT_PRESENT -> {
                    log.debug("  [SKIP] {} - 未安装", result.name);
                    skipCount++;
                }
            }
        }

        log.info("检查结果: 通过={}, 失败={}, 跳过={}", passCount, failCount, skipCount);
    }

    /**
     * 处理检查违规项
     */
    private void handleViolations(List<CheckResult> results, TenantProperties.Checker config) {
        List<CheckResult> violations = results.stream()
            .filter(r -> r.status == CheckStatus.NOT_ADAPTED)
            .toList();

        if (violations.isEmpty()) {
            log.info("多租户兼容性检查通过,所有中间件均已正确适配");
            return;
        }

        String errorMessage = buildErrorMessage(violations);

        if ("STRICT".equalsIgnoreCase(config.getMode())) {
            log.error("多租户兼容性检查未通过!以下中间件未正确适配多租户:\n{}", errorMessage);
            throw new IllegalStateException(
                "多租户兼容性检查未通过,存在 " + violations.size()
                    + " 项未适配的中间件。请修复上述问题,"
                    + "或设置 fxclub.tenant.checker.mode=WARN 切换为警告模式。\n"
                    + errorMessage);
        } else {
            log.warn("多租户兼容性检查发现 {} 项警告,以下中间件可能未正确适配多租户:\n{}",
                violations.size(), errorMessage);
        }
    }

    /**
     * 构建错误信息
     */
    private String buildErrorMessage(List<CheckResult> violations) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < violations.size(); i++) {
            CheckResult v = violations.get(i);
            sb.append("  ").append(i + 1).append(". ").append(v.name).append(": ")
                .append(v.message).append("\n");
        }
        return sb.toString();
    }

    // ==================== 内部类 ====================

    /**
     * 检查状态
     */
    enum CheckStatus {
        /**
         * 已适配多租户
         */
        ADAPTED,
        /**
         * 未适配多租户
         */
        NOT_ADAPTED,
        /**
         * 中间件未安装,跳过
         */
        NOT_PRESENT
    }

    /**
     * 检查结果
     */
    static class CheckResult {

        /**
         * 中间件名称
         */
        final String name;
        /**
         * 检查状态
         */
        final CheckStatus status;
        /**
         * 详细信息
         */
        final String message;

        private CheckResult(String name, CheckStatus status, String message) {
            this.name = name;
            this.status = status;
            this.message = message;
        }

        static CheckResult adapted(String name, String message) {
            return new CheckResult(name, CheckStatus.ADAPTED, message);
        }

        static CheckResult notAdapted(String name, String message) {
            return new CheckResult(name, CheckStatus.NOT_ADAPTED, message);
        }

        static CheckResult notPresent(String name) {
            return new CheckResult(name, CheckStatus.NOT_PRESENT, "未安装");
        }
    }
}
相关推荐
014-code2 小时前
Java 并发中的原子类
java·开发语言·并发
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题】【Java基础篇】第29题:静态代理和动态代理的区别是什么
java·开发语言·后端·面试·代理模式
善恶怪客2 小时前
Java-数组和可变参数
java·开发语言
小编码上说2 小时前
LSH(局部敏感哈希)分桶,海量数据下的相似性搜索解决方案
java·spring boot·缓存·langchain4j·lsh·局部敏感哈希·ai调用优化
计算机_毕业设计2 小时前
java-springboot数字藏品系统 基于 SpringBoot 的区块链数字艺术品交易平台 Java 微服务架构下的加密藏品展示与拍卖系统计算机毕业设计
java·spring boot·课程设计
ONVO ncen2 小时前
Redis6.2.6下载和安装
java
丑八怪大丑2 小时前
JDK8-17新特性
java·开发语言
京师20万禁军教头2 小时前
37面向对象(高级)-main方法
java
书源丶2 小时前
三十五、Java 泛型——类型安全的「万能模板」
java·开发语言·安全