> 백엔드 개발 > Golang > Go 인터페이스에 대한 간단한 가이드

Go 인터페이스에 대한 간단한 가이드

DDD
풀어 주다: 2024-09-18 18:39:14
원래의
1091명이 탐색했습니다.

JavaScript에서 Go로 전환하는 개발자로서 처음 Go를 배우기 시작했을 때 인터페이스 개념이 어려웠습니다.
이 가이드의 목표는 JavaScript 배경 지식이 있든 프로그래밍이 처음이든 유사한 상황에 있는 사람들을 위해 Go 인터페이스에 대한 간단한 설명을 제공하는 것입니다.

Go 인터페이스의 강력함과 유연성을 입증하는 동시에 실용적인 통찰력을 제공하는 것이 목표입니다. 이 글을 끝까지 읽으시면 Go 프로젝트에서 인터페이스를 효과적으로 사용하는 방법을 확실하게 이해하실 수 있기를 바랍니다.

 

개요

Go에서 한발 물러나 간단한 비유를 통해 높은 수준에서 인터페이스가 무엇인지 이해해 보겠습니다.

우리가 Uber, Lyft 또는 기타 차량 공유 서비스가 없는 세상에 살고 있다고 상상해 보세요.
자동차, 트럭, 비행기 등 다양한 종류의 차량을 소유한 토니라는 사람이 있습니다. 그는 "이 많은 차량을 어떻게 최대한 활용할 수 있을까?"라고 궁금해합니다

그런데 잦은 출장이 필요한 영업사원 티나가 있습니다. 그녀는 운전을 좋아하지 않고 운전면허도 없어 주로 택시를 탄다. 그러나 승진할수록 그녀의 일정은 더욱 바빠지고 역동적이게 되며 때로는 택시가 그녀의 필요를 충족시키지 못하는 경우도 있습니다.

티나의 월요일부터 수요일까지의 일정을 살펴보겠습니다.

  • 월요일: 고객 A의 사무실에 오후 2시까지 도착해야 하며, 오후 1시에 중요한 회의가 있어서 이동 중에 노트북을 사용해야 합니다.
  • 화요일: 고객 B의 사무실에 오후 5시까지 도착해야 합니다. 출퇴근 시간은 2시간 정도이므로 누워서 낮잠을 잘 수 있는 차가 필요합니다.
  • 수요일: 짐이 많아 오전 10시까지 공항에 도착해야 하기 때문에 넓은 차량이 필요합니다.

어느 날 Tina는 Tony가 여러 유형의 차량을 가지고 있다는 사실을 발견하고 필요에 따라 가장 적합한 차량을 선택할 수 있습니다.

A straightforward guide for Go interface이 시나리오에서는 Tina가 어디든 가고 싶어할 때마다 Tony를 방문하여 적절한 자동차를 선택하기 전에 모든 기술적인 세부 사항에 대한 설명을 들어야 합니다. 그러나 Tina는 이러한 기술적 세부 사항에 관심이 없으며 이를 알 필요도 없습니다. 그녀에게는 자신의 요구 사항을 충족하는 자동차가 필요할 뿐입니다.

다음은 이 시나리오에서 Tina와 Tony의 관계를 보여주는 간단한 다이어그램입니다.
A straightforward guide for Go interface보시다시피 티나는 토니에게 직접 물어봅니다. 즉, Tina는 차가 필요할 때마다 Tony에게 직접 연락해야 하기 때문에 Tony에게 의존합니다.

 

Tina의 삶을 더 쉽게 만들기 위해 그녀는 Tony와 자동차에 대한 요구 사항 목록인 계약을 체결합니다. 그런 다음 Tony는 이 목록을 기반으로 가장 적합한 자동차를 선택합니다.
A straightforward guide for Go interface이 예에서 요구 사항 목록이 포함된 계약은 Tina가 자동차의 세부 사항을 추상화하고 요구 사항에만 집중하는 데 도움이 됩니다. Tina가 해야 할 일은 계약서에 요구 사항을 정의하는 것뿐입니다. 그러면 Tony가 Tina에게 가장 적합한 자동차를 선택해 줄 것입니다.

이 다이어그램을 통해 이 시나리오의 관계를 더 자세히 설명할 수 있습니다.
A straightforward guide for Go interfaceTina는 이제 Tony에게 직접 요청하는 대신 계약을 통해 필요한 자동차를 구입할 수 있습니다. 이 경우 그녀는 더 이상 Tony에게 의존하지 않습니다. 대신에 그녀는 계약에 의존합니다. 계약의 주요 목적은 자동차의 세부 사항을 추상화하는 것이므로 Tina는 자동차의 구체적인 세부 사항을 알 필요가 없습니다. 그녀가 알아야 할 것은 차가 그녀의 요구 사항을 충족한다는 것뿐입니다.

이 다이어그램에서 다음과 같은 특징을 확인할 수 있습니다.

  • 계약은 Tina가 정의합니다. 어떤 요구 사항이 필요한지 결정하는 것은 그녀의 몫입니다.
  • 계약은 토니와 티나 사이의 중개자 역할을 합니다. 그들은 서로 직접적으로 의존하지 않습니다. 대신 계약에 따라 다릅니다.
    • 토니가 서비스 제공을 중단하는 경우 티나는 다른 사람들과 동일한 계약을 사용할 수 있습니다.
  • 동일한 요구 사항을 충족하는 자동차가 여러 대 있을 수 있습니다.
    • 예를 들어 Tesla Model S와 Mercedes-Benz S-Class 모두 Tina의 요구 사항을 충족할 수 있습니다.

이 다이어그램을 이해하는 것이 인터페이스 개념을 이해하는 데 중요하기 때문에 이 다이어그램이 이해가 되기를 바랍니다. 다음 섹션 전반에 걸쳐 유사한 다이어그램이 나타나 이 중요한 개념을 강화합니다.

 

인터페이스란 무엇입니까?

이전 예에서 요구사항 목록이 포함된 계약이 바로 Go의 인터페이스입니다.

  1. 계약은 Tina가 자동차의 세부 사항을 추상화하고 자신의 요구 사항에만 집중하는 데 도움이 됩니다.
    • 인터페이스는 구현의 세부 사항을 추상화하고 동작에만 집중합니다.
  2. 계약은 요구 사항 목록으로 정의됩니다.
    • 인터페이스는 메소드 시그니처 목록으로 정의됩니다.
  3. 요건을 만족하는 자동차라면 누구나 계약을 이행한다고 합니다.
    • 인터페이스에 지정된 모든 메소드를 구현하는 모든 유형을 해당 인터페이스를 구현한다고 합니다.
  4. 계약은 소비자(이 경우 Tina)의 소유입니다.
    • 인터페이스는 그것을 사용하는 사람(호출자)의 소유입니다.
  5. 계약은 티나와 토니 사이의 중개자 역할을 합니다.
    • 인터페이스는 호출자와 구현자 사이의 중개자 역할을 합니다.
  6. 동일한 요구 사항을 충족하는 자동차가 여러 대 있을 수 있습니다.
    • 인터페이스 구현이 여러 개 있을 수 있습니다

Go를 다른 많은 언어와 차별화하는 주요 기능은 암시적 인터페이스 구현을 사용한다는 것입니다. 즉, 유형이 인터페이스를 구현한다고 명시적으로 선언할 필요가 없습니다. 유형이 인터페이스에 필요한 모든 메소드를 정의하는 한 해당 인터페이스를 자동으로 구현합니다.

Go에서 인터페이스로 작업할 때 세부 구현이 아닌 동작 목록만 제공한다는 점에 유의하는 것이 중요합니다. 인터페이스는 유형이 작동하는 방식이 아니라 유형이 가져야 하는 메소드를 정의합니다.

 

간단한 예

Go에서 인터페이스가 어떻게 작동하는지 간단한 예를 통해 살펴보겠습니다.

먼저 Car 인터페이스를 정의하겠습니다.

type Car interface {
    Drive()
}
로그인 후 복사

이 간단한 Car 인터페이스에는 인수를 사용하지 않고 아무것도 반환하지 않는 단일 메서드 Drive()가 있습니다. 이 정확한 서명이 있는 Drive() 메소드가 있는 모든 유형은 Car 인터페이스를 구현하는 것으로 간주됩니다.

이제 Car 인터페이스를 구현하는 Tesla 유형을 만들어 보겠습니다.

type Tesla struct{}

func (t Tesla) Drive() {
    println("driving a tesla")
}
로그인 후 복사

Tesla 유형은 인터페이스에 정의된 것과 동일한 서명을 가진 Drive() 메소드가 있기 때문에 Car 인터페이스를 구현합니다.

이 인터페이스를 어떻게 사용할 수 있는지 보여주기 위해 모든 Car를 허용하는 함수를 만들어 보겠습니다.

func DriveCar(c Car) {
    c.Drive()
}

func main() {
    t := Tesla{}
    DriveCar(t)
}
/*
Output:
driving a tesla
*/
로그인 후 복사

이 코드는 Tesla 값을 모든 Car를 허용하는 DriveCar 함수에 전달할 수 있기 때문에 Tesla 유형이 Car 인터페이스를 구현한다는 것을 증명합니다.
참고: 이 저장소에서 전체 코드를 찾을 수 있습니다.

Tesla가 Car 인터페이스를 암시적으로 구현한다는 점을 이해하는 것이 중요합니다. Tesla 구조체 유형이 Car 인터페이스를 구현하는 것과 같은 명시적인 선언은 없습니다. 대신 Go는 올바른 서명이 있는 Drive() 메소드가 있다는 이유만으로 Tesla가 Car를 구현한다는 것을 인식합니다.

Tesla 유형과 Car 인터페이스 간의 관계를 다이어그램으로 시각화해 보겠습니다.
A straightforward guide for Go interface이 다이어그램은 Tesla 유형과 Car 인터페이스 간의 관계를 보여줍니다. Car 인터페이스는 Tesla 유형에 대해 아무것도 모른다는 점에 유의하세요. 어떤 유형이 구현되는지는 중요하지 않으며 알 필요도 없습니다.

이 예가 Go의 인터페이스 개념을 명확히 하는 데 도움이 되기를 바랍니다. 이 간단한 시나리오에서 인터페이스를 사용하면 얻을 수 있는 실질적인 이점이 궁금하더라도 걱정하지 마세요. 다음 섹션에서는 보다 복잡한 상황에서 인터페이스의 성능과 유연성을 살펴보겠습니다.

 

사용 사례

이 섹션에서는 인터페이스가 유용한 이유를 알아보기 위해 몇 가지 실제 사례를 살펴보겠습니다.

다형성

인터페이스를 그토록 강력하게 만드는 것은 Go에서 다형성을 달성하는 능력입니다.
객체 지향 프로그래밍의 개념인 다형성을 사용하면 다양한 유형의 객체를 동일한 방식으로 처리할 수 있습니다. 간단히 말해서 다형성은 "다양한 형태를 가짐"을 뜻하는 멋진 단어일 뿐입니다.
Go 세계에서는 다형성을 "하나의 인터페이스, 다중 구현"으로 생각할 수 있습니다.

예를 들어 이 개념을 살펴보겠습니다. 다양한 유형의 데이터베이스와 작동할 수 있는 간단한 ORM(객체 관계형 매핑)을 구축하고 싶다고 상상해 보세요. 우리는 클라이언트가 특정 쿼리 구문에 대해 걱정하지 않고 데이터베이스에서 데이터를 쉽게 삽입, 업데이트 및 삭제할 수 있기를 원합니다.
이 예에서는 현재 mysql과 postgres만 지원하고 삽입 작업에만 집중한다고 가정하겠습니다. 궁극적으로 우리는 클라이언트가 ORM을 다음과 같이 사용하기를 원합니다.

conn := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test")
orm := myorm.New(&myorm.MySQL{Conn: conn})
orm.Insert("users", user)
로그인 후 복사

 

먼저 인터페이스를 사용하지 않고 이를 달성할 수 있는 방법을 살펴보겠습니다.
각각 Insert 메소드를 사용하여 MySQL 및 Postgres 구조체를 정의하는 것부터 시작하겠습니다.

type MySQL struct {
    Conn *sql.DB
}

func (m *MySQL) Insert(table string, data map[string]interface{}) error {
    // insert into mysql using mysql query
}

type Postgres struct {
    Conn *sql.DB
}

func (p *Postgres) Insert(table string, data map[string]interface{}) error {
    // insert into postgres using postgres query
}
로그인 후 복사

다음으로 드라이버 필드가 있는 ORM 구조체를 정의합니다.

type ORM struct {
    db any
}
로그인 후 복사

The ORM struct will be used by the client. We use the any type for the driver field because we can't determine the specific type of the driver at compile time.

Now, let's implement the New function to initialize the ORM struct:

func New(db any) *ORM {
    return &ORM{db: db}
}
로그인 후 복사

Finally, we'll implement the Insert method for the ORM struct:

func (o *ORM) Insert(table string, data map[string]interface{}) error {
    switch d := o.db.(type) {
    case MySQL:
        return d.Insert(table, data)
    case Postgres:
        return d.Insert(table, data)
    default:
        return fmt.Errorf("unsupported database driver")
    }
}
로그인 후 복사

We have to use a type switch (switch d := o.db.(type)) to determine the type of the driver because the db field is of type any.

While this approach works, it has a significant drawback: if we want to support more database types, we need to add more case statements. This might not seem like a big issue initially, but as we add more database types, our code becomes harder to maintain.

 

Now, let's see how interfaces can help us solve this problem more elegantly.
First, we'll define a DB interface with an Insert method:

type DB interface {
    Insert(table string, data map[string]interface{}) error
}
로그인 후 복사

Any type that has an Insert method with this exact signature automatically implements the DB interface.
Recall that our MySQL and Postgres structs both have Insert methods matching this signature, so they implicitly implement the DB interface.

Next, we can use the DB interface as the type for the db field in our ORM struct:

type ORM struct {
    db DB
}
로그인 후 복사

Let's update the New function to accept a DB interface:

func New(db DB) *ORM {
    return &ORM{db: db}
}
로그인 후 복사

Finally, we'll modify the Insert method to use the DB interface:

func (o *ORM) Insert(table string, data map[string]interface{}) error {
    return o.db.Insert(table, data)
}
로그인 후 복사

Instead of using a switch statement to determine the database type, we can simply call the Insert method of the DB interface.

Now, clients can use the ORM struct to insert data into any supported database without worrying about the specific implementation details:

// using mysql
conn := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test")
orm := myorm.New(&myorm.MySQL{Conn: conn})
orm.Insert("users", user)

// using postgres
conn = sql.Open("postgres", "user=pqgotest dbname=pqgotest sslmode=disable")
orm = myORM.New(&myorm.Postgres{Conn: conn})
orm.Insert("users", user)
로그인 후 복사

With the DB interface, we can easily add support for more database types without modifying the ORM struct or the Insert method. This makes our code more flexible and easier to extend.

Consider the following diagram to illustrate the relationship between the ORM, MySQL, Postgres, and DB interfaces:
A straightforward guide for Go interfaceIn this diagram, the ORM struct depends on the DB interface, and the MySQL and Postgres structs implement the DB interface. This allows the ORM struct to use the Insert method of the DB interface without knowing the specific implementation details of the MySQL or Postgres structs.

This example demonstrates the power of interfaces in Go. We can have one interface and multiple implementations, allowing us to write more adaptable and maintainable code.

Note: You can find the complete code in this repository.

 

Making testing easier

Let's consider an example where we want to implement an S3 uploader to upload files to AWS S3. Initially, we might implement it like this:

type S3Uploader struct {
    client *s3.Client
}

func NewS3Uploader(client *s3.Client) *S3Uploader {
    return &S3Uploader{client: client}
}

func (s *S3Uploader) Upload(ctx context.Context, bucketName, objectKey string, data []byte) error {
    _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: aws.String(bucketName),
        Key:    aws.String(objectKey),
        Body:   bytes.NewReader(data),
    })

    return err
}
로그인 후 복사

In this example, the client field in the S3Uploader struct is type *s3.Client, which means the S3Uploader struct is directly dependent on the s3.Client.
Let's visualize this with a diagram:
A straightforward guide for Go interfaceWhile this implementation works fine during development, it can pose challenges when we're writing unit tests. For unit testing, we typically want to avoid depending on external services like S3. Instead, we'd prefer to use a mock that simulates the behavior of the S3 client.

This is where interfaces come to the rescue.

We can define an S3Manager interface that includes a PutObject method:

type S3Manager interface {
    PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
}
로그인 후 복사

Note that the PutObject method has the same signature as the PutObject method in s3.Client.

Now, we can use this S3Manager interface as the type for the client field in our S3Uploader struct:

type S3Uploader struct {
    client S3Manager
}
로그인 후 복사

Next, we'll modify the NewS3Uploader function to accept the S3Manager interface instead of the concrete s3.Client:

func NewS3Uploader(client S3Manager) *S3Uploader {
    return &S3Uploader{client: client}
}
로그인 후 복사

With this implementation, we can pass any type that has a PutObject method to the NewS3Uploader function.

For testing purposes, we can create a mock object that implements the S3Manager interface:

type MockS3Manager struct{}

func (m *MockS3Manager) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
    // mocking logic
    return &s3.PutObjectOutput{}, nil
}
로그인 후 복사

We can then pass this MockS3Manager to the NewS3Uploader function when writing unit testing.

mockUploader := NewS3Uploader(&MockS3Manager{})
로그인 후 복사

This approach allows us to test the Upload method easily without actually interacting with the S3 service.

After using the interface, our diagram looks like this:
A straightforward guide for Go interfaceIn this new structure, the S3Uploader struct depends on the S3Manager interface. Both s3.Client and MockS3Manager implement the S3Manager interface. This allows us to use s3.Client for the real S3 service and MockS3Manager for mocking during unit tests.

As you might have noticed, this is also an excellent example of polymorphism in action.

Note: You can find the complete code in this repository.

 

Decoupling

In software design, it's recommended to decouple dependencies between modules. Decoupling means making the dependencies between modules as loose as possible. It helps us develop software in a more flexible way.

To use an analogy, we can think of a middleman sitting between two modules:
A straightforward guide for Go interfaceIn this case, Module A depends on the middleman, instead of directly depending on Module B.

You might wonder, what's the benefit of doing this?
Let's look at an example.

Imagine we're building a web application that takes an ID as a parameter to get a user's name. In this application, we have two packages: handler and service.
The handler package is responsible for handling HTTP requests and responses.
The service package is responsible for retrieving the user's name from the database.

Let's first look at the code for the handler package:

package handler

type Handler struct {
    // we'll implement MySQLService later
    service service.MySQLService
}

func (h *Handler) GetUserName(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")

    if !isValidID(id) {
        http.Error(w, "Invalid user ID", http.StatusBadRequest)
        return
    }

    userName, err := h.service.GetUserName(id)
    if err != nil {
        http.Error(w, fmt.Sprintf("Error retrieving user name: %v", err), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(userName)
}
로그인 후 복사

This code is straightforward. The Handler struct has a service field. For now, it depends on the MySQLService struct, which we'll implement later. It uses h.service.GetUserName(id) to get the user's name. The handler package's job is to handle HTTP requests and responses, as well as validation.

Now, let's look at the service package:

package service

type MySQLService struct {
    sql *sql.DB
}

func NewMySQLService(sql *sql.DB) *MySQLService {
    return &MySQLService{sql: sql}
}

func (s *MySQLService) GetUserName(id string) (string, error) {
    // get user name from database
}
로그인 후 복사

Here, the MySQLService struct has an sql field, and it retrieves the user's name from the database.

In this implementation, the Handler struct is directly dependent on the MySQLService struct:
A straightforward guide for Go interfaceThis might not seem like a big deal at first, but if we want to switch to a different database, we'd have to modify the Handler struct to remove the dependency on the MySQLService struct and create a new struct for the new database.
A straightforward guide for Go interfaceThis violates the principle of decoupling. Typically, changes in one package should not affect other packages.

 

To fix this problem, we can use an interface.
We can define a Service interface that has a GetUserName method:

type Service interface {
    GetUserName(id string) (string, error)
}
로그인 후 복사

We can use this Service interface as the type of the service field in the Handler struct:

package handler

type Service interface {
    GetUserName(id string) (string, error)
}

type Handler struct {
    service Service // now it depends on the Service interface
}

func (h *Handler) GetUserName(w http.ResponseWriter, r *http.Request) {
    // same as before

    // Get the user from the service
    user, err := h.service.GetUserName(id)
    if err != nil {
        http.Error(w, "Error retrieving user: "+err.Error(), http.StatusInternalServerError)
        return
    }

    // same as before
}
로그인 후 복사

In this implementation, the Handler struct is no longer dependent on the MySQLService struct. Instead, it depends on the Service interface:
A straightforward guide for Go interfaceIn this design, the Service interface acts as a middleman between Handler and MySQLService.
For Handler, it now depends on behavior, rather than a concrete implementation. It doesn't need to know the details of the Service struct, such as what database it uses. It only needs to know that the Service has a GetUserName method.

When we need to switch to a different database, we can just simply create a new struct that implements the Service interface without changing the Handler struct.
A straightforward guide for Go interface

When designing code structure, we should always depend on behavior rather than implementation.
It's better to depend on something that has the behavior you need, rather than depending on a concrete implementation.

Note: You can find the complete code in this repository.

 

Working With the Standard Library

As you gain more experience with Go, you'll find that interfaces are everywhere in the standard library.
Let's use the error interface as an example.

In Go, error is simply an interface with one method, Error() string:

type error interface {
    Error() string
}
로그인 후 복사

This means that any type with an Error method matching this signature implements the error interface. We can leverage this feature to create our own custom error types.

Suppose we have a function to log error messages:

func LogError(err error) {
    log.Fatal(fmt.Errorf("received error: %w", err))
}
로그인 후 복사

While this is a simple example, in practice, the LogError function might include additional logic, such as adding metadata to the error message or sending it to a remote logging service.

Now, let's define two custom error types, OrderError and PaymentDeclinedError. These have different fields, and we want to log them differently:

// OrderError represents a general error related to orders
type OrderError struct {
    OrderID string
    Message string
}

func (e OrderError) Error() string {
    return fmt.Sprintf("Order %s: %s", e.OrderID, e.Message)
}

// PaymentDeclinedError represents a payment failure
type PaymentDeclinedError struct {
    OrderID string
    Reason  string
}

func (e PaymentDeclinedError) Error() string {
    return fmt.Sprintf("Payment declined for order %s: %s", e.OrderID, e.Reason)
}
로그인 후 복사

Because both OrderError and PaymentDeclinedError have an Error method with the same signature as the error interface, they both implement this interface. Consequently, we can use them as arguments to the LogError function:

LogError(OrderError{OrderID: "123", Message: "Order not found"})
LogError(PaymentDeclinedError{OrderID: "123", Reason: "Insufficient funds"})
로그인 후 복사

This is another excellent example of polymorphism in Go: one interface, multiple implementations. The LogError function can work with any type that implements the error interface, allowing for flexible and extensible error handling in your Go programs.

Note: You can find the complete code in this repository.

 

Summary

In this article, we've explored the concept of interfaces in Go, starting with a simple analogy and gradually moving to more complex examples.

Key takeaways about Go interfaces:

  • They are all about abstraction
  • They are defined as a set of method signatures
  • They define behavior without specifying implementation details
  • They are implemented implicitly (no explicit declaration needed)

I hope this article has helped you gain a better understanding of interfaces in Go.

 

Reference

  • Learning Go: An Idiomatic Approach to Real-World Go Programming
  • 100 Go Mistakes and How to Avoid Them
  • Golang Interfaces Explained

 

As I'm not an experienced Go developer, I welcome any feedback. If you've noticed any mistakes or have suggestions for improvement, please leave a comment. Your feedback is greatly appreciated and will help enhance this resource for others.

위 내용은 Go 인터페이스에 대한 간단한 가이드의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

원천:dev.to
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿