|
@@ -0,0 +1,502 @@
|
|
|
+前言
|
|
|
+近期工作中有遇到多租户模式的应用场景,对此自己查阅了大量的资料。对可行性进行分析后选择了共享库表,按租户id字段区分租户的方式去实现。以此记录一下方便日后所需查阅
|
|
|
+
|
|
|
+1.熟悉多租户之前先来了解一下什么是SaaS系统
|
|
|
+以下内容来着百度百科
|
|
|
+
|
|
|
+SaaS平台是运营saas软件的平台。SaaS提供商为企业搭建信息化所需要的所有网络基础设施及软件、硬件运作平台,并负责所有前期的实施、后期的维护等一系列服务,企业无需购买软硬件、建设机房、招聘IT人员,即可通过互联网使用信息系统。SaaS 是一种软件布局模型,其应用专为网络交付而设计,便于用户通过互联网托管、部署及接入。
|
|
|
+
|
|
|
+也就是说,我只需要能连接上互联网,并且给saas平台交租金,我就能用saas平台给我提供的系统服务。这方面最典型的例子就是各种云平台,例如阿里云。既然我能通过互联网使用saas平台提供的服务,那么其他人当然也是可以的。于是这就产生了一个多租户的问题。
|
|
|
+
|
|
|
+2.什么是多租户模式
|
|
|
+多租户,简单来说,是一种架构设计方式,就是在一台或者一组服务器上运行的saas系统,可以为多个租户(客户)提供服务,目的是为了让多个租户在互联网环境下使用同一套程序,且保证租户间的数据隔离。从这种架构设计的模式上,不难看出来,多租户架构的重点就是同一套程序下多个租户数据的隔离。由于租户数据是集中存储的,所以要实现数据的安全性,就是看能否实现对租户数据的隔离,防止租户数据不经意或被他人恶意地获取和篡改。
|
|
|
+
|
|
|
+3.多租户数据隔离方式
|
|
|
+目前saas多租户系统的数据隔离有三种解决方案,即:
|
|
|
+
|
|
|
+为每个租户提供独立的数据库
|
|
|
+独立的表空间
|
|
|
+按字段区分租户
|
|
|
+
|
|
|
+3.2.每个租户提供独立的表空间
|
|
|
+这种方案的实现方式,就是所有租户共享同一个应用,应用后端只连接一个数据库系统,所有租户共享这个数据库系统,每个租户在数据库系统中拥有一个独立的表空间。表空间中的数据表结构都是一样的。DB2、ORACLE、PostgreSQL,一个数据库下可以有多个Schema(在mysql中其实就是分多个数据库)
|
|
|
+3.4.三种数据隔离方案的优劣势分析
|
|
|
+隔离方案 成本 支持租户数量 优点 不足
|
|
|
+独立数据库系统 高 少 隔离级别最高,安全性最好,能够满足不同租户的独特需求,出现故障时恢复数据比较容易 数据库需要独立安装,维护成本和购置成本高
|
|
|
+共享数据库,独立表空间 中 较多 提供了一定程度的逻辑数据隔离,一个数据库系统可支持多个租户 出现故障的情况下,数据恢复相对而言比较复杂
|
|
|
+按租户id字段区分 低 非常多 维护和购置成本最低,每个数据库能够支持的租户数量最多 隔离级别最低,安全性也最低,数据备份和恢复非常复杂,需要逐表逐条备份和还原
|
|
|
+4.使用Mybatisplus搭建多租户模式(方式三的实现:共享库表,按租户id字段区分租户)
|
|
|
+4.1.MyBatisPlusConfig.java
|
|
|
+
|
|
|
+package com.bitvalue.gp.sys.config;
|
|
|
+
|
|
|
+import com.bitvalue.gp.sys.core.mybatis.dbid.GunsDatabaseIdProvider;
|
|
|
+import com.bitvalue.gp.sys.core.mybatis.fieldfill.CustomMetaObjectHandler;
|
|
|
+import com.baomidou.mybatisplus.annotation.DbType;
|
|
|
+import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer;
|
|
|
+import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
|
|
+import org.mybatis.spring.annotation.MapperScan;
|
|
|
+import org.springframework.context.annotation.Bean;
|
|
|
+import org.springframework.context.annotation.Configuration;
|
|
|
+
|
|
|
+/**
|
|
|
+ * MyBatisPlusConfig扩展插件配置
|
|
|
+ *
|
|
|
+ * @author tangling
|
|
|
+ * @date 2021/4/18 10:49
|
|
|
+ */
|
|
|
+ @Configuration
|
|
|
+ //扫描mapper
|
|
|
+ @MapperScan(basePackages = {"com.bitvalue.gp.**.mapper"})
|
|
|
+ public class MyBatisPlusConfig {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * mp多租户配置
|
|
|
+ */
|
|
|
+ @Bean
|
|
|
+ public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
|
|
+ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
|
|
+ // 多租户插件
|
|
|
+ interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new CustomTenantLineHandler()));
|
|
|
+ // 分页插件(ps:如果项目中有用到分页插件可以添加如下这行代码,但是必须要写在多租户插件后面)
|
|
|
+ interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
|
|
+ return interceptor;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Bean
|
|
|
+ public ConfigurationCustomizer configurationCustomizer() {
|
|
|
+ return configuration -> configuration.setUseDeprecatedExecutor(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 自定义公共字段自动注入
|
|
|
+ */
|
|
|
+ @Bean
|
|
|
+ public MetaObjectHandler metaObjectHandler() {
|
|
|
+ //自定义sql字段填充器,自动填充创建修改相关字段
|
|
|
+ return new CustomMetaObjectHandler();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ 1
|
|
|
+ 2
|
|
|
+ 3
|
|
|
+ 4
|
|
|
+ 5
|
|
|
+ 6
|
|
|
+ 7
|
|
|
+ 8
|
|
|
+ 9
|
|
|
+ 10
|
|
|
+ 11
|
|
|
+ 12
|
|
|
+ 13
|
|
|
+ 14
|
|
|
+ 15
|
|
|
+ 16
|
|
|
+ 17
|
|
|
+ 18
|
|
|
+ 19
|
|
|
+ 20
|
|
|
+ 21
|
|
|
+ 22
|
|
|
+ 23
|
|
|
+ 24
|
|
|
+ 25
|
|
|
+ 26
|
|
|
+ 27
|
|
|
+ 28
|
|
|
+ 29
|
|
|
+ 30
|
|
|
+ 31
|
|
|
+ 32
|
|
|
+ 33
|
|
|
+ 34
|
|
|
+ 35
|
|
|
+ 36
|
|
|
+ 37
|
|
|
+ 38
|
|
|
+ 39
|
|
|
+ 40
|
|
|
+ 41
|
|
|
+ 42
|
|
|
+ 43
|
|
|
+ 44
|
|
|
+ 45
|
|
|
+ 46
|
|
|
+ 47
|
|
|
+ 48
|
|
|
+ 49
|
|
|
+ 50
|
|
|
+ 51
|
|
|
+ 52
|
|
|
+ 53
|
|
|
+ 4.2.多租户插件 | CustomTenantLineHandler.java
|
|
|
+ CustomTenantLineHandler类实现了TenantLineHandler接口,并实现了
|
|
|
+
|
|
|
+getTenantId()方法,该方法主要用于设置租户Id的值,在框架去处理SQL语句前去改写SQL语句,为SQL语句添加上租户判断条件。租户Id可以从缓存、cookie、token等中获取(根据实际的业务场景来)
|
|
|
+getTenantIdColumn()方法,该方法用于设置租户Id的字段名称
|
|
|
+ignoreTable(String tableName)方法,该方法用于标记忽略添加租户ID的表
|
|
|
+主要的核心还是在getTenantId()方法,我们需要考虑这个租户Id的值应该如何去设置已经设置的同时会不会出现线程安全问题(看了大部分文章都是通过一个Bean中的字段类进行赋值的,这样可能会出现线程安全问题)。
|
|
|
+
|
|
|
+我这里的思想是,在用户登录成功后。存储用户的基本信息到安全框架的上下文对象中并将用户的基本信息和租户Id生成一个token返回给请求方。当请求方再次来访问时会携带上这个token(首先会在过滤器中拦截请求,验证token能够解析后)进行一系列的业务操作后,最终要执行SQL语句时来到这个租户监听器中,在这里获取并设置租户Id。我这里是从请求头中获取token,通过解析token获取租户Id。
|
|
|
+也可能会有一种情况就是,如果是内部mapper之间的调用那就没有HttpServerRequest,就无法获取到token并且还会报错。这里我对此进行了try/catch。
|
|
|
+在catch结构体中处理内部调用问题,处理的方式就是从上下文对象中获取当前登录用户的账号,根据用户的账号去缓存中获取到该用户的租户信息。
|
|
|
+ps:用户账号:租户Id信息我是在Spring容器初始化完成后就往redis里面存储了
|
|
|
+其实同样的也可以将用户的租户Id存储到上下文对象中,直接获取。
|
|
|
+
|
|
|
+package com.bitvalue.gp.sys.config;
|
|
|
+
|
|
|
+import cn.hutool.core.util.ObjectUtil;
|
|
|
+import cn.hutool.extra.spring.SpringUtil;
|
|
|
+import com.bitvalue.gp.core.consts.CommonConstant;
|
|
|
+import com.bitvalue.gp.core.context.login.LoginContextHolder;
|
|
|
+import com.bitvalue.gp.core.exception.AuthException;
|
|
|
+import com.bitvalue.gp.core.exception.ServiceException;
|
|
|
+import com.bitvalue.gp.core.pojo.login.SysLoginUser;
|
|
|
+import com.bitvalue.gp.sys.core.jwt.JwtPayLoad;
|
|
|
+import com.bitvalue.gp.sys.core.jwt.JwtTokenUtil;
|
|
|
+import com.bitvalue.gp.core.util.HttpServletUtil;
|
|
|
+import com.bitvalue.gp.sys.modular.auth.service.AuthService;
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
|
|
+import com.google.common.collect.Lists;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import net.sf.jsqlparser.expression.Expression;
|
|
|
+import net.sf.jsqlparser.expression.LongValue;
|
|
|
+import org.apache.commons.lang3.StringUtils;
|
|
|
+import org.springframework.data.redis.core.RedisTemplate;
|
|
|
+
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 多租户处理插件
|
|
|
+ *
|
|
|
+ * @author tangling
|
|
|
+ * @date 2021/04/26 13:37
|
|
|
+ */
|
|
|
+ @Slf4j
|
|
|
+ public class CustomTenantLineHandler implements TenantLineHandler {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 用户对应租户信息缓存key
|
|
|
+ */
|
|
|
+ public static final String TENANT_CASE_KEY = "TENANT_CASE_KEY";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 忽略添加租户ID的表
|
|
|
+ */
|
|
|
+ private static List<String> IGNORE_TABLE_NAMES = Lists.newArrayList(
|
|
|
+ "tenant_info"
|
|
|
+ );
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取租户ID值表达式
|
|
|
+ *
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public Expression getTenantId() {
|
|
|
+ //租户Id,可以从缓存或者cookie,token等中获取
|
|
|
+ return new LongValue(returnTenantId());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取租户字段名(数据库的租户ID字段名)
|
|
|
+ *
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public String getTenantIdColumn() {
|
|
|
+ return "tenant_id";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据表名判断是否忽略拼接多租户条件
|
|
|
+ *
|
|
|
+ * @param tableName
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public boolean ignoreTable(String tableName) {
|
|
|
+ return IGNORE_TABLE_NAMES.contains(tableName);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从请求中获取到token,从token中解析出tenantId
|
|
|
+ *
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ public Long returnTenantId() {
|
|
|
+ //初始化值,保存程序正常启动
|
|
|
+ Long tenantId = 1L;
|
|
|
+ //从请求头中获取token
|
|
|
+ try {
|
|
|
+ HttpServletRequest request = HttpServletUtil.getRequest();
|
|
|
+ AuthService authService = SpringUtil.getBean(AuthService.class);
|
|
|
+ String token = authService.getTokenFromRequest(request);
|
|
|
+ //请求头中有token
|
|
|
+ if (StringUtils.isNotEmpty(token)) {
|
|
|
+ JwtPayLoad jwtPayLoad = JwtTokenUtil.getJwtPayLoad(token);
|
|
|
+ tenantId = jwtPayLoad.getTenantId();
|
|
|
+ }
|
|
|
+ } catch (ServiceException exception) {
|
|
|
+ log.info(">>> 没有HTTP服务请求处理方式!");
|
|
|
+ //没有HTTP服务请求处理方式,内部调用处理
|
|
|
+ try {
|
|
|
+ //从security中的认证上下文对象中获取当前登录用户账号,根据账号去redis缓存中匹配出当前操作是属于那个租户ID
|
|
|
+ String account = LoginContextHolder.me().getSysLoginUser().getAccount();
|
|
|
+ if (StringUtils.isNotEmpty(account)) {
|
|
|
+ RedisTemplate redisTemplate = SpringUtil.getBean("redisTemplate");
|
|
|
+ Object object = redisTemplate.opsForValue().get(TENANT_CASE_KEY);
|
|
|
+ String jsonMap = object.toString();
|
|
|
+ Map accountTenantMapper = JSON.parseObject(jsonMap, Map.class);
|
|
|
+ tenantId = Long.valueOf(String.valueOf((accountTenantMapper.get(account))));
|
|
|
+ log.info(">>> 内部调用!用户账号:" + account + " | 所属租户:" + tenantId);
|
|
|
+ }else {
|
|
|
+ log.info(">>> 必要参数缺失!");
|
|
|
+ }
|
|
|
+ } catch (AuthException e) {
|
|
|
+ log.info(">>> " + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return tenantId;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ 1
|
|
|
+ 2
|
|
|
+ 3
|
|
|
+ 4
|
|
|
+ 5
|
|
|
+ 6
|
|
|
+ 7
|
|
|
+ 8
|
|
|
+ 9
|
|
|
+ 10
|
|
|
+ 11
|
|
|
+ 12
|
|
|
+ 13
|
|
|
+ 14
|
|
|
+ 15
|
|
|
+ 16
|
|
|
+ 17
|
|
|
+ 18
|
|
|
+ 19
|
|
|
+ 20
|
|
|
+ 21
|
|
|
+ 22
|
|
|
+ 23
|
|
|
+ 24
|
|
|
+ 25
|
|
|
+ 26
|
|
|
+ 27
|
|
|
+ 28
|
|
|
+ 29
|
|
|
+ 30
|
|
|
+ 31
|
|
|
+ 32
|
|
|
+ 33
|
|
|
+ 34
|
|
|
+ 35
|
|
|
+ 36
|
|
|
+ 37
|
|
|
+ 38
|
|
|
+ 39
|
|
|
+ 40
|
|
|
+ 41
|
|
|
+ 42
|
|
|
+ 43
|
|
|
+ 44
|
|
|
+ 45
|
|
|
+ 46
|
|
|
+ 47
|
|
|
+ 48
|
|
|
+ 49
|
|
|
+ 50
|
|
|
+ 51
|
|
|
+ 52
|
|
|
+ 53
|
|
|
+ 54
|
|
|
+ 55
|
|
|
+ 56
|
|
|
+ 57
|
|
|
+ 58
|
|
|
+ 59
|
|
|
+ 60
|
|
|
+ 61
|
|
|
+ 62
|
|
|
+ 63
|
|
|
+ 64
|
|
|
+ 65
|
|
|
+ 66
|
|
|
+ 67
|
|
|
+ 68
|
|
|
+ 69
|
|
|
+ 70
|
|
|
+ 71
|
|
|
+ 72
|
|
|
+ 73
|
|
|
+ 74
|
|
|
+ 75
|
|
|
+ 76
|
|
|
+ 77
|
|
|
+ 78
|
|
|
+ 79
|
|
|
+ 80
|
|
|
+ 81
|
|
|
+ 82
|
|
|
+ 83
|
|
|
+ 84
|
|
|
+ 85
|
|
|
+ 86
|
|
|
+ 87
|
|
|
+ 88
|
|
|
+ 89
|
|
|
+ 90
|
|
|
+ 91
|
|
|
+ 92
|
|
|
+ 93
|
|
|
+ 94
|
|
|
+ 95
|
|
|
+ 96
|
|
|
+ 97
|
|
|
+ 98
|
|
|
+ 99
|
|
|
+ 100
|
|
|
+ 101
|
|
|
+ 102
|
|
|
+ 103
|
|
|
+ 104
|
|
|
+ 105
|
|
|
+ 106
|
|
|
+ 107
|
|
|
+ 108
|
|
|
+ 109
|
|
|
+ 110
|
|
|
+ 111
|
|
|
+ 112
|
|
|
+ 113
|
|
|
+ 114
|
|
|
+ 115
|
|
|
+ 116
|
|
|
+ 117
|
|
|
+ 118
|
|
|
+ 119
|
|
|
+ 120
|
|
|
+ 4.3.HttpServletUtil.java
|
|
|
+ package com.bitvalue.gp.core.util;
|
|
|
+
|
|
|
+import com.bitvalue.gp.core.exception.ServiceException;
|
|
|
+import com.bitvalue.gp.core.exception.enums.ServerExceptionEnum;
|
|
|
+import org.springframework.web.context.request.RequestContextHolder;
|
|
|
+import org.springframework.web.context.request.ServletRequestAttributes;
|
|
|
+
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import javax.servlet.http.HttpServletResponse;
|
|
|
+
|
|
|
+/**
|
|
|
+ * HttpServlet工具类,获取当前request和response
|
|
|
+ *
|
|
|
+ * @author tangling
|
|
|
+ * @date 2021/3/30 15:09
|
|
|
+ */
|
|
|
+ public class HttpServletUtil {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取当前请求的request对象
|
|
|
+ */
|
|
|
+ public static HttpServletRequest getRequest() {
|
|
|
+ ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
|
|
+ if (requestAttributes == null) {
|
|
|
+ throw new ServiceException(ServerExceptionEnum.REQUEST_EMPTY);
|
|
|
+ } else {
|
|
|
+ return requestAttributes.getRequest();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取当前请求的response对象
|
|
|
+ */
|
|
|
+ public static HttpServletResponse getResponse() {
|
|
|
+ ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
|
|
+ if (requestAttributes == null) {
|
|
|
+ throw new ServiceException(ServerExceptionEnum.REQUEST_EMPTY);
|
|
|
+ } else {
|
|
|
+ return requestAttributes.getResponse();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ * 4.4.自定义sql字段填充器,自动填充创建修改相关字段 | CustomMetaObjectHandler.java
|
|
|
+
|
|
|
+package com.bitvalue.gp.sys.core.mybatis.fieldfill;
|
|
|
+
|
|
|
+import cn.hutool.log.Log;
|
|
|
+import com.bitvalue.gp.core.context.login.LoginContextHolder;
|
|
|
+import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
|
|
+import org.apache.ibatis.reflection.MetaObject;
|
|
|
+import org.apache.ibatis.reflection.ReflectionException;
|
|
|
+
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 自定义sql字段填充器,自动填充创建修改相关字段
|
|
|
+ *
|
|
|
+ * @author tangling
|
|
|
+ * @date 2021/3/30 15:21
|
|
|
+ */
|
|
|
+ public class CustomMetaObjectHandler implements MetaObjectHandler {
|
|
|
+
|
|
|
+ private static final Log log = Log.get();
|
|
|
+
|
|
|
+ private static final String CREATE_USER = "createUser";
|
|
|
+
|
|
|
+ private static final String CREATE_TIME = "createTime";
|
|
|
+
|
|
|
+ private static final String UPDATE_USER = "updateUser";
|
|
|
+
|
|
|
+ private static final String UPDATE_TIME = "updateTime";
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void insertFill(MetaObject metaObject) {
|
|
|
+ try {
|
|
|
+ //设置createUser(BaseEntity)
|
|
|
+ setFieldValByName(CREATE_USER, this.getUserUniqueId(), metaObject);
|
|
|
+
|
|
|
+ //设置createTime(BaseEntity)
|
|
|
+ setFieldValByName(CREATE_TIME, new Date(), metaObject);
|
|
|
+ } catch (ReflectionException e) {
|
|
|
+ log.warn(">>> CustomMetaObjectHandler处理过程中无相关字段,不做处理");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void updateFill(MetaObject metaObject) {
|
|
|
+ try {
|
|
|
+ //设置updateUser(BaseEntity)
|
|
|
+ setFieldValByName(UPDATE_USER, this.getUserUniqueId(), metaObject);
|
|
|
+ //设置updateTime(BaseEntity)
|
|
|
+ setFieldValByName(UPDATE_TIME, new Date(), metaObject);
|
|
|
+ } catch (ReflectionException e) {
|
|
|
+ log.warn(">>> CustomMetaObjectHandler处理过程中无相关字段,不做处理");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取用户唯一id
|
|
|
+ */
|
|
|
+ private Long getUserUniqueId() {
|
|
|
+ try {
|
|
|
+ return LoginContextHolder.me().getSysLoginUserId();
|
|
|
+ } catch (Exception e) {
|
|
|
+ //如果获取不到就返回-1
|
|
|
+ return -1L;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ————————————————
|
|
|
+ 版权声明:本文为CSDN博主「Tang.Mr」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
|
|
|
+ 原文链接:https://blog.csdn.net/ScholarTang/article/details/116646998
|