type
status
date
slug
summary
tags
category
icon
password
前言:
“改变从现在开始”——Poze
📝 微服务重要知识点
微服务重要知识点
架构风格
- 服务拆分
- 远程调用
- 服务治理
- 请求路由
- 身份认证
- 配置管理
- 服务保护
- 分布式事务
- 异步通信
- 消息可靠性
- 延迟消息
- 分布式搜索
- 倒排索引
- 数据聚合
基本步骤
- 引入MP启动依赖
- 继承父类BaseMapper<实体类>调方法
- 特点
- 对mybatis无侵入
- 开发效率极高
- 实现原理:
- MP通过扫描实体类,并基于反射获取实体类信息作为数据库表信息。约定大于配置,只要遵循约定,MP就可以利用反射来获取实体类的信息从而得知表信息,约定有(类名驼峰转下划线做表名,变量名驼峰转下划线做字段名,名为id的作为主键),不符合约定的话就得自己去配置表名@TableName,字段名@TableField(变量名与属性名不一致,变量名以is开头且为布尔值,is会被省去,成员变量名字与关键字冲突,在数据库表字段中不存在),主键@TableId(value=““,type=IdType.)。
自定义SQL
- 使用原因:大部分公司不允许Java代码中出现SQL语句(只能在mapper或者mapper xml中),因此,需要构建一些查询条件,然后把构建的条件参数传给自定义的方法。
- 步骤
- 使用Warpper构建where条件
- 在mapper方法的参数列表中用Param注解声明Warpper变量名称,必须为ew,其他参数名称根据业务取
- 在mapper.xml中自定义Sql,并用#和传入方法的参数做条件,where后面的条件之间用{ew.customSqlSegment},不需要写where
Service接口
- 作用:进一步简化Java中的sql编写次数
- 使用步骤
- 我们的Service要继承IService<实体类>
- 我们的ServiceImpl要继承ServiceImpl并实现自建的Service,先继承后实现,extends ServiceImpl<M,T>
- M extends BaseMapper
- T extends Object 第二个就是操作的实体
继承的ServiceImpl中的泛型第一个是当前Service用到的Mapper
DTO(Data Transfer Object)
- 用于封装数据传输对象,将数据库中的数据转换为前端需要的格式,方便前后端数据交互
- 一般对象类型都是包装类型修饰,基本数据类型很少用来做对象类型去传输。
- 基本上是实体类或者数据库中没有默认值的字段,也·就是需要传输给前端的变量。
- DTO需要转换成PO才可以被保存到数据库中,借助hutools的BeanUtil.copyProperties拷贝
VO(Value Object)
- 用于封装值对象,根据具体的需求封装不同的数据属性,方便前端页面的展示和交互。
- 其属性类型也一般都是包装类型
- 属性一般根据前端需要的数据来定义
- VO作为返回值,PO需要转换成VO才可以传递,借助hutools的BeanUtil.copyProperties拷贝
关于@Autowired注解直接在属性上注入飘黄解决方法
- 法一:使用构造函数注入,但如果属性过短,可能比较麻烦
- 法二:使用final修饰属性+@RAC必备的构造函数。无需Autowired,对于不需要注入的对象,不加final即可,反之加了final的都是构造函数的一部分,都可以被spring自动注入
RequestBody PathVariable RequestParam
- RequestBody处理POST、PUT的请求体数据,可以将JSON、XML或其他格式的数据转为Java对象
- PathVariable处理RESTful风格的URL中,从URL中提取数据,当URL路径中包含变量,如
/user/{id}
,可以使用@PathVariable
获取该变量的值。
- RequestParam处理GET请求,从URL中获取参数,当需要从URL中的查询字符串中获取参数值,如
/user?id=123
,可以使用@RequestParam
。
MP使用场景总结
- 在简单的CRUD场景,直接调用MP里提供的Service方法,无需写任何的自定义Service或者Mapper,只有在业务逻辑或者Sql语句相对复杂,需要自己写业务写Sql,而MP只提供基础的增删改查且有时候MP提供的Sql不能满足业务需求,那么就需要写自定义Service和Mapper,用lambdaQuery或者lambdaUpdate可以省去上面提到的麻烦
乐观锁和悲观锁
- 乐观锁:compare and set 先比较后更新
.eq(User::getBalance,user.getBalance()) // 乐观锁,只有要修改的数据必须得和之前查到的数据一致才可修改 .update(); // 只有执行到这才可以更新数据
数据库批量新增问题
- 用for循环一条一条的调用mp的save方法耗时特别久
- 借助mp的saveBatch的话可以提升十倍效率,但是一旦遇到数十万条数据,耗时也久(saveBatch类似于preparedStatement函数,将每条要插入到数据库的操作预处理成Sql语句,而执行Sql操作相当于发送网络请求,时间就耗在这里,四次挥手,三次握手。)
- 解决方法
- 使用真正的批处理,即只执行一次(发起一次网络请求),插入所有数据
- 将rewriteBatchedStatements(Mysql的锅) 从 false改成true。即可实现真正的批处理
代码生成
- 使用MP以往的步骤:
- 写一个实体类,如果有需要,还需要在实体类上加一些注解
- 写一个Mapper继承BaseMapper<要操作的实体类>
- 写一个Service接口继承IService<要操作的实体类>接口
- 写一个Service实现类继承ServiceImpl<继承了BaseMapper的Mapper(要映射对应数据库的Mapper),要操作的实体类>并实现自己写的Service接口
- 现在的步骤
- 代码生成器
- 使用MybatisPlus插件(动漫头像那款)…
循环依赖问题
- 使用静态工具DB的方法来操作相关联的表
Java当中的枚举类型与数据库中的int类型的转换
- 原因:纯数字代码可读性极差,借助枚举类型可以让新接触此业务的同事秒懂业务属性与功能
- 解决步骤
- 一:给枚举中的与数据库对应的Value值添加@EnumValue注解
- 二:给文件配置统一的枚举处理器,实现类型转换
- 三:枚举给前端返回的时候默认返回的是枚举项的名字,不太友好,使用MVC的数据传输注解@JsonValue注解标记在对应字段即可更改前端展示值
MP分页插件的使用
- 第一步:配置
- 需要创建一个mp配置类,实例化MPInterceptor(实例化配置对象)
- 在配置类里声明分页插件,并将分页插件对象添加到配置对象中去(添加插件),最后返回配置对象
- 第二步:使用
- 核心API就是page对象 其功能①Page.of(设置页码,大小) ②也可以添加排序条件。
- 把page对象传给对应的分页方法,就可以实现分页查询并且返回分页结果
把别的对象转成当前类对象用of,把自己转成别的对象用to
部署黑马商城
- 部署后端
- 创建mysql对应目录后将本地资料(sql文件,init文件)上传至虚拟机
- 运行命令
docker network create hm-net docker run -d \ --name mysql \ -p 3306:3306 \ -e TZ=Asia/Shanghai \ -e MYSQL_ROOT_PASSWORD=root \ -v /root/mysql/data:/var/lib/mysql \ -v /root/mysql/init:/docker-entrypoint-initdb.d \ -v /root/mysql/conf:/etc/mysql/conf.d \ --network hm-net \ mysql
- 创建一个网络,并让把mysql加入hmall的桥接网络
docker network create hmall docker network connect hmall mysql
- 修改配置文件中数据库的host和密码
- 将后端项目打成jar包后,使用docker命令构建镜像
docker build -t hmall .
- 运行容器
docker run -d -name hmall -network hamll -p 8080:8080 hmall
- 测试,用浏览器访问
http://你的虚拟机地址:8080/hi http://你的虚拟机地址:8080/search/list
- 部署前端
- 将前端项目的nginx目录上传到虚拟机的root目录下
- 执行命令,开启容器
docker run -d \ --name nginx \ -p 18080:18080 \ -p 18081:18081 \ -v /root/nignx/html:/usr/share/nginx/html \ -v /root/nginx/nginx.conf:/etc/nginx/nginx.conf \ --network hmall \ nginx
- 并注意!要开放端口
sudo firewall-cmd --permanent --add-port=18080/tcp sudo firewall-cmd --permanent --add-port=18081/tcp sudo firewall-cmd --reload sudo firewall-cmd --list-all
- 部署黑马商城(简化版DockerCompose)
- DockerCompose语法
version: "3.8" services: mysql: image: mysql container_name: mysql ports: -"3307:3307" environment: TZ:Asia/Shanghai MYSOL_ROOT_PASSWORD: root volumes: - "./mysql/conf:/etc/mysql/conf.d" -"./mysql/data:/var/lib/mysql" -"./mysql/init:/docker-entrypoint-initdb.d' networks: - hmall hmall: build: (构建jar包镜像) context: . (当前目录) dockerfile: Dockerfile container_name: hmall ports: -"8080:8080" networks: - hmall depends_on: mysql (后端依赖与mysql,先创建mysql) nginx: image: nginx container_name: nginx ports: -"18080:18080" -"18081:18081" volumes: -"./nginx/nginx.conf:/etc/nginx/nginx.conf" -"./nginx/html:/usr/share/nginx/html" depends_on: - hmall(前端依赖与后端,先创建后端) networks: - hmall networks:(章鱼哥看到这自动创建网络,自动取完成网络和容器的连接) hamll: name: hmall
- 运行版:
version: "3.8" services: mysql: image: mysql container_name: mysql ports: - "3307:3307" environment: TZ: Asia/Shanghai MYSQL_ROOT_PASSWORD: root volumes: - "./mysql/conf:/etc/mysql/conf.d" - "./mysql/data:/var/lib/mysql" - "./mysql/init:/docker-entrypoint-initdb.d" networks: - hmall hmall: build: context: . dockerfile: Dockerfile container_name: hmall ports: - "8080:8080" networks: - hmall depends_on: - mysql nginx: image: nginx container_name: nginx ports: - "18080:18080" - "18081:18081" volumes: - "./nginx/nginx.conf:/etc/nginx/nginx.conf" - "./nginx/html:/usr/share/nginx/html" depends_on: - hmall networks: - hmall networks: hamll: name: hmall
关于用户信息的存取(利用拦截器将当前线程的用户信息存入线程域里)
- LoginFormDTO
- 存储着用户名,密码,记住我属性以及报错信息注解
@Data @ApiModel(description = "登录表单实体") public class LoginFormDTO { @ApiModelProperty(value = "用户名", required = true) @NotNull(message = "用户名不能为空") private String username; @NotNull(message = "密码不能为空") @ApiModelProperty(value = "用户名", required = true) private String password; @ApiModelProperty(value = "是否记住我", required = false) private Boolean rememberMe = false; }
- UserController
- 返回值是VO,传入的参数是DTO,return后面直接跟方法(方法的返回值是VO)
@ApiOperation("用户登录接口") @PostMapping("login") public UserLoginVO login(@RequestBody @Validated LoginFormDTO loginFormDTO){ return userService.login(loginFormDTO); }
- Service
- 定义需要实习的业务的接口,返回值与传入参数和Controller一致?
UserLoginVO login(LoginFormDTO loginFormDTO);
- ServiceImpl
- 实现Service接口,返回值与传入参数和接口一致。查询数据库,校验,利用utils(JwtTool)封装一个JWT(生成一个Token),再封装成VO返回
@Override public UserLoginVO login(LoginFormDTO loginDTO) { // 1.数据校验 String username = loginDTO.getUsername(); String password = loginDTO.getPassword(); // 2.根据用户名或手机号查询 User user = lambdaQuery().eq(User::getUsername, username).one(); Assert.notNull(user, "用户名错误"); // 3.校验是否禁用 if (user.getStatus() == UserStatus.FROZEN) { throw new ForbiddenException("用户被冻结"); } // 4.校验密码 if (!passwordEncoder.matches(password, user.getPassword())) { throw new BadRequestException("用户名或密码错误"); } // 5.生成TOKEN String token = jwtTool.createToken(user.getId(), jwtProperties.getTokenTTL()); // 6.封装VO返回 UserLoginVO vo = new UserLoginVO(); vo.setUserId(user.getId()); vo.setUsername(user.getUsername()); vo.setBalance(user.getBalance()); vo.setToken(token); return vo; }
- Interceptor
- SpringMVC的一个拦截器,实现了一个前置方法和最终方法,前置方法里就会从请求头中取出authorization(token),然后利用utils的JwtTool将token中的userId取出存入上下文,后续业务需要使用到用户信息即可直接从UserContext获取。
- 最终方法把所有与当条用户数据有关的信息清除避免内存泄漏
@RequiredArgsConstructor public class LoginInterceptor implements HandlerInterceptor { private final JwtTool jwtTool; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1.获取请求头中的 token String token = request.getHeader("authorization"); // 2.校验token Long userId = jwtTool.parseToken(token); // 3.存入上下文 UserContext.setUser(userId); // 4.放行 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 清理用户 UserContext.removeUser(); } }
- UserContext
- 上下文,通常里面只有属性,有Data注解,开放get set方法,将当前线程域的用户存储到ThreadLocal当中。由于Tomcat的进入请求基本数是一个请求,如果不创建自己的线程,那么都是由一个线程获取。因此我们在拦截器里存入当前线程的线程域,将来在处理业务的整个过程,只要这个用户不退出登录,随时都可以基于User’Context获取用户id。
public class UserContext { private static final ThreadLocal<Long> tl = new ThreadLocal<>(); /** * 保存当前登录用户信息到ThreadLocal * @param userId 用户id */ public static void setUser(Long userId) { tl.set(userId); } /** * 获取当前登录用户信息 * @return 用户id */ public static Long getUser() { return tl.get(); } /** * 移除当前登录用户信息 */ public static void removeUser(){ tl.remove(); }}
SpringBoot自动装配注解飘黄解决方法
- 在属性上直接使用@Autowired注解会飘黄
- 解决方法
- 一:使用构造器注入,写一个属性对应的构造函数,但如果成员变量很多,就会很繁琐
- 二:使用lombok注解@RequiredArgsConstructor为带了final修饰的属性(常量)自动创建构造函数,不加final或者给了初始值就不进行自动装配(推荐)
远程调用
- 像前端往后端发送请求一样,Java利用代码自己向自己发送请求
- 使用步骤
- 注入RestTemplate到Spring容器
- new一个Template然后把它变成bean(加注解)
- 发起远程调用
- 借助其中的exchange方法,其参数有
- 请求路径 url httpxxx
- 请求方式 method HttpMethod.GET
- 请求实体 requestEntity 可以为空
- 返回值类型 responseType User.class
- 请求参数 urlVariables Map.of(“?”,“?”)
- 使用更为简单的openFeign
- 步骤
- 引依赖,引入openFeign和负载均衡组件springCloudLoadBanlancer
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId> </dependency>
- 在启动类里开关,通过@EnableFeignClients注解,启用OpenFeign功能
@SpringBootApplication @EnableFeignClients
- 编写FeignClient
@FeignClient(value = "item-service") public interface ItemClient { @GetMapping("/items") public List<ItemDTO> queryItemsByIds(@RequestParam Collection<Long> ids); }
- 使用FeignClient
List<ItemDTO> items = itemClient.queryItemsByIds(itemIds);
- 拓展:openFeign整合okhttp连接池加强性能。
- 引依赖
<dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-okhttp</artifactId> </dependency>
- 配置开关
feign: okhttp: enabled: true
服务注册
- 步骤
- 引入nacos discovery 依赖
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency>
- 配置nacos地址
cloud: nacos: server-addr: 192.168.239.129:8848
- 服务发现
服务治理
- 三个角色
- 服务消费者:调用其他服务提供的接口
- 服务提供者:暴露服务接口,供其他服务使用
- 注册中心:收集服务提供者的信息和健康状态(心跳机制),并将其推送给消费者(家政中心)
- 当有多个实例时,消费者应该选择哪个?
- 多种负载均很算法
- 随机
- 轮询
- 加权轮询
网关
- 概念、作用:网关就像门口的保安大爷,微服务属于小区里的住户,前端去找小区里的人可以向保安大爷询问,但是保安大爷要先确定前端身份,也就是说网关就是起到访问路由和安全校验的作用。
- 路由规则
server: port: 8080spring: application: name: gateway cloud: nacos: server-addr: 192.168.239.129:8848 gateway: routes: - id: item # 路由规则id,自定义,唯一 uri: lb://item-service # 路由的目标服务,lb(load banlance)代表负载均衡,会从注册中心拉取服务列表 predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务 - Path=/items/**,/search/** # 这里是以请求路径作为判断规则
网关请求处理流程(生命周期、执行流程)
微服务登录校验解决方案
- 我们把登录校验的动作从每一个微服务内部移到了网关)globalFilter)去做,然后再利用微服务内部的UserContext(ThreadLocal)保存和传递用户信息(一般一个微服务里就是一个线程再业务之间跑),微服务与微服务之间的用户信息传递利用了openFeign(RequestFilter)的Http请求头。
服务器(电脑)
- 向微服务发请求前都会经过保安大爷(网关)
网关
- 电脑发送请求来了,做两件事情
- 第一,做JWT的登录校验
- 获取token中的用户信息(userId)
- 第二,将用户信息放在请求头往微服务传,因为是处理业务的是微服务
- 保存到请求头,保安大爷校验完请求的身份后,就让请求带着用户信息(在请求头)传递到后边的微服务了
- 传递就需要用到exchange的mutateAPI去修改请求头(将用户信息添加到里面,名字要规范同一),这样网关往下传的时候就会携带这个用户信息在请求头里
- 这里用到的过滤器叫GlobalFilter
微服务
- 微服务的业务之间获取用户信息
- 首先,因为一个微服务里的好多好多业务需要用到用户信息,咱不可能在每个微服务的业务里动去写获取用户信息的逻辑
- 因此,在common微服务中定义了一个拦截器HandlerInterceptor(SpringMVC的拦截器)将用户信息取出来放在ThreadLocal里,供其他业务取用(一般同一个微服务都是一个线程到处跑,所以共用一个线程域)
- 这里要注意一个点,要使SpringMVC的拦截器生效,还需要在Config里把你编写的实现了HandlerInterceptor接口的拦截器类实例给注册进SpringMVC里,并加上@Configuration
- 这里可能在注册完后启动网关会报错,这是因为配置类放在common模块下没有被其他微服务扫描到(涉及SpringBoot自动装配原理),还有就是微服务底层使用的不是springMVC,它是非阻塞式的响应式编程(web flux)
- 解决方法:
- 在SpringBoot的resource目录下META-INF下定义一个文件(spring.factories,新版好像叫enableAutoConfiguration)去记录这些配置类
- 又来一个坑了,这时候启动网关,会报一个FileNotFoundException,它说找不到咱实现的WebMvcConfigurer!
- 原因:(网关的底层不是SpringMVC,所以找不到MVC的包)
- 1 网关的pom里依赖了common,common里有我们写的SpringMVC拦截器,但是网关的底层不是SpringMVC,所以就会报找不到springMVC的WebMvcConfigurer
- 2 common被网关引用了,也被微服务引用了,common里有springMVC的配置类
- 解决方法(让common模块只在微服务里生效,不在网关生效)
- 只让common里的SpringMVC配置类在微服务里生效,在网关里不生效(依然是springBoot自动装配原理)
- 加一个Conditional条件,在这里网关和微服务的差别在于网关的底层是web flux 微服务的底层是springMVC
- 换句话讲,网关没有springMVC 而微服务有,因此利用这个条件(有无SpringMVC的核心API DispatcherService.class)区分
- 也就是说 加注解@ConditionalOnClass(DispatcherService.class),这样pom引用了common的模块就会根据是否满足这个条件来加载对应的配置类了。
- 微服务与微服务之间获取用户信息
- 首先下个定论,别的微服务要获取用户信息也是从common模块里的UserContext里获取,每个微服务都引入了common的依赖包,因此只需要把不同微服务之间的http请求头带上用户信息就可以使用common的UserContext来存取用户信息了。
- 要想在不同微服务间获取到用户信息,那么就必须保证请求在进入另一个微服务之前一定要在请求头上带着用户信息,这样咱才能从请求头拿到它,然后其他业务也就可以通过UserContext(ThreadLocal)获取用户信息(userId)。
- 因此这就要用到openFeign提供的拦截器接口RequestInterceptor(所有由openFeign发起的请求都会先调用拦截器处理请求。
- RequestInterceptor里的apply方法的参数RequestTemplate提供了修改请求头的API header(),可以通过这个来往请求头里添加用户信息然后在不同微服务之间的请求传递。
- 在请求头添加完用户信息后,还需要在每个微服务的启动类上加上defaultConfiguration = 类名.class
配置管理服务
配置共享
配置热更新
- 添加一个配置类,注册进容器,并且标上前缀,这样在nacos里填热更新配置的时候就可以找到前缀对应下的属性了。
@Data@Component@ConfigurationProperties(prefix = "hm.cart")public class CartProperties { private Integer maxItems;}
- 在nacos里添加一个配置,名字要对应后端配置文件里的spring,application的name+config.file-extension,例如
- bootstrap.yaml里的配置
application: # 每个微服务都要起一个名字 name: cart-service # nacos配置文件名 profiles: active: dev # 选填 cloud: nacos: server-addr: 192.168.239.129:8848 config: file-extension: yaml # nacos配置文件名后缀
- nacos配置
动态路由(拓展 P70)
SpringBoot和SpringCloud的启动流程
SpringBoot
- 启动
- 读取application.yaml文件
- 基于application.yaml文件里的配置完成Spring ApplicationContext的初始化
- ApplicationContext是Spring的bean工厂(上下文、容器)
- 创建完成
SpringCloud
- 启动
- 尝试拉取nacos中的配置
- 再基于nacos中的共享配置完成springcloud上下文的初始化
- 在上面这步执行完后,才会取读取springboot的application.yaml加载和上下文的初始化
- 因此引出一个问题,有了springcloud后先读取的是nacos的配置,但是nacos地址在application.yaml中
- 所以解决方案:多了一个新的配置文件bootstrap.yaml
- bootstrap是引导的意思,只要加入了这个文件,项目一启动最先执行的就算这个配置文件
总结
雪崩问题
- 产生的原因
- 微服务相互调用,服务提供者出现故障或阻塞
- 服务调用者没有做好异常处理,导致自身故障
- 调用链种的所有服务级联失败,导致整个集群故障
- 解决问题的思路
- 尽量避免服务出现故障或阻塞(从源头上处理)
- 保证代码的健壮性
- 保证网络畅通
- 能应对较高的并发请求
- 服务调用者做好远程调用异常的后备方案,避免故障扩散(从预防角度处理,隔离)
服务保护方案
- 请求限流
- 限制访问微服务的请求的并发量,避免服务因流量激增出现故障
- QPS(Query Per Second)
- 每秒钟处理请求数
- 线程隔离
- 也叫做舱壁模式,模拟船舱隔板的防水原理
- 通过限定每个业务能使用的线程数量而将故障业务隔离,避免故障扩散。
- 线程数是可以使用的资源
- 5个并发线程数,如果单线程QPS(每秒处理请求量)为2,则5线程QPS为10
- 服务熔断
- 由断路器统计请求的异常比例或者慢调用比例,如果超出阈值则会熔断该业务,则拦截接口的请求
- 熔断期间,所有请求快速失败,全部走fallback逻辑
分布式事务(面试决斗场)
- 对于单体项目,不管有多少业务都是在一个service方法(一个事务)里,因此它们满足事务(事务就是与数据库相关的操作)的ACID特性
- ACID
- Atomicity 原子性:一个事务种的所有操作要么全部成功执行,要么全部失败回滚。
- Consistency 一致性:在十五执行前后,数据库的状态应保持一致。事务的执行应使数据库从一个一致的状态转换到另一个一致的状态,即满足事务的约束和规定的业务规则。
- Isolation 隔离性:多个事务并发执行时,每个事务都应该被隔离开,互不干扰。每个事务应该感觉不到其他事务的存在,即使事务同时对同一数据进行操作,也不会产生相互干扰的结果。
- Durability 持久性:一旦事务提交成功,对数据库的修改就会永久保存,即使发生系统故障或者端点等状况,数据也能够回复。
- 解决方案
- 使用Seata,首先将它的sql配置在数据库运行,然后准备配置文件
- 作者:poze624
- 链接:https://poze624.top/notes/20240623212308
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。