当前位置:   article > 正文

Spring Boot+gRPC构建微服务并部署到Istio(详细教程)

istio gateway spring boot

点击关注公众号,实用技术文章及时了解f5bee0a1bc8eb63baf63f7d7c892eb85.png

作为Service Mesh和云原生技术的忠实拥护者,我却一直没有开发过Service Mesh的应用。正好最近受够了Spring Cloud的“折磨”,对Kubernetes也可以熟练使用了,而且网上几乎没有Spring Boot微服务部署到Istio的案例,我就开始考虑用Spring Boot写个微服务的Demo并且部署到Istio。项目本身不复杂,就是发送一个字符串并且返回一个字符串的最简单的Demo。

题外话:我本来是想用Spring MVC写的——因为周围有的同学不相信Spring MVC也可以开发微服务,但是Spring MVC的各种配置和依赖问题把我整的想吐,为了少掉几根头发,还是用了方便好用的Spring Boot。

为什么要用Istio

目前,对于Java技术栈来说,构建微服务的最佳选择是Spring Boot而Spring Boot一般搭配目前落地案例很多的微服务框架Spring Cloud来使用。

Spring Cloud看似很完美,但是在实际上手开发后,很容易就会发现Spring Cloud存在以下比较严重的问题:

  • 服务治理相关的逻辑存在于Spring Cloud Netflix等SDK中,与业务代码紧密耦合。

  • SDK对业务代码侵入太大,SDK发生升级且无法向下兼容时,业务代码必须做出改变以适配SDK的升级——即使业务逻辑并没有发生任何变化。

  • 各种组件令人眼花缭乱,质量也参差不齐,学习成本太高,且组件之间代码很难完全复用,仅仅为了实现治理逻辑而学习SDK也并不是很好的选择。

  • 绑定于Java技术栈,虽然可以接入其他语言但要手动实现服务治理相关的逻辑,不符合微服务“可以用多种语言进行开发”的原则。

  • Spring Cloud仅仅是一个开发框架,没有实现微服务所必须的服务调度、资源分配等功能,这些需求要借助Kubernetes等平台来完成。但Spring Cloud与Kubernetes功能上有重合,且部分功能也存在冲突,二者很难完美配合。

替代Spring Cloud的选择有没有呢?有!它就是Istio。

Istio彻底把治理逻辑从业务代码中剥离出来,成为了独立的进程(Sidecar)。部署时两者部署在一起,在一个Pod里共同运行,业务代码完全感知不到Sidecar的存在。这就实现了治理逻辑对业务代码的零侵入——实际上不仅是代码没有侵入,在运行时两者也没有任何的耦合。这使得不同的微服务完全可以使用不同语言、不同技术栈来开发,也不用担心服务治理问题,可以说这是一种很优雅的解决方案了。

所以,“为什么要使用Istio”这个问题也就迎刃而解了——因为Istio解决了传统微服务诸如业务逻辑与服务治理逻辑耦合、不能很好地实现跨语言等痛点,而且非常容易使用。只要会用Kubernetes,学习Istio的使用一点都不困难。

为什么要使用gRPC作为通信框架?

在微服务架构中,服务之间的通信是一个比较大的问题,一般采用RPC或者RESTful API来实现。

Spring Boot可以使用RestTemplate调用远程服务,但这种方式不直观,代码也比较复杂,进行跨语言通信也是个比较大的问题;而gRPC相比Dubbo等常见的Java RPC框架更加轻量,使用起来也很方便,代码可读性高,并且与Istio和Kubernetes可以很好地进行整合,在Protobuf和HTTP2的加持下性能也还不错,所以这次选择了gRPC来解决Spring Boot微服务间通信的问题。并且,虽然gRPC没有服务发现、负载均衡等能力,但是Istio在这方面就非常强大,两者形成了完美的互补关系。

由于考虑到各种grpc-spring-boot-starter可能会对Spring Boot与Istio的整合产生不可知的副作用,所以这一次我没有用任何的grpc-spring-boot-starter,而是直接手写了gRPC与Spring Boot的整合。不想借助第三方框架整合gRPC和Spring Boot的可以简单参考一下我的实现。

编写业务代码

首先使用Spring Initializr建立父级项目spring-boot-istio,并引入gRPC的依赖。pom文件如下:

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  3.          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  4.     <modelVersion>4.0.0</modelVersion>
  5.     <modules>
  6.         <module>spring-boot-istio-api</module>
  7.         <module>spring-boot-istio-server</module>
  8.         <module>spring-boot-istio-client</module>
  9.     </modules>
  10.     <parent>
  11.         <groupId>org.springframework.boot</groupId>
  12.         <artifactId>spring-boot-starter-parent</artifactId>
  13.         <version>2.2.6.RELEASE</version>
  14.         <relativePath/> <!-- lookup parent from repository -->
  15.     </parent>
  16.     <groupId>site.wendev</groupId>
  17.     <artifactId>spring-boot-istio</artifactId>
  18.     <version>0.0.1-SNAPSHOT</version>
  19.     <name>spring-boot-istio</name>
  20.     <description>Demo project for Spring Boot With Istio.</description>
  21.     <packaging>pom</packaging>
  22.     <properties>
  23.         <java.version>1.8</java.version>
  24.     </properties>
  25.     <dependencyManagement>
  26.         <dependencies>
  27.             <dependency>
  28.                 <groupId>io.grpc</groupId>
  29.                 <artifactId>grpc-all</artifactId>
  30.                 <version>1.28.1</version>
  31.             </dependency>
  32.         </dependencies>
  33.     </dependencyManagement>
  34. </project>

然后建立公共依赖模块spring-boot-istio-api,pom文件如下,主要就是gRPC的一些依赖:

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <project xmlns="http://maven.apache.org/POM/4.0.0"
  3.          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4.          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  5.     <parent>
  6.         <artifactId>spring-boot-istio</artifactId>
  7.         <groupId>site.wendev</groupId>
  8.         <version>0.0.1-SNAPSHOT</version>
  9.     </parent>
  10.     <modelVersion>4.0.0</modelVersion>
  11.     <artifactId>spring-boot-istio-api</artifactId>
  12.     <dependencies>
  13.         <dependency>
  14.             <groupId>io.grpc</groupId>
  15.             <artifactId>grpc-all</artifactId>
  16.         </dependency>
  17.         <dependency>
  18.             <groupId>javax.annotation</groupId>
  19.             <artifactId>javax.annotation-api</artifactId>
  20.             <version>1.3.2</version>
  21.         </dependency>
  22.     </dependencies>
  23.     <build>
  24.         <extensions>
  25.             <extension>
  26.                 <groupId>kr.motd.maven</groupId>
  27.                 <artifactId>os-maven-plugin</artifactId>
  28.                 <version>1.6.2</version>
  29.             </extension>
  30.         </extensions>
  31.         <plugins>
  32.             <plugin>
  33.                 <groupId>org.xolstice.maven.plugins</groupId>
  34.                 <artifactId>protobuf-maven-plugin</artifactId>
  35.                 <version>0.6.1</version>
  36.                 <configuration>
  37.                     <protocArtifact>com.google.protobuf:protoc:3.11.3:exe:${os.detected.classifier}</protocArtifact>
  38.                     <pluginId>grpc-java</pluginId>
  39.                     <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.28.1:exe:${os.detected.classifier}</pluginArtifact>
  40.                     <protocExecutable>/Users/jiangwen/tools/protoc-3.11.3/bin/protoc</protocExecutable>
  41.                 </configuration>
  42.                 <executions>
  43.                     <execution>
  44.                         <goals>
  45.                             <goal>compile</goal>
  46.                             <goal>compile-custom</goal>
  47.                         </goals>
  48.                     </execution>
  49.                 </executions>
  50.             </plugin>
  51.         </plugins>
  52.     </build>
  53. </project>

建立src/main/proto文件夹,在此文件夹下建立hello.proto,定义服务间的接口如下:

  1. syntax = "proto3";
  2. option java_package = "site.wendev.spring.boot.istio.api";
  3. option java_outer_classname = "HelloWorldService";
  4. package helloworld;
  5. service HelloWorld {
  6.     rpc SayHello (HelloRequest) returns (HelloResponse) {}
  7. }
  8. message HelloRequest {
  9.     string name = 1;
  10. }
  11. message HelloResponse {
  12.     string message = 1;
  13. }

很简单,就是发送一个name返回一个带name的message。

然后生成服务端和客户端的代码,并且放到java文件夹下。这部分内容可以参考gRPC的官方文档。

有了API模块之后,就可以编写服务提供者(服务端)和服务消费者(客户端)了。这里我们重点看一下如何整合gRPC和Spring Boot。

服务端

业务代码非常简单:

  1. /**
  2.  * 服务端业务逻辑实现
  3.  *
  4.  * @author 江文
  5.  */
  6. @Slf4j
  7. @Component
  8. public class HelloServiceImpl extends HelloWorldGrpc.HelloWorldImplBase {
  9.     @Override
  10.     public void sayHello(HelloWorldService.HelloRequest request,
  11.                          StreamObserver<HelloWorldService.HelloResponse> responseObserver) {
  12.         // 根据请求对象建立响应对象,返回响应信息
  13.         HelloWorldService.HelloResponse response = HelloWorldService.HelloResponse
  14.                 .newBuilder()
  15.                 .setMessage(String.format("Hello, %s. This message comes from gRPC.", request.getName()))
  16.                 .build();
  17.         responseObserver.onNext(response);
  18.         responseObserver.onCompleted();
  19.         log.info("Client Message Received:[{}]", request.getName());
  20.     }
  21. }

光有业务代码还不行,我们还需要在应用启动时把gRPC Server也给一起启动起来。首先写一下Server端的启动、关闭等逻辑:

  1. /**
  2.  * gRPC Server的配置——启动、关闭等
  3.  * 需要使用<code>@Component</code>注解注册为一个Spring Bean
  4.  *
  5.  * @author 江文
  6.  */
  7. @Slf4j
  8. @Component
  9. public class GrpcServerConfiguration {
  10.     @Autowired
  11.     HelloServiceImpl service;
  12.     /** 注入配置文件中的端口信息 */
  13.     @Value("${grpc.server-port}")
  14.     private int port;
  15.     private Server server;
  16.     public void start() throws IOException {
  17.         // 构建服务端
  18.         log.info("Starting gRPC on port {}.", port);
  19.         server = ServerBuilder.forPort(port).addService(service).build().start();
  20.         log.info("gRPC server started, listening on {}.", port);
  21.         // 添加服务端关闭的逻辑
  22.         Runtime.getRuntime().addShutdownHook(new Thread(() -> {
  23.             log.info("Shutting down gRPC server.");
  24.             GrpcServerConfiguration.this.stop();
  25.             log.info("gRPC server shut down successfully.");
  26.         }));
  27.     }
  28.     private void stop() {
  29.         if (server != null) {
  30.             // 关闭服务端
  31.             server.shutdown();
  32.         }
  33.     }
  34.     public void block() throws InterruptedException {
  35.         if (server != null) {
  36.             // 服务端启动后直到应用关闭都处于阻塞状态,方便接收请求
  37.             server.awaitTermination();
  38.         }
  39.     }
  40. }

定义好gRPC的启动、停止等逻辑后,就可以使用CommandLineRunner把它加入到Spring Boot的启动中去了:

  1. /**
  2.  * 加入gRPC Server的启动、停止等逻辑到Spring Boot的生命周期中
  3.  *
  4.  * @author 江文
  5.  */
  6. @Component
  7. public class GrpcCommandLineRunner implements CommandLineRunner {
  8.     @Autowired
  9.     GrpcServerConfiguration configuration;
  10.     @Override
  11.     public void run(String... args) throws Exception {
  12.         configuration.start();
  13.         configuration.block();
  14.     }
  15. }

之所以要把gRPC的逻辑注册成Spring Bean,就是因为在这里要获取到它的实例并进行相应的操作。

这样,在启动Spring Boot时,由于CommandLineRunner的存在,gRPC服务端也就可以一同启动了。

客户端

业务代码同样非常简单:

  1. /**
  2.  * 客户端业务逻辑实现
  3.  *
  4.  * @author 江文
  5.  */
  6. @RestController
  7. @Slf4j
  8. public class HelloController {
  9.     @Autowired
  10.     GrpcClientConfiguration configuration;
  11.     @GetMapping("/hello")
  12.     public String hello(@RequestParam(name = "name", defaultValue = "JiangWen", required = false) String name) {
  13.         // 构建一个请求
  14.         HelloWorldService.HelloRequest request = HelloWorldService.HelloRequest
  15.                 .newBuilder()
  16.                 .setName(name)
  17.                 .build();
  18.         // 使用stub发送请求至服务端
  19.         HelloWorldService.HelloResponse response = configuration.getStub().sayHello(request);
  20.         log.info("Server response received: [{}]", response.getMessage());
  21.         return response.getMessage();
  22.     }
  23. }

在启动客户端时,我们需要打开gRPC的客户端,并获取到channel和stub以进行RPC通信,来看看gRPC客户端的实现逻辑:

  1. /**
  2.  * gRPC Client的配置——启动、建立channel、获取stub、关闭等
  3.  * 需要注册为Spring Bean
  4.  *
  5.  * @author 江文
  6.  */
  7. @Slf4j
  8. @Component
  9. public class GrpcClientConfiguration {
  10.     /** gRPC Server的地址 */
  11.     @Value("${server-host}")
  12.     private String host;
  13.     /** gRPC Server的端口 */
  14.     @Value("${server-port}")
  15.     private int port;
  16.     private ManagedChannel channel;
  17.     private HelloWorldGrpc.HelloWorldBlockingStub stub;
  18.     public void start() {
  19.         // 开启channel
  20.         channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
  21.         // 通过channel获取到服务端的stub
  22.         stub = HelloWorldGrpc.newBlockingStub(channel);
  23.         log.info("gRPC client started, server address: {}:{}", host, port);
  24.     }
  25.     public void shutdown() throws InterruptedException {
  26.         // 调用shutdown方法后等待1秒关闭channel
  27.         channel.shutdown().awaitTermination(1, TimeUnit.SECONDS);
  28.         log.info("gRPC client shut down successfully.");
  29.     }
  30.     public HelloWorldGrpc.HelloWorldBlockingStub getStub() {
  31.         return this.stub;
  32.     }
  33. }

比服务端要简单一些。

最后,仍然需要一个CommandLineRunner把这些启动逻辑加入到Spring Boot的启动过程中:

  1. /**
  2.  * 加入gRPC Client的启动、停止等逻辑到Spring Boot生命周期中
  3.  *
  4.  * @author 江文
  5.  */
  6. @Component
  7. @Slf4j
  8. public class GrpcClientCommandLineRunner implements CommandLineRunner {
  9.     @Autowired
  10.     GrpcClientConfiguration configuration;
  11.     @Override
  12.     public void run(String... args) {
  13.         // 开启gRPC客户端
  14.         configuration.start();
  15.         
  16.         // 添加客户端关闭的逻辑
  17.         Runtime.getRuntime().addShutdownHook(new Thread(() -> {
  18.             try {
  19.                 configuration.shutdown();
  20.             } catch (InterruptedException e) {
  21.                 e.printStackTrace();
  22.             }
  23.         }));
  24.     }
  25. }

编写Dockerfile

业务代码跑通之后,就可以制作Docker镜像,准备部署到Istio中去了。

在开始编写Dockerfile之前,先改动一下客户端的配置文件:

  1. server:
  2.   port: 19090
  3. spring:
  4.   application:
  5.     name: spring-boot-istio-client
  6. server-host: ${server-host}
  7. server-port: ${server-port}

接下来编写Dockerfile:

服务端:

  1. FROM openjdk:8u121-jdk
  2. RUN /bin/cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
  3.   && echo 'Asia/Shanghai' >/etc/timezone
  4. ADD /target/spring-boot-istio-server-0.0.1-SNAPSHOT.jar /
  5. ENV SERVER_PORT="18080"
  6. ENTRYPOINT java -jar /spring-boot-istio-server-0.0.1-SNAPSHOT.jar

可以看到这里添加了启动参数,配合前面的配置,当这个镜像部署到Kubernetes集群时,就可以在Kubernetes的配合之下通过服务名找到服务端了。

同时,服务端和客户端的pom文件中添加:

  1. <build>
  2.     <plugins>
  3.         <plugin>
  4.             <groupId>org.springframework.boot</groupId>
  5.             <artifactId>spring-boot-maven-plugin</artifactId>
  6.             <configuration>
  7.                 <executable>true</executable>
  8.             </configuration>
  9.         </plugin>
  10.         <plugin>
  11.             <groupId>com.spotify</groupId>
  12.             <artifactId>dockerfile-maven-plugin</artifactId>
  13.             <version>1.4.13</version>
  14.             <dependencies>
  15.                 <dependency>
  16.                     <groupId>javax.activation</groupId>
  17.                     <artifactId>activation</artifactId>
  18.                     <version>1.1</version>
  19.                 </dependency>
  20.             </dependencies>
  21.             <executions>
  22.                 <execution>
  23.                     <id>default</id>
  24.                     <goals>
  25.                         <goal>build</goal>
  26.                         <goal>push</goal>
  27.                     </goals>
  28.                 </execution>
  29.             </executions>
  30.             <configuration>
  31.                 <repository>wendev-docker.pkg.coding.net/develop/docker/${project.artifactId}
  32.                 </repository>
  33.                 <tag>${project.version}</tag>
  34.                 <buildArgs>
  35.                     <JAR_FILE>${project.build.finalName}.jar</JAR_FILE>
  36.                 </buildArgs>
  37.             </configuration>
  38.         </plugin>
  39.     </plugins>
  40. </build>

这样执行mvn clean package时就可以同时把docker镜像构建出来了。

编写部署文件

有了镜像之后,就可以写部署文件了:

服务端:

  1. apiVersion: v1
  2. kind: Service
  3. metadata:
  4.   name: spring-boot-istio-server
  5. spec:
  6.   type: ClusterIP
  7.   ports:
  8.     - name: http
  9.       port: 18080
  10.       targetPort: 18080
  11.     - name: grpc
  12.       port: 18888
  13.       targetPort: 18888
  14.   selector:
  15.     app: spring-boot-istio-server
  16. ---
  17. apiVersion: apps/v1
  18. kind: Deployment
  19. metadata:
  20.   name: spring-boot-istio-server
  21. spec:
  22.   replicas: 1
  23.   selector:
  24.     matchLabels:
  25.       app: spring-boot-istio-server
  26.   template:
  27.     metadata:
  28.       labels:
  29.         app: spring-boot-istio-server
  30.     spec:
  31.       containers:
  32.         - name: spring-boot-istio-server
  33.           image: wendev-docker.pkg.coding.net/develop/docker/spring-boot-istio-server:0.0.1-SNAPSHOT
  34.           imagePullPolicy: Always
  35.           tty: true
  36.           ports:
  37.             - name: http
  38.               protocol: TCP
  39.               containerPort: 18080
  40.             - name: grpc
  41.               protocol: TCP
  42.               containerPort: 18888

主要是暴露服务端的端口:18080和gRPC Server的端口18888,以便可以从Pod外部访问服务端。

客户端:

  1. apiVersion: v1
  2. kind: Service
  3. metadata:
  4.   name: spring-boot-istio-client
  5. spec:
  6.   type: ClusterIP
  7.   ports:
  8.     - name: http
  9.       port: 19090
  10.       targetPort: 19090
  11.   selector:
  12.     app: spring-boot-istio-client
  13. ---
  14. apiVersion: apps/v1
  15. kind: Deployment
  16. metadata:
  17.   name: spring-boot-istio-client
  18. spec:
  19.   replicas: 1
  20.   selector:
  21.     matchLabels:
  22.       app: spring-boot-istio-client
  23.   template:
  24.     metadata:
  25.       labels:
  26.         app: spring-boot-istio-client
  27.     spec:
  28.       containers:
  29.         - name: spring-boot-istio-client
  30.           image: wendev-docker.pkg.coding.net/develop/docker/spring-boot-istio-client:0.0.1-SNAPSHOT
  31.           imagePullPolicy: Always
  32.           tty: true
  33.           ports:
  34.             - name: http
  35.               protocol: TCP
  36.               containerPort: 19090

主要是暴露客户端的端口19090,以便访问客户端并调用服务端。

如果想先试试把它们部署到k8s可不可以正常访问,可以这样配置Ingress:

  1. apiVersion: networking.k8s.io/v1beta1
  2. kind: Ingress
  3. metadata:
  4.   name: nginx-web
  5.   annotations:
  6.     kubernetes.io/ingress.class: "nginx"
  7.     nginx.ingress.kubernetes.io/use-reges: "true"
  8.     nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"
  9.     nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
  10.     nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
  11.     nginx.ingress.kubernetes.io/proxy-body-size: "10m"
  12.     nginx.ingress.kubernetes.io/rewrite-target: /
  13. spec:
  14.   rules:
  15.     - host: dev.wendev.site
  16.       http:
  17.         paths:
  18.           - path: /
  19.             backend:
  20.               serviceName: spring-boot-istio-client
  21.               servicePort: 19090

Istio的网关配置文件与k8s不大一样:

  1. apiVersion: networking.istio.io/v1alpha3
  2. kind: Gateway
  3. metadata:
  4.   name: spring-boot-istio-gateway
  5. spec:
  6.   selector:
  7.     istio: ingressgateway
  8.   servers:
  9.     - port:
  10.         number: 80
  11.         name: http
  12.         protocol: HTTP
  13.       hosts:
  14.         - "*"
  15. ---
  16. apiVersion: networking.istio.io/v1alpha3
  17. kind: VirtualService
  18. metadata:
  19.   name: spring-boot-istio
  20. spec:
  21.   hosts:
  22.     - "*"
  23.   gateways:
  24.     - spring-boot-istio-gateway
  25.   http:
  26.     - match:
  27.         - uri:
  28.             exact: /hello
  29.       route:
  30.         - destination:
  31.             host: spring-boot-istio-client
  32.             port:
  33.               number: 19090

主要就是暴露/hello这个路径,并且指定对应的服务和端口。

部署应用到Istio

首先搭建k8s集群并且安装istio。我使用的k8s版本是1.16.0,Istio版本是最新的1.6.0-alpha.1,使用istioctl命令安装Istio。建议跑通官方的bookinfo示例之后再来部署本项目。

注:以下命令都是在开启了自动注入Sidecar的前提下运行的

我是在虚拟机中运行的k8s,所以istio-ingressgateway没有外部ip:

  1. $ kubectl get svc istio-ingressgateway -n istio-system
  2. NAME                   TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)                                                                                                                                      AGE
  3. istio-ingressgateway   NodePort   10.97.158.232   <none>        15020:30388/TCP,80:31690/TCP,443:31493/TCP,15029:32182/TCP,15030:31724/TCP,15031:30887/TCP,15032:30369/TCP,31400:31122/TCP,15443:31545/TCP   26h

所以,需要设置IP和端口,以NodePort的方式访问gateway:

  1. export INGRESS_PORT=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.spec.ports[?(@.name=="http2")].nodePort}')
  2. export SECURE_INGRESS_PORT=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.spec.ports[?(@.name=="https")].nodePort}')
  3. export INGRESS_HOST=127.0.0.1
  4. export GATEWAY_URL=$INGRESS_HOST:$INGRESS_PORT

必须要等到两个pod全部变为Running而且Ready变为2/2才算部署完成。

接下来就可以通过

curl -s http://${GATEWAY_URL}/hello

访问到服务了。如果成功返回了Hello, JiangWen. This message comes from gRPC.的结果,没有出错则说明部署完成。

来源:blog.csdn.net/weixin_43887447/article/

details/109605564

推荐

Java面试题宝典

技术内卷群,一起来学习!!

289ef1c21749785bd4834a213ec90c93.png

PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。点“在看”支持我们吧!

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小丑西瓜9/article/detail/145911
推荐阅读
相关标签
  

闽ICP备14008679号