>  기사  >  백엔드 개발  >  Go, PostgreSQL, Google Cloud 및 CockroachDB를 사용하여 API 구축

Go, PostgreSQL, Google Cloud 및 CockroachDB를 사용하여 API 구축

Linda Hamilton
Linda Hamilton원래의
2024-10-24 07:02:30758검색

Go와 PostgreSQL로 API를 구축하고 Google Cloud Run, Cloud Build, Secret Manager, Artifact Registry로 CI/CD 파이프라인을 설정하고 Cloud Run 인스턴스를 CockroachDB에 연결했습니다.

API는 Crisis Core: Final Fantasy VII 게임을 기반으로 "Materia Fusion"을 시뮬레이션합니다. 이 문서의 대상 독자는 API를 구축하고 배포하는 방법을 알고 싶은 개발자를 위한 것입니다. 이 프로젝트를 진행하면서 배운 모든 것, 효과가 없었던 것, 게임의 마테리아 융합 규칙을 이해하고 번역하는 것에 대해 이야기하는 또 다른 기사가 있습니다(링크는 곧 제공됩니다).

쉽게 참고할 수 있는 링크

  • GitHub 저장소 및 README
  • Swagger(OpenAPI) 문서화 및 테스트
  • 공개 우편배달부 컬렉션
  • 도메인 모델 소스

API 목표

3개의 엔드포인트 — 상태 확인(GET), 모든 물질 목록(GET), 물질 융합 시뮬레이션(POST)

도메인 모델

마테리아(단수형 및 복수형 모두)는 마법의 원천 역할을 하는 수정 구체입니다. 게임에는 144개의 서로 다른 마테리아가 있으며, 크게 "마법", "명령", "지원", "독립"의 4가지 범주로 분류됩니다. 그러나 물질 융합의 규칙을 파악하기 위해 융합 행위에 따라 32개의 내부 범주를 갖고, 해당 범주 내에 8등급을 두는 것이 더 쉬웠습니다(참고 자료 참조). .

마테리아는 일정 시간 동안 사용하면 '마스터링'됩니다. 여기서는 기간이 중요하지 않습니다.

가장 중요한 것은 2개의 마테리아를 융합하여 새로운 마테리아를 생산할 수 있다는 것입니다. 융합을 관리하는 규칙은 다음의 영향을 받습니다.

  • 마테리아 중 하나 또는 둘 다 마스터했는지 여부
  • 어떤 물질이 먼저 나오나요(X Y가 반드시 Y X와 같지는 않음).
  • 마테리아 내부 카테고리.
  • 마테리아 등급.

Building an API with Go, PostgreSQL, Google Cloud and CockroachDB

또한 예외가 많이 있으며 일부 규칙에는 3가지 수준의 중첩된 if-else 논리가 있습니다. 이렇게 하면 DB에 간단한 테이블을 만들고 그 안에 1000개의 규칙을 유지하거나 모든 것을 지배하는 하나의 공식을 만들 가능성이 제거됩니다.

요컨대 다음이 필요합니다.

  1. name(string), materia_type(ENUM)(32개의 내부 범주), grade(integer), display_materia_type(ENUM)(게임에 사용되는 4개의 범주), 설명(string) 및 id( 정수)를 자동 증가 기본 키로 사용합니다.
  2. 기본 규칙 형식 MateriaTypeA MateriaTypeB = MateriaTypeC를 캡슐화하는 데이터 구조.
  3. 기본적이고 복잡한 규칙을 사용하여 내부 카테고리 및 등급 측면에서 출력 물질을 결정하는 코드입니다.

1. 로컬 PostgreSQL DB 설정

이상적으로는 웹사이트 자체에서 DB를 설치하는 것이 좋습니다. 그런데 pgAdmin 툴이 무슨 이유인지 DB에 연결할 수 없어서 Homebrew를 사용했습니다.

설치

brew install postgresql@17

이렇게 하면 DB 사용에 도움이 되는 CLI 바이너리 파일 전체가 설치됩니다.

선택 사항: $PATH 변수에 /opt/homebrew/opt/postgresql@17/bin을 추가합니다.

# create the DB
createdb materiafusiondb
# step into the DB to perform SQL commands
psql materiafusiondb

사용자 및 권한 생성

-- create an SQL user to be used by the Go server
CREATE USER go_client WITH PASSWORD 'xxxxxxxx';

-- The Go server doesn't ever need to add data to the DB. 
-- So let's give it just read permission.
CREATE ROLE readonly_role;
GRANT USAGE ON SCHEMA public TO readonly_role;

-- This command gives SELECT access to all future created tables. 
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly_role;

-- If you want to be more strict and give access only to tables that already exist, use this:
-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_role;

GRANT readonly_role TO go_client;

테이블 만들기

CREATE TYPE display_materia_type AS ENUM ('Magic', 'Command', 'Support', 'Independent');

CREATE TYPE materia_type AS ENUM ('Fire', 'Ice', 'Lightning', 'Restore', 'Full Cure', 'Status Defense', 'Defense', 'Absorb Magic', 'Status Magic', 'Fire & Status', 'Ice & Status', 'Lightning & Status', 'Gravity', 'Ultimate', 'Quick Attack', 'Quick Attack & Status', 'Blade Arts', 'Blade Arts & Status', 'Fire Blade', 'Ice Blade', 'Lightning Blade', 'Absorb Blade', 'Item', 'Punch', 'SP Turbo', 'HP Up', 'AP Up', 'ATK Up', 'VIT Up', 'MAG Up', 'SPR Up', 'Dash', 'Dualcast', 'DMW', 'Libra', 'MP Up', 'Anything');

CREATE TABLE materia (
    id integer NOT NULL,
    name character varying(50) NOT NULL,
    materia_type materia_type NOT NULL,
    grade integer NOT NULL,
    display_materia_type display_materia_type,
    description text
    CONSTRAINT materia_pkey PRIMARY KEY (id)
);

-- The primary key 'id' should auto-increment by 1 for every row entry.
CREATE SEQUENCE materia_id_seq
    AS integer
    START WITH 1
    INCREMENT BY 1
    NO MINVALUE
    NO MAXVALUE
    CACHE 1;

ALTER SEQUENCE materia_id_seq OWNED BY materia.id;

ALTER TABLE ONLY materia ALTER COLUMN id SET DEFAULT nextval('materia_id_seq'::REGCLASS);

데이터 추가

표 헤더와 데이터가 포함된 Excel 시트를 만들고 CSV 파일로 내보냅니다. 그런 다음 다음 명령을 실행합니다.

COPY materia(name,materia_type,grade,display_materia_type,description) FROM
 '<path_to_csv_file>/materiadata.csv' DELIMITER ',' CSV HEADER;

2. Go 서버 생성

autostrada.dev를 사용하여 상용구 코드를 만듭니다. api, postgresql, httprouter , env var config, Tintedlogging, git, live reload, makefile 옵션을 추가합니다. 결국 다음과 같은 파일 구조를 갖게 됩니다.

? codebase
├─ cmd
│  └─ api
│     ├─ errors.go
│     ├─ handlers.go
│     ├─ helpers.go
│     ├─ main.go
│     ├─ middleware.go
│     └─ server.go
├─ internal
│  ├─ database --- db.go
│  ├─ env --- env.go
│  ├─ request --- json.go
│  ├─ response --- json.go
│  └─ validator
│     ├─ helpers.go
│     └─ validators.go
├─ go.mod
├─ LICENSE
├─ Makefile
├─ README.md
└─ README.html

.env 파일

상용구 생성기는 환경 변수를 가져와 코드에 추가하는 코드를 생성했지만 값을 더 쉽게 추적하고 업데이트할 수 있습니다.

/.env 파일을 만듭니다. 다음 값을 추가합니다:

HTTP_PORT=4444
DB_DSN=go_client:<password>@localhost:5432/materiafusiondb?sslmode=disable
API_TIMEOUT_SECONDS=5
API_CALLS_ALLOWED_PER_SECOND=1

godotenv 라이브러리 추가:

go get github.com/joho/godotenv

main.go에 다음을 추가하세요.

// At the beginning of main():
err := godotenv.Load(".env") // Loads environment variables from .env file
if err != nil { // This will be true in prod, but that's fine.
  fmt.Println("Error loading .env file")
}


// Modify config struct:
type config struct {
  baseURL string
  db      struct {
    dsn string
  }
  httpPort                 int
  apiTimeout               int
  apiCallsAllowedPerSecond float64
}

// Modify run() to use the new values from .env:
cfg.httpPort = env.GetInt("HTTP_PORT")
cfg.db.dsn = env.GetString("DB_DSN")
cfg.apiTimeout = env.GetInt("API_TIMEOUT_SECONDS")
cfg.apiCallsAllowedPerSecond = float64(env.GetInt("API_CALLS_ALLOWED_PER_SECOND"))

// cfg.baseURL = env.GetString("BASE_URL") - not required

미들웨어 및 경로

보일러플레이트에는 패닉 상태를 복구하기 위한 미들웨어가 이미 포함되어 있습니다. 콘텐츠 유형 확인, 속도 제한, API 시간 초과 보호 등 3가지를 더 추가할 예정입니다.

톨게이트 라이브러리 추가:

go get github.com/didip/tollbooth

func (app *application) contentTypeCheck(next http.Handler) http.Handler {
 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  if r.Header.Get("Content-Type") != "application/json" {
   app.unsupportedMediaType(w, r)

   return
  }
  next.ServeHTTP(w, r)
 })
}


func (app *application) rateLimiter(next http.Handler) http.Handler {
 limiter := tollbooth.NewLimiter(app.config.apiCallsAllowedPerSecond, nil)
 limiter.SetIPLookups([]string{"X-Real-IP", "X-Forwarded-For", "RemoteAddr"})

 return tollbooth.LimitHandler(limiter, next)
}


func (app *application) apiTimeout(next http.Handler) http.Handler {
 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  timeoutDuration := time.Duration(app.config.apiTimeout) * time.Second

  ctx, cancel := context.WithTimeout(r.Context(), timeoutDuration)
  defer cancel()

  r = r.WithContext(ctx)

  done := make(chan struct{})

  go func() {
   next.ServeHTTP(w, r)
   close(done)
  }()

  select {
  case <-done:
   return
  case <-ctx.Done():
   app.gatewayTimeout(w, r)
   return
  }
 })
}

경로에 미들웨어를 추가해야 합니다. 모든 경로에 추가하거나 특정 경로에 추가할 수 있습니다. 우리의 경우 Content-Type 확인(즉, 입력 헤더에 Content-Type: application/json을 포함하도록 요구)은 POST 요청에만 필요합니다. 따라서 Routes.go를 다음과 같이 수정하십시오:

func (app *application) routes() http.Handler {
 mux := httprouter.New()

 mux.NotFound = http.HandlerFunc(app.notFound)
 mux.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowed)

 // Serve the Swagger UI. Uncomment this line later
 // mux.Handler("GET", "/docs/*any", httpSwagger.WrapHandler)

 mux.HandlerFunc("GET", "/status", app.status)
 mux.HandlerFunc("GET", "/materia", app.getAllMateria)

 // Adding content-type check middleware to only the POST method
 mux.Handler("POST", "/fusion", app.contentTypeCheck(http.HandlerFunc(app.fuseMateria)))

 return app.chainMiddlewares(mux)
}

func (app *application) chainMiddlewares(next http.Handler) http.Handler {
 middlewares := []func(http.Handler) http.Handler{
  app.recoverPanic,
  app.apiTimeout,
  app.rateLimiter,
 }

 for _, middleware := range middlewares {
  next = middleware(next)
 }

 return next
}

오류 처리

미들웨어 기능을 돕기 위해 /api/errors.go에 다음 메소드를 추가하세요.

func (app *application) unsupportedMediaType(w http.ResponseWriter, r *http.Request) {
 message := fmt.Sprintf("The %s Content-Type is not supported", r.Header.Get("Content-Type"))
 app.errorMessage(w, r, http.StatusUnsupportedMediaType, message, nil)
}

func (app *application) gatewayTimeout(w http.ResponseWriter, r *http.Request) {
 message := "Request timed out"
 app.errorMessage(w, r, http.StatusGatewayTimeout, message, nil)
}

요청 및 응답 구조 파일

/api/dtos.go :

package main

// MateriaDTO provides Materia details - Name, Description and Type (Magic / Command / Support / Independent)
type MateriaDTO struct {
 Name        string `json:"name" example:"Thunder"`
 Type        string `json:"type" example:"Magic"`
 Description string `json:"description" example:"Shoots lightning forward dealing thunder damage."`
}

// StatusDTO provides status of the server
type StatusDTO struct {
 Status string `json:"Status" example:"OK"`
}

// ErrorResponseDTO provides Error message
type ErrorResponseDTO struct {
 Error string `json:"Error" example:"The server encountered a problem and could not process your request"`
}

/api/requests.go :

brew install postgresql@17

생성된 코드의 유효성 검사기는 나중에 융합 엔드포인트에 대한 입력 필드의 유효성을 검사하는 데 사용됩니다.

조합 규칙의 데이터 구조

/internal/crisis-core-materia-fusion/constants.go 파일을 생성합니다

다음을 추가하세요.

# create the DB
createdb materiafusiondb
# step into the DB to perform SQL commands
psql materiafusiondb

여기에서 32개의 MateriaType의 전체 목록을 확인할 수 있습니다.

/internal/crisis-core-materia-fusion/models.go 파일을 생성합니다

다음을 추가하세요.

-- create an SQL user to be used by the Go server
CREATE USER go_client WITH PASSWORD 'xxxxxxxx';

-- The Go server doesn't ever need to add data to the DB. 
-- So let's give it just read permission.
CREATE ROLE readonly_role;
GRANT USAGE ON SCHEMA public TO readonly_role;

-- This command gives SELECT access to all future created tables. 
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly_role;

-- If you want to be more strict and give access only to tables that already exist, use this:
-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_role;

GRANT readonly_role TO go_client;

여기에서 전체 규칙 목록을 확인할 수 있습니다.

api/handlers.go의 물질 처리기

CREATE TYPE display_materia_type AS ENUM ('Magic', 'Command', 'Support', 'Independent');

CREATE TYPE materia_type AS ENUM ('Fire', 'Ice', 'Lightning', 'Restore', 'Full Cure', 'Status Defense', 'Defense', 'Absorb Magic', 'Status Magic', 'Fire & Status', 'Ice & Status', 'Lightning & Status', 'Gravity', 'Ultimate', 'Quick Attack', 'Quick Attack & Status', 'Blade Arts', 'Blade Arts & Status', 'Fire Blade', 'Ice Blade', 'Lightning Blade', 'Absorb Blade', 'Item', 'Punch', 'SP Turbo', 'HP Up', 'AP Up', 'ATK Up', 'VIT Up', 'MAG Up', 'SPR Up', 'Dash', 'Dualcast', 'DMW', 'Libra', 'MP Up', 'Anything');

CREATE TABLE materia (
    id integer NOT NULL,
    name character varying(50) NOT NULL,
    materia_type materia_type NOT NULL,
    grade integer NOT NULL,
    display_materia_type display_materia_type,
    description text
    CONSTRAINT materia_pkey PRIMARY KEY (id)
);

-- The primary key 'id' should auto-increment by 1 for every row entry.
CREATE SEQUENCE materia_id_seq
    AS integer
    START WITH 1
    INCREMENT BY 1
    NO MINVALUE
    NO MAXVALUE
    CACHE 1;

ALTER SEQUENCE materia_id_seq OWNED BY materia.id;

ALTER TABLE ONLY materia ALTER COLUMN id SET DEFAULT nextval('materia_id_seq'::REGCLASS);

서버 내 캐시

다음과 같은 이유로 서버 내 캐시를 사용하고 있습니다.

  1. DB에서 가져온 데이터는 절대 변경되지 않습니다.
  2. 마테리아 엔드포인트와 융합 엔드포인트 모두 동일한 데이터를 사용합니다.

main.go 업데이트:

COPY materia(name,materia_type,grade,display_materia_type,description) FROM
 '<path_to_csv_file>/materiadata.csv' DELIMITER ',' CSV HEADER;

api/helpers.go 업데이트:

? codebase
├─ cmd
│  └─ api
│     ├─ errors.go
│     ├─ handlers.go
│     ├─ helpers.go
│     ├─ main.go
│     ├─ middleware.go
│     └─ server.go
├─ internal
│  ├─ database --- db.go
│  ├─ env --- env.go
│  ├─ request --- json.go
│  ├─ response --- json.go
│  └─ validator
│     ├─ helpers.go
│     └─ validators.go
├─ go.mod
├─ LICENSE
├─ Makefile
├─ README.md
└─ README.html

api/handlers.go의 융합을 위한 핸들러

HTTP_PORT=4444
DB_DSN=go_client:<password>@localhost:5432/materiafusiondb?sslmode=disable
API_TIMEOUT_SECONDS=5
API_CALLS_ALLOWED_PER_SECOND=1

전체 핸들러 코드는 여기에서 찾을 수 있습니다.

Swagger UI 및 OpenAPI 정의 문서

Swagger 라이브러리 추가:

go get github.com/joho/godotenv

routes.go에서 Swagger 줄의 주석 처리를 제거하고 import를 추가합니다.

// At the beginning of main():
err := godotenv.Load(".env") // Loads environment variables from .env file
if err != nil { // This will be true in prod, but that's fine.
  fmt.Println("Error loading .env file")
}


// Modify config struct:
type config struct {
  baseURL string
  db      struct {
    dsn string
  }
  httpPort                 int
  apiTimeout               int
  apiCallsAllowedPerSecond float64
}

// Modify run() to use the new values from .env:
cfg.httpPort = env.GetInt("HTTP_PORT")
cfg.db.dsn = env.GetString("DB_DSN")
cfg.apiTimeout = env.GetInt("API_TIMEOUT_SECONDS")
cfg.apiCallsAllowedPerSecond = float64(env.GetInt("API_CALLS_ALLOWED_PER_SECOND"))

// cfg.baseURL = env.GetString("BASE_URL") - not required

핸들러, DTO 및 모델 파일에서 Swagger 문서에 대한 설명을 추가합니다. 모든 옵션에 대해서는 이것을 참조하세요.

터미널에서 다음을 실행하세요.

go get github.com/didip/tollbooth

이렇게 하면 Go, JSON 및 YAML에 사용할 수 있는 정의가 포함된 api/docs 폴더가 생성됩니다.

테스트하려면 로컬 서버를 시작하고 http://localhost:4444/docs를 엽니다.


최종 폴더 구조:

func (app *application) contentTypeCheck(next http.Handler) http.Handler {
 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  if r.Header.Get("Content-Type") != "application/json" {
   app.unsupportedMediaType(w, r)

   return
  }
  next.ServeHTTP(w, r)
 })
}


func (app *application) rateLimiter(next http.Handler) http.Handler {
 limiter := tollbooth.NewLimiter(app.config.apiCallsAllowedPerSecond, nil)
 limiter.SetIPLookups([]string{"X-Real-IP", "X-Forwarded-For", "RemoteAddr"})

 return tollbooth.LimitHandler(limiter, next)
}


func (app *application) apiTimeout(next http.Handler) http.Handler {
 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  timeoutDuration := time.Duration(app.config.apiTimeout) * time.Second

  ctx, cancel := context.WithTimeout(r.Context(), timeoutDuration)
  defer cancel()

  r = r.WithContext(ctx)

  done := make(chan struct{})

  go func() {
   next.ServeHTTP(w, r)
   close(done)
  }()

  select {
  case <-done:
   return
  case <-ctx.Done():
   app.gatewayTimeout(w, r)
   return
  }
 })
}

3. CockroachDB에서 원격 PostgreSQL 인스턴스 설정

  1. 여기의 단계를 따르세요.
  2. 인증서 생성 후 프로젝트에 /certs/root.crt를 생성하고 여기에 인증서를 추가합니다. 나중에 Google Run 구성에서 이 파일을 참조할 것입니다.
  3. 주의! 이 폴더를 원격 저장소로 푸시하지 않습니다. .gitignore에 certs/ 폴더를 추가합니다. 원하는 경우 연결을 테스트하기 위해서만 로컬에서 인증서를 생성하고 있습니다.
  4. 이제 CockroachDB → 대시보드 → 왼쪽 메뉴 → 데이터베이스로 이동하면 생성한 DB를 확인할 수 있습니다.

이주

로컬 DB 인스턴스에서 다음을 실행합니다.

brew install postgresql@17
  1. CockroachDB → 왼쪽 메뉴 → 마이그레이션 → 스키마 추가 → 방금 받은 SQL 파일을 드래그하세요. 테이블 데이터 삽입을 제외한 모든 단계가 실행됩니다. 또한 실행된 단계 목록도 표시됩니다.
  2. 이 기사를 작성할 당시 CockroachDB의 PostgreSQL 인스턴스는 IMPORT INTO와 같은 문을 지원하지 않습니다. 그래서 로컬 SQL 파일에 270개 행에 대한 INSERT 문을 생성해야 했습니다(방금 얻은 pg_dump 출력에서 ​​파생할 수 있음).
  3. 원격 인스턴스에 로그인하여 SQL 파일을 실행하세요.

원격 인스턴스에 로그인:

# create the DB
createdb materiafusiondb
# step into the DB to perform SQL commands
psql materiafusiondb

4. Google Cloud Run 인스턴스 배포

  1. 이와 같은 Dockerfile을 생성하세요.
  2. Google Cloud Run으로 이동하여 API용 새 프로젝트를 생성하세요.
  3. 서비스 생성 → 저장소에서 지속적으로 배포클라우드 빌드로 설정저장소 제공자 = Github → 저장소 선택 → 빌드 유형 = Dockerfile → 저장.
  4. 인증 = 인증되지 않은 호출을 허용.
  5. 대부분의 기본값은 그대로 유지하면 됩니다.
  6. 컨테이너까지 아래로 스크롤 → 컨테이너 포트 = 4444.
  7. 변수 및 비밀 탭을 선택하고 로컬 .env 파일에 있는 것과 동일한 환경 변수를 추가합니다.

값:

  1. HTTP_PORT = 4444
  2. DB_DSN = ?sslmode=verify-full&sslrootcert=/app/certs/root.crt
  3. API_TIMEOUT_SECONDS = 5
  4. API_CALLS_ALLOWED_PER_SECOND = 1

인증서에 Google Secret Manager 사용

퍼즐의 마지막 조각.

  1. Secret Manager 검색 → Secret 생성 → 이름 = 'DB_CERT' → CockroachDB의 .crt 인증서를 업로드하세요.
  2. Cloud Run → (귀하의 서비스) → 지속 배포 편집 클릭 → 구성까지 아래로 스크롤 → 편집기 열기
  3. 첫 번째 단계로 다음을 추가하세요.
-- create an SQL user to be used by the Go server
CREATE USER go_client WITH PASSWORD 'xxxxxxxx';

-- The Go server doesn't ever need to add data to the DB. 
-- So let's give it just read permission.
CREATE ROLE readonly_role;
GRANT USAGE ON SCHEMA public TO readonly_role;

-- This command gives SELECT access to all future created tables. 
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly_role;

-- If you want to be more strict and give access only to tables that already exist, use this:
-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_role;

GRANT readonly_role TO go_client;

이렇게 하면 Cloud Build가 빌드가 시작되기 전에 프로젝트에 certs/root.crt 파일을 생성하므로 Dockerfile은 Github 저장소에 푸시하지 않은 경우에도 해당 파일에 액세스할 수 있습니다.


그리고 그게 다입니다. 커밋을 푸시하고 빌드가 트리거되는지 확인하세요. Cloud Run 대시보드에는 호스팅된 Go 서버의 URL이 표시됩니다.


“왜 Y를 하지 않고 X를 하였나요?” 관련 질문 읽어보세요.

알고 싶은 내용이나 토론하고 싶은 내용이 있으면 여기로 이동하거나 아래에 댓글을 남겨주세요.

위 내용은 Go, PostgreSQL, Google Cloud 및 CockroachDB를 사용하여 API 구축의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.