>  기사  >  Java  >  Spring Boot+gRPC를 사용하여 마이크로서비스를 구축하고 배포하는 방법

Spring Boot+gRPC를 사용하여 마이크로서비스를 구축하고 배포하는 방법

WBOY
WBOY앞으로
2023-05-22 20:13:591318검색

1. 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는 거버넌스 로직을 비즈니스 코드와 완전히 분리하고 독립적인 프로세스(사이드카)를 구현합니다. 배포 중에 사이드카와 비즈니스 코드는 동일한 Pod에 공존하지만 비즈니스 코드는 사이드카의 존재를 전혀 인식하지 못합니다. 이를 통해 비즈니스 코드에 대한 거버넌스 로직의 침입이 전혀 발생하지 않습니다. 실제로 코드가 침해되지 않을 뿐만 아니라 런타임 시 둘 사이에 결합도 없습니다. 이를 통해 서비스 거버넌스 문제에 대해 걱정할 필요 없이 다양한 언어와 기술 스택을 사용하여 다양한 마이크로서비스를 개발할 수 있습니다. 이는 매우 우아한 솔루션이라고 할 수 있습니다.

그래서 "Istio를 사용하는 이유"에 대한 질문은 쉽게 해결됩니다. Istio는 비즈니스 로직과 서비스 거버넌스 로직의 결합, 언어 간 구현 불가능 등 기존 마이크로서비스의 문제점을 해결하기 때문입니다. 사용하기 매우 쉽습니다. Kubernetes를 마스터하고 나면 Istio 사용 방법을 배우는 것은 어렵지 않습니다.

1.1. gRPC를 통신 프레임워크로 사용하는 이유는 무엇입니까?

마이크로서비스 아키텍처에서 서비스 간 통신은 상대적으로 큰 문제이며 일반적으로 RPC 또는 RESTful API를 사용하여 구현됩니다.

Spring Boot는 RestTemplate을 사용하여 원격 서비스를 호출할 수 있지만 이 방법은 직관적이지 않고 코드가 복잡합니다. 언어 간 통신도 큰 문제입니다. gRPC는 Dubbo와 같은 일반적인 Java RPC 프레임워크보다 가볍습니다. 사용하기도 매우 편리하고, 코드 가독성도 좋고, Protobuf와 HTTP2 지원으로 성능도 나쁘지 않아서 이번에는 gRPC를 선택했습니다. Spring Boot 마이크로서비스 간의 통신 문제입니다. 또한 gRPC에는 서비스 검색, 로드 밸런싱 및 기타 기능이 없지만 Istio는 이 점에서 매우 강력하며 둘은 완벽한 보완 관계를 형성합니다.

다양한 grpc-spring-boot-starter가 Spring Boot와 Istio 통합에 대해 알 수 없는 부작용이 있을 수 있다는 점을 고려하여 이번에는 grpc-spring-boot-starter를 사용하지 않고 직접 작성했습니다. gRPC 통합 그리고 스프링 부트. gRPC와 Spring Boot를 통합하기 위해 타사 프레임워크를 사용하고 싶지 않은 경우 간단한 구현 방법을 참조할 수 있습니다.

1.2. 비즈니스 코드 작성

먼저 Spring 초기화를 사용하여 상위 프로젝트 spring-boot-istio를 설정하고 gRPC 종속성을 도입합니다. pom 파일은 다음과 같습니다:

<?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" 
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <modules>
        <module>spring-boot-istio-api</module>
        <module>spring-boot-istio-server</module>
        <module>spring-boot-istio-client</module>
    </modules>
    <parent>
        <groupId>org.springframework.boot</groupId> 
       <artifactId>spring-boot-starter-parent</artifactId> 
       <version>2.2.6.RELEASE</version>
       <relativePath/>
     </parent>
    <groupId>site.wendev</groupId>
    <artifactId>spring-boot-istio</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-boot-istio</name>
    <description>Demo project for Spring Boot With Istio.</description>
    <packaging>pom</packaging>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>io.grpc</groupId>
                <artifactId>grpc-all</artifactId>
                <version>1.28.1</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

그런 다음 공개 종속성 모듈 spring-boot-istio-api를 생성합니다. pom 파일은 다음과 같습니다. 주로 gRPC의 일부 종속성입니다:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-boot-istio</artifactId>
        <groupId>site.wendev</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>spring-boot-istio-api</artifactId>
    <dependencies>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-all</artifactId>
        </dependency>
        <dependency>
            <groupId>javax.annotation</groupId>
            <artifactId>javax.annotation-api</artifactId>
            <version>1.3.2</version> 
       </dependency>
    </dependencies> 
   <build>
        <extensions>
            <extension>
                <groupId>kr.motd.maven</groupId>
                <artifactId>os-maven-plugin</artifactId>
                <version>1.6.2</version>
            </extension>
        </extensions>
        <plugins>
            <plugin> 
               <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>0.6.1</version>
                <configuration>
                    <protocArtifact>com.google.protobuf:protoc:3.11.3:exe:${os.detected.classifier}</protocArtifact>
                    <pluginId>grpc-java</pluginId>
                    <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.28.1:exe:${os.detected.classifier}</pluginArtifact>
                    <protocExecutable>/Users/jiangwen/tools/protoc-3.11.3/bin/protoc</protocExecutable>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>compile-custom</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

src/main/proto 폴더를 생성하고 create hello 이 폴더 아래에 .proto는 서비스 간 인터페이스를 다음과 같이 정의합니다:

syntax = "proto3";
option java_package = "site.wendev.spring.boot.istio.api";
option java_outer_classname = "HelloWorldService";
package helloworld;

service HelloWorld {
    rpc SayHello (HelloRequest)
 returns (HelloResponse) {}
}

message HelloRequest {
    string name = 1;
}
message HelloResponse {
    string message = 1;
}

매우 간단합니다. 이름을 보내고 이름이 포함된 메시지를 반환하면 됩니다.

그런 다음 서버 및 클라이언트 코드를 생성하여 java 폴더에 넣으세요. 이 부분에 대해서는 gRPC 공식 문서를 참고하시기 바랍니다.

API 모듈이 사용 가능해지면 서비스 제공자(서버)와 서비스 소비자(클라이언트)가 개발될 수 있습니다. 여기서는 gRPC와 Spring Boot를 통합하는 방법에 중점을 둡니다.

1) 서버 측

비즈니스 코드는 매우 간단합니다.

/**
 * 服务端业务逻辑实现
 * 
 * @author 江文
 * @date 2020/4/12 2:49 下午
 */
@Slf4j
@Component
public class HelloServiceImpl extends HelloWorldGrpc.HelloWorldImplBase {
    @Override
    public void sayHello(HelloWorldService.HelloRequest request,
                         StreamObserver<HelloWorldService.HelloResponse> responseObserver) {
        // 根据请求对象建立响应对象,返回响应信息
        HelloWorldService.HelloResponse response = HelloWorldService.HelloResponse
                .newBuilder()
                .setMessage(String.format("Hello, %s. This message comes from gRPC.", request.getName()))
                .build();
        responseObserver.onNext(response);
        responseObserver.onCompleted();
        log.info("Client Message Received:[{}]", request.getName());
    }
}

비즈니스 코드 외에도 애플리케이션이 시작될 때 동시에 gRPC 서버도 시작해야 합니다. 먼저 서버 측에 시작, 종료 및 기타 로직을 작성합니다.

/**
 * gRPC Server的配置——启动、关闭等
 * 需要使用<code>@Component</code>注解注册为一个Spring Bean
 * 
 * @author 江文
 * @date 2020/4/12 2:56 下午
 */
@Slf4j
@Componentpublic class GrpcServerConfiguration {
    @Autowired
    HelloServiceImpl service;
    /** 注入配置文件中的端口信息 */ 
   @Value("${grpc.server-port}")
    private int port;
    private Server server;
    public void start() throws IOException {
        // 构建服务端
        log.info("Starting gRPC on port {}.", port);
        server = ServerBuilder.forPort(port).addService(service).build().start();
        log.info("gRPC server started, listening on {}.", port);
        // 添加服务端关闭的逻辑
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            log.info("Shutting down gRPC server.");
            GrpcServerConfiguration.this.stop();
            log.info("gRPC server shut down successfully.");
        }));
    }
    private void stop() {
        if (server != null) {
            // 关闭服务端
            server.shutdown();
        }
    }
    public void block() throws InterruptedException {
        if (server != null) {
            // 服务端启动后直到应用关闭都处于阻塞状态,方便接收请求 
           server.awaitTermination();
        }
    }
}

gRPC의 시작, 중지 및 기타 로직을 정의한 후 CommandLineRunner를 사용하여 이를 Spring Boot의 시작에 추가할 수 있습니다.

/** 
 * 加入gRPC Server的启动、停止等逻辑到Spring Boot的生命周期中
 *
 * @author 江文
 * @date 2020/4/12 3:10 下午
 */
@Component
public class GrpcCommandLineRunner implements CommandLineRunner {
    @Autowired
    GrpcServerConfiguration configuration;

    @Override
    public void run(String... args) throws Exception {
        configuration.start();
        configuration.block();
    }
}

등록하겠습니다. gRPC의 로직 인스턴스를 얻고 해당 작업을 수행해야 하기 때문에 Spring Bean이 됩니다.

이렇게 하면 Spring Boot 시작 시 CommandLineRunner가 있기 때문에 gRPC 서버도 함께 시작할 수 있습니다.

2) 클라이언트

비즈니스 코드도 매우 간단합니다.

/**
 * 客户端业务逻辑实现
 *
 * @author 江文
 * @date 2020/4/12 3:26 下午
 */
@RestController
@Slf4j
public class HelloController {
    @Autowired
    GrpcClientConfiguration configuration;
    @GetMapping("/hello")
    public String hello(@RequestParam(name = "name", defaultValue = "JiangWen", required = false) String name) {
        // 构建一个请求        HelloWorldService.HelloRequest request = HelloWorldService.HelloRequest
                .newBuilder()
                .setName(name)
                .build();        // 使用stub发送请求至服务端
        HelloWorldService.HelloResponse response = configuration.getStub().sayHello(request);
        log.info("Server response received: [{}]", response.getMessage());
        return response.getMessage();
    }
}

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

/**
 * gRPC Client的配置——启动、建立channel、获取stub、关闭等
 * 需要注册为Spring Bean
 *
 * @author 江文
 * @date 2020/4/12 3:27 下午
 */
@Slf4j
@Component
public class GrpcClientConfiguration {
    /** gRPC Server的地址 */
    @Value("${server-host}")
    private String host;
    /** gRPC Server的端口 */
    @Value("${server-port}")
    private int port;
    private ManagedChannel channel;
    private HelloWorldGrpc.HelloWorldBlockingStub stub;
    public void start() {
        // 开启channel
        channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
        // 通过channel获取到服务端的stub
        stub = HelloWorldGrpc.newBlockingStub(channel);
        log.info("gRPC client started, server address: {}:{}", host, port);
    }
    public void shutdown() throws InterruptedException {
        // 调用shutdown方法后等待1秒关闭channel
        channel.shutdown().awaitTermination(1, TimeUnit.SECONDS);
        log.info("gRPC client shut down successfully.");
    }
    public HelloWorldGrpc.HelloWorldBlockingStub getStub() {
        return this.stub;
    }
}

比服务端要简单一些。

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

/**
 * 加入gRPC Client的启动、停止等逻辑到Spring Boot生命周期中
 *
 * @author 江文
 * @date 2020/4/12 3:36 下午
 */
@Component
@Slf4j
public class GrpcClientCommandLineRunner implements CommandLineRunner {
    @Autowired
    GrpcClientConfiguration configuration;

    @Override
    public void run(String... args) {
        // 开启gRPC客户端
        configuration.start();
                // 添加客户端关闭的逻辑
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try {
                configuration.shutdown();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }));
    }
}

1.3、 编写Dockerfile

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

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

server:
  port: 19090
spring:
  application:
      name: spring-boot-istio-clientserver-host: ${server-host}server-port: ${server-port}

接下来编写Dockerfile:

1) 服务端:

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

主要是规定服务端应用的端口为18080,并且在容器启动时让服务端也一起启动。

2) 客户端:

FROM openjdk:8u121-jdk
RUN /bin/cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
  && echo &#39;Asia/Shanghai&#39; >/etc/timezoneADD /target/spring-boot-istio-client-0.0.1-SNAPSHOT.jar /ENV GRPC_SERVER_HOST="spring-boot-istio-server"ENV GRPC_SERVER_PORT="18888"ENTRYPOINT java -jar /spring-boot-istio-client-0.0.1-SNAPSHOT.jar \ --server-host=$GRPC_SERVER_HOST \ --server-port=$GRPC_SERVER_PORT

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

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

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <executable>true</executable>
                </configuration>
            </plugin>
            <plugin>
                <groupId>com.spotify</groupId> 
               <artifactId>dockerfile-maven-plugin</artifactId>
                <version>1.4.13</version>
                <dependencies>
                    <dependency>
                        <groupId>javax.activation</groupId>
                        <artifactId>activation</artifactId>
                        <version>1.1</version>
                    </dependency>
                </dependencies>
                <executions>
                    <execution>
                        <id>default</id> 
                       <goals>
                            <goal>build</goal>
                            <goal>push</goal>
                       </goals>
                    </execution>
                </executions>
                <configuration>
                    <repository>wendev-docker.pkg.coding.net/develop/docker/${project.artifactId}</repository>
                    <tag>${project.version}</tag>
                    <buildArgs>
                        <JAR_FILE>${project.build.finalName}.jar</JAR_FILE>
                    </buildArgs>
                </configuration>
            </plugin>
        </plugins>
    </build>

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

2. 编写部署文件

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

1) 服务端:

apiVersion: v1
kind:
 Servicemetadata:
  name: spring-boot-istio-server
spec:
  type: ClusterIP
  ports:
    - name: http 
      port: 18080
      targetPort: 18080
    - name: grpc
      port: 18888
      targetPort: 18888
  selector:
    app: spring-boot-istio-server

---apiVersion: apps/v1
kind:
 Deploymentmetadata:
  name: spring-boot-istio-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-boot-istio-server
  template:
    metadata:
      labels:
        app: spring-boot-istio-server
    spec:
      containers:
        - name: spring-boot-istio-server
          image: wendev-docker.pkg.coding.net/develop/docker/spring-boot-istio-server:0.0.1-SNAPSHOT
          imagePullPolicy: Always
          tty: true
          ports:
            - name: http
              protocol: TCP
              containerPort: 18080 
            - name: grpc
              protocol: TCP
              containerPort: 18888

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

2) 客户端:

apiVersion: v1
kind:
 Servicemetadata:
  name:
 spring-boot-istio-client
spec:
  type: ClusterIP
  ports:
    - name: http
      port: 19090
      targetPort: 19090
  selector:
    app: spring-boot-istio-client

---apiVersion: apps/v1
kind:
 Deploymentmetadata:
  name: spring-boot-istio-client
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-boot-istio-client
  template:
    metadata:
      labels:
        app: spring-boot-istio-client
    spec:
      containers:
        - name: spring-boot-istio-client
          image: wendev-docker.pkg.coding.net/develop/docker/spring-boot-istio-client:0.0.1-SNAPSHOT
          imagePullPolicy: Always
          tty: true
          ports:
            - name: http 
             protocol: TCP
              containerPort: 19090

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

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

apiVersion: networking.k8s.io/v1beta1
kind:
 Ingressmetadata:
  name: nginx-web
  annotations:
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/use-reges: "true"
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
    - host: dev.wendev.site
      http:
        paths:
          - path: /
            backend:
              serviceName: spring-boot-istio-client
              servicePort: 19090

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

apiVersion: networking.istio.io/v1alpha3
kind:
 Gatewaymetadata:
  name: spring-boot-istio-gateway
spec:
  selector:
    istio: ingressgateway
  servers:
    - port:
        number: 80
        name: http
        protocol: HTTP
      hosts:
        - "*"

---apiVersion: networking.istio.io/v1alpha3
kind: Virtual
Servicemetadata:
  name: spring-boot-istio
spec:
  hosts:
    - "*"
  gateways:
    - spring-boot-istio-gateway
  http:
    - match:
        - uri:
            exact: /hello
      route:
        - destination:
            host: spring-boot-istio-client
            port:
              number: 19090

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

3. 部署应用到Istio

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

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

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

$ kubectl get svc istio-ingressgateway -n istio-system
NAME                   TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S) 
                                                                                                                                     
AGEistio-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:

export INGRESS_PORT=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath=&#39;{.spec.ports[?(@.name=="http2")].nodePort}&#39;)
export SECURE_INGRESS_PORT=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath=&#39;{.spec.ports[?(@.name=="https")].nodePort}&#39;)
export INGRESS_HOST=127.0.0.1export GATEWAY_URL=$INGRESS_HOST:$INGRESS_PORT

这样就可以了。

接下来部署服务:

$ kubectl apply -f spring-boot-istio-server.yml
$ kubectl apply -f spring-boot-istio-client.yml
$ kubectl apply -f istio-gateway.yml

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

接下来就可以通过

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

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

위 내용은 Spring Boot+gRPC를 사용하여 마이크로서비스를 구축하고 배포하는 방법의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 yisu.com에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제