> Java > java지도 시간 > Saga 패턴이 분산 트랜잭션 문제를 해결하는 방법: 방법 및 실제 사례

Saga 패턴이 분산 트랜잭션 문제를 해결하는 방법: 방법 및 실제 사례

Linda Hamilton
풀어 주다: 2024-10-20 20:11:02
원래의
586명이 탐색했습니다.

1. 문제 이해: 분산 트랜잭션의 복잡성

How the Saga Pattern Resolves Distributed Transaction Issues: Methods and Real-World Example

분산 트랜잭션에는 각 서비스가 트랜잭션의 일부를 수행하는 여러 마이크로서비스가 포함됩니다. 예를 들어 전자상거래 플랫폼에는 결제, 재고, 주문 관리와 같은 서비스가 포함될 수 있습니다. 거래를 완료하려면 이러한 서비스가 함께 작동해야 합니다. 그러나 이러한 서비스 중 하나가 실패하면 어떻게 됩니까?

1.1 실제 시나리오

주문 중에 다음 단계가 발생하는 전자상거래 애플리케이션을 상상해 보세요.

  • 1단계 : 고객 계좌에서 결제 금액을 차감합니다.
  • 2단계 : 인벤토리의 아이템 수를 줄입니다.
  • 3단계 : 주문 관리 시스템에서 주문을 생성합니다.

How the Saga Pattern Resolves Distributed Transaction Issues: Methods and Real-World Example

결제 금액이 차감된 후 주문이 생성되기 전에 재고 서비스가 실패하면 시스템이 일관성 없는 상태가 됩니다. 고객에게 요금이 청구되었으나 주문이 이루어지지 않았습니다.

1.2 기존 솔루션과 그 한계

이러한 실패를 처리하려면 2단계 커밋 프로토콜을 사용하는 분산 트랜잭션을 사용하는 것이 좋습니다. 그러나 이로 인해 몇 가지 문제가 발생합니다.

  • 높은 지연 시간 : 각 서비스는 트랜잭션 중에 리소스를 잠가야 하므로 지연 시간이 늘어납니다.
  • 가용성 감소 : 서비스에 장애가 발생하면 전체 트랜잭션이 롤백되어 전체 시스템 가용성이 감소합니다.
  • 긴밀한 결합: 서비스가 긴밀하게 결합되어 개별 서비스를 확장하거나 수정하기가 더 어려워집니다.

2. Saga 패턴이 문제를 해결하는 방법

분산 시스템에서는 트랜잭션이 여러 마이크로서비스에 걸쳐 이루어지는 경우가 많습니다. 모든 서비스가 성공적으로 완료되거나 전혀 완료되지 않도록 하는 것은 어려운 일입니다. 2단계 커밋이 포함된 분산 트랜잭션을 사용하여 이를 처리하는 기존 방법은 높은 대기 시간, 긴밀한 결합 및 가용성 감소와 같은 문제로 인해 문제가 될 수 있습니다.

How the Saga Pattern Resolves Distributed Transaction Issues: Methods and Real-World Example

Saga 패턴은 보다 유연한 접근 방식을 제공합니다. Saga 패턴은 트랜잭션을 단일 단위로 실행하려고 시도하는 대신 독립적으로 수행할 수 있는 더 작고 격리된 단계로 트랜잭션을 나눕니다. 각 단계는 데이터베이스를 업데이트하고 다음 단계를 트리거하는 로컬 트랜잭션입니다. 단계가 실패하면 시스템은 보상 작업을 수행하여 이전 단계에서 변경한 내용을 취소하여 시스템이 일관된 상태로 돌아갈 수 있도록 합니다.

2.1 사가 패턴이란 무엇입니까?

Saga 패턴은 기본적으로 차례로 실행되는 작은 트랜잭션의 시퀀스입니다. 작동 방식은 다음과 같습니다.

  • 로컬 트랜잭션 : 트랜잭션에 관련된 각 서비스는 자체 로컬 트랜잭션을 수행합니다. 예를 들어, 주문 처리 시스템에서 하나의 서비스는 결제, 다른 서비스는 재고, 또 다른 하나는 주문 기록을 처리할 수 있습니다.
  • 이벤트 또는 메시지 게시: 서비스는 로컬 트랜잭션을 완료한 후 이벤트를 게시하거나 해당 단계의 성공적인 완료를 나타내는 메시지를 보냅니다. 예를 들어 결제가 처리된 후 결제 서비스에서 "PaymentCompleted" 이벤트를 게시할 수 있습니다.
  • 다음 단계 트리거: 시퀀스의 다음 서비스는 이벤트를 수신하고 이를 수신하면 로컬 트랜잭션을 진행합니다. 이는 거래의 모든 단계가 완료될 때까지 계속됩니다.
  • 보상 조치 : 단계가 실패하면 보상 조치가 호출됩니다. 이러한 작업은 이전 단계에서 변경한 내용을 되돌리도록 설계되었습니다. 예를 들어, 결제 후 재고 감소에 실패하면 보상 조치를 통해 결제 금액을 환불하게 됩니다.

2.2 사가의 종류

사가 패턴을 구현하는 방법은 크게 안무오케스트레이션 두 가지가 있습니다.

2.2.1 안무사가

안무사가에는 중앙 코디네이터가 없습니다. 대신, Saga와 관련된 각 서비스는 이벤트를 수신하고 이전 단계의 결과에 따라 조치를 취할 시기를 결정합니다. 이 접근 방식은 분산되어 있어 서비스가 독립적으로 작동할 수 있습니다. 작동 방식은 다음과 같습니다.

  • 이벤트 기반 조정 : 각 서비스는 해당 서비스와 관련된 이벤트를 처리합니다. 예를 들어 결제 서비스는 결제를 처리한 후 "PaymentCompleted" 이벤트를 발생시킵니다. 인벤토리 서비스는 이 이벤트를 수신하고 이를 수신하면 항목 수를 차감합니다.
  • 분산형 제어 : 중앙 조정자가 없기 때문에 각 서비스는 수신된 이벤트에 따라 다음에 수행할 작업을 알아야 합니다. 이렇게 하면 시스템에 더 많은 유연성이 제공되지만 모든 서비스가 올바른 작업 순서를 이해할 수 있도록 신중한 계획이 필요합니다.
  • 보상 조치: 서비스가 문제가 있음을 감지하면 실패 이벤트를 내보낼 수 있으며, 다른 서비스는 이를 수신하여 보상 조치를 트리거할 수 있습니다. 예를 들어, 재고 서비스가 재고를 업데이트할 수 없는 경우 결제 서비스가 환불을 트리거하기 위해 수신 대기하는 "InventoryUpdateFailed" 이벤트를 발생시킬 수 있습니다.

안무의 장점:

  • 느슨한 결합 : 서비스가 느슨하게 결합되어 개별 서비스를 더 쉽게 확장하고 수정할 수 있습니다.
  • 복원력 : 각 서비스가 독립적으로 작동하므로 개별 서비스의 장애에 대해 시스템의 복원력이 더욱 높아질 수 있습니다.

안무의 어려움:

  • 복잡성 : 서비스의 수가 늘어남에 따라 이벤트의 흐름을 관리하고 이해하는 것이 복잡해질 수 있습니다.
  • 중앙 통제 부족 : 중앙 코디네이터가 없으면 전체 거래 흐름을 모니터링하고 디버그하기가 더 어려울 수 있습니다.

2.2.2 오케스트레이션 사가

오케스트레이션 사가에서는 중앙 오케스트레이터가 트랜잭션 흐름을 제어합니다. 오케스트레이터는 단계의 순서를 결정하고 서비스 간의 통신을 처리합니다. 작동 방식은 다음과 같습니다.

  • 중앙 집중식 제어 : 오케스트레이터는 각 서비스에 순차적으로 명령을 보냅니다. 예를 들어 오케스트레이터는 먼저 결제 서비스에 결제를 처리하도록 지시할 수 있습니다. 완료되면 인벤토리 서비스에 인벤토리 업데이트 등을 지시합니다.
  • 순차 실행 : 각 서비스는 오케스트레이터의 지시가 있을 때만 작업을 수행하여 단계가 올바른 순서로 발생하도록 합니다.
  • 보상 논리 : 오케스트레이터는 문제가 발생할 경우 보상 조치를 시작하는 역할도 담당합니다. 예를 들어, 인벤토리 업데이트가 실패하면 오케스트레이터는 결제 서비스에 결제 금액을 환불하도록 명령할 수 있습니다.

오케스트레이션의 장점:

  • 중앙 집중식 제어 : 단일 오케스트레이터를 사용하면 거래 흐름을 더 쉽게 모니터링, 관리, 디버그할 수 있습니다.
  • 간단한 로직 : 오케스트레이터가 흐름을 처리하므로 개별 서비스는 전체 트랜잭션 순서를 알 필요가 없습니다.

오케스트레이션의 과제:

  • 단일 실패 지점 : 오케스트레이터는 고가용성을 위해 설계되지 않으면 병목 현상이나 단일 실패 지점이 될 수 있습니다.
  • 오케스트레이터에 대한 긴밀한 결합 : 서비스가 오케스트레이터에 종속되어 안무에 비해 시스템의 유연성이 떨어질 수 있습니다.

3. 간단한 오케스트레이션 사가 패턴 구현: 단계별 가이드

전자상거래 시나리오를 고려하고 Saga 패턴을 사용하여 구현해 보겠습니다.

커피 구매 시나리오에서 각 서비스는 현지 거래를 나타냅니다. Coffee Service는 구매를 완료하기 위해 다른 서비스를 조정하는 이 시리즈의 조정자 역할을 합니다.

이 이야기의 진행 방식은 다음과 같습니다.

  • 고객 주문 : 고객이 주문 서비스를 통해 주문합니다.
  • 커피 서비스가 이야기를 시작합니다 : 커피 서비스가 주문을 받고 이야기를 시작합니다.
  • Order Service는 주문을 생성합니다 : Order Service는 새로운 주문을 생성하고 유지합니다.
  • 청구 서비스가 비용을 계산합니다 : 청구 서비스가 주문의 총 비용을 계산하고 청구 기록을 생성합니다.
  • 결제 서비스가 결제를 처리합니다 : 결제 서비스가 결제를 처리합니다.
  • 커피 서비스에서 주문 상태 업데이트 : 결제가 완료되면 커피 서비스에서 주문 상태를 '완료'로 업데이트합니다.

How the Saga Pattern Resolves Distributed Transaction Issues: Methods and Real-World Example

3.1 거래 주체

How the Saga Pattern Resolves Distributed Transaction Issues: Methods and Real-World Example

제가 구현한 Saga에서 각 SagaItemBuilder는 분산 트랜잭션 흐름의 한 단계를 나타냅니다. ActionBuilder는 오류가 발생할 경우 실행할 기본 작업과 롤백 작업을 포함하여 수행할 작업을 정의합니다. ActionBuilder는 세 가지 정보를 요약합니다.

comComponent : 호출될 메소드가 상주하는 Bean 인스턴스입니다.

method : 호출할 메소드의 이름입니다.

args : 메소드에 전달될 인수입니다.

ActionBuilder

public class ActionBuilder {
    private Object component;
    private String method;
    private Object[] args;

    public static ActionBuilder builder() {
        return new ActionBuilder();
    }

    public ActionBuilder component(Object component) {
        this.component = component;
        return this;
    }

    public ActionBuilder method(String method) {
        this.method = method;
        return this;
    }

    public ActionBuilder args(Object... args) {
        this.args = args;
        return this;
    }

    public Object getComponent() { return component; }
    public String getMethod() { return method; }
    public Object[] getArgs() { return args; }
}
로그인 후 복사

SagaItemBuilder

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class SagaItemBuilder {
    private ActionBuilder action;
    private Map<Class<? extends Exception>, ActionBuilder> onBehaviour;

    public static SagaItemBuilder builder() {
        return new SagaItemBuilder();
    }

    public SagaItemBuilder action(ActionBuilder action) {
        this.action = action;
        return this;
    }

    public SagaItemBuilder onBehaviour(Class<? extends Exception> exception, ActionBuilder action) {
        if (Objects.isNull(onBehaviour)) onBehaviour = new HashMap<>();
        onBehaviour.put(exception, action);
        return this;
    }

    public ActionBuilder getAction() {
        return action;
    }

    public Map<Class<? extends Exception>, ActionBuilder> getBehaviour() {
        return onBehaviour;
    }
}
로그인 후 복사

시나리오

import java.util.ArrayList;
import java.util.List;

public class Scenarios {
    List<SagaItemBuilder> scenarios;

    public static Scenarios builder() {
        return new Scenarios();
    }

    public Scenarios scenario(SagaItemBuilder sagaItemBuilder) {
        if (scenarios == null) scenarios = new ArrayList<>();
        scenarios.add(sagaItemBuilder);
        return this;
    }

    public List<SagaItemBuilder> getScenario() {
        return scenarios;
    }
}
로그인 후 복사

다음은 배포 트랜잭션을 커밋하는 방법입니다.

package com.example.demo.saga;

import com.example.demo.saga.exception.CanNotRollbackException;
import com.example.demo.saga.exception.RollBackException;
import com.example.demo.saga.pojo.ActionBuilder;
import com.example.demo.saga.pojo.SagaItemBuilder;
import com.example.demo.saga.pojo.Scenarios;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.Map;
import java.util.Set;

@Component
public class DTC {

    public boolean commit(Scenarios scenarios) throws Exception {
        validate(scenarios);
        for (int i = 0; i < scenarios.getScenario().size(); i++) {
            SagaItemBuilder scenario = scenarios.getScenario().get(i);
            ActionBuilder action = scenario.getAction();
            Object bean = action.getComponent();
            String method = action.getMethod();
            Object[] args = action.getArgs();

            try {
                invoke(bean, method, args);
            } catch (Exception e) {
                rollback(scenarios, i, e);
                return false;
            }
        }
        return true;
    }

    private void rollback(Scenarios scenarios, Integer failStep, Exception currentStepFailException) {
        for (int i = failStep; i >= 0; i--) {
            SagaItemBuilder scenario = scenarios.getScenario().get(i);
            Map<Class<? extends Exception>, ActionBuilder> behaviours = scenario.getBehaviour();
            Set<Class<? extends Exception>> exceptions = behaviours.keySet();
            ActionBuilder actionWhenException = null;

            if (failStep == i) {
                for(Class<? extends Exception> exception: exceptions) {
                    if (exception.isInstance(currentStepFailException)) {
                        actionWhenException = behaviours.get(exception);
                    }
                }
                if (actionWhenException == null) actionWhenException = behaviours.get(RollBackException.class);
            } else {
                actionWhenException = behaviours.get(RollBackException.class);
            }

            Object bean = actionWhenException.getComponent();
            String method = actionWhenException.getMethod();
            Object[] args = actionWhenException.getArgs();
            try {
                invoke(bean, method, args);
            } catch (Exception e) {
                throw new CanNotRollbackException("Error in %s belong to %s. Can not rollback transaction".formatted(method, bean.getClass()));
            }
        }
    }

    private void validate(Scenarios scenarios) throws Exception {
        for (int i = 0; i < scenarios.getScenario().size(); i++) {
            SagaItemBuilder scenario = scenarios.getScenario().get(i);
            ActionBuilder action = scenario.getAction();
            if (action.getComponent() == null) throw new Exception("Missing bean in scenario");
            if (action.getMethod() == null) throw new Exception("Missing method in scenario");

            Map<Class<? extends Exception>, ActionBuilder> behaviours = scenario.getBehaviour();
            Set<Class<? extends Exception>> exceptions = behaviours.keySet();
            if (exceptions.contains(null)) throw new Exception("Exception can not be null in scenario has method %s, bean %s " .formatted(action.getMethod(), action.getComponent().getClass()));
            if (!exceptions.contains(RollBackException.class)) throw new Exception("Missing default RollBackException in scenario has method %s, bean %s " .formatted(action.getMethod(), action.getComponent().getClass()));
        }
    }

    public String invoke(Object bean, String methodName, Object... args) throws Exception {
        try {
            Class<?>[] paramTypes = new Class[args.length];
            for (int i = 0; i < args.length; i++) {
                paramTypes[i] = parameterType(args[i]);
            }
            Method method = bean.getClass().getDeclaredMethod(methodName, paramTypes);
            Object result = method.invoke(bean, args);
            return result != null ? result.toString() : null;
        } catch (Exception e) {
            throw e;
        }
    }

    private static Class<?> parameterType (Object o) {
        if (o instanceof Integer) {
           return int.class;
        } else if (o instanceof Boolean) {
            return boolean.class;
        } else if (o instanceof Double) {
            return double.class;
        } else if (o instanceof Float) {
            return float.class;
        } else if (o instanceof Long) {
            return long.class;
        } else if (o instanceof Short) {
            return short.class;
        } else if (o instanceof Byte) {
            return byte.class;
        } else if (o instanceof Character) {
            return char.class;
        } else {
            return o.getClass();
        }
    }
}
로그인 후 복사

3.2 사용하기

외부 서비스를 호출하는 서비스가 3개 있습니다: BillingService , OrderService , PaymentService.

주문서비스

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class OrderService {

    public String prepareOrder(String name, int number) {
        System.out.println("Prepare order for %s with order id %d ".formatted(name, number));
        return "Prepare order for %s with order id %d ".formatted(name, number);
    }

    public void Rollback_prepareOrder_NullPointException() {
        System.out.println("Rollback prepareOrder because NullPointException");
    }

    public void Rollback_prepareOrder_RollBackException() {
        System.out.println("Rollback prepareOrder because RollBackException");
    }
}
로그인 후 복사

결제서비스

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class BillingService {

    public String prepareBilling(String name, int number) {
        System.out.println("Prepare billing for %s with order id %d ".formatted(name, number));
        return "Prepare billing for %s with order id %d ".formatted(name, number);
    }

    public String createBilling(String name, int number) {
        System.out.println("Create billing for %s with order id %d ".formatted(name, number));
        return "Create billing for %s with order id %d ".formatted(name, number);
    }

    public void Rollback_prepareBilling_NullPointException() {
        System.out.println("Rollback prepareBilling because NullPointException");
    }

    public void Rollback_prepareBilling_ArrayIndexOutOfBoundsException() {
        System.out.println("Rollback prepareBilling because ArrayIndexOutOfBoundsException");
    }

    public void Rollback_prepareBilling_RollBackException() {
        System.out.println("Rollback prepareBilling because RollBackException");
    }

    public void Rollback_createBilling_NullPointException() {
        System.out.println("Rollback createBilling because NullPointException");
    }

    public void Rollback_createBilling_ArrayIndexOutOfBoundsException() {
        System.out.println("Rollback createBilling because ArrayIndexOutOfBoundsException");
    }

    public void Rollback_createBilling_RollBackException() {
        System.out.println("Rollback createBilling because RollBackException");
    }
}
로그인 후 복사

결제서비스

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    public String createPayment() {
        System.out.println("Create payment");
        return "Create payment";
    }

    public void Rollback_createPayment_NullPointException() {
        System.out.println("Rollback createPayment because NullPointException");
    }

    public void Rollback_createPayment_RollBackException() {
        System.out.println("Rollback createPayment because RollBackException");
    }
}
로그인 후 복사

그리고 커피서비스에서는 아래와 같이 구현하는데, 시나리오를 작성한 후 커밋합니다.

package com.example.demo.service;

import com.example.demo.saga.DTC;
import com.example.demo.saga.exception.RollBackException;
import com.example.demo.saga.pojo.ActionBuilder;
import com.example.demo.saga.pojo.SagaItemBuilder;
import com.example.demo.saga.pojo.Scenarios;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class CoffeeService {

    @Autowired
    private OrderService orderService;

    @Autowired
    private BillingService billingService;

    @Autowired
    private PaymentService paymentService;

    @Autowired
    private DTC dtc;

    public String test() throws Exception {
        Scenarios scenarios = Scenarios.builder()
                .scenario(
                        SagaItemBuilder.builder()
                                .action(ActionBuilder.builder().component(orderService).method("prepareOrder").args("tuanh.net", 123))
                                .onBehaviour(NullPointerException.class, ActionBuilder.builder().component(orderService).method("Rollback_prepareOrder_NullPointException").args())
                                .onBehaviour(RollBackException.class, ActionBuilder.builder().component(orderService).method("Rollback_prepareOrder_RollBackException").args())
                ).scenario(
                        SagaItemBuilder.builder()
                                .action(ActionBuilder.builder().component(billingService).method("prepareBilling").args("tuanh.net", 123))
                                .onBehaviour(NullPointerException.class, ActionBuilder.builder().component(billingService).method("Rollback_prepareBilling_NullPointException").args())
                                .onBehaviour(RollBackException.class, ActionBuilder.builder().component(billingService).method("Rollback_prepareBilling_RollBackException").args())
                ).scenario(
                         SagaItemBuilder.builder()
                                .action(ActionBuilder.builder().component(billingService).method("createBilling").args("tuanh.net", 123))
                                .onBehaviour(NullPointerException.class, ActionBuilder.builder().component(billingService).method("Rollback_createBilling_ArrayIndexOutOfBoundsException").args())
                                .onBehaviour(RollBackException.class, ActionBuilder.builder().component(billingService).method("Rollback_createBilling_RollBackException").args())
                ).scenario(
                        SagaItemBuilder.builder()
                                .action(ActionBuilder.builder().component(paymentService).method("createPayment").args())
                                .onBehaviour(NullPointerException.class, ActionBuilder.builder().component(paymentService).method("Rollback_createPayment_NullPointException").args())
                                .onBehaviour(RollBackException.class, ActionBuilder.builder().component(paymentService).method("Rollback_createPayment_RollBackException").args())
                );
        dtc.commit(scenarios);
        return "ok";
    }
}
로그인 후 복사

3.3 결과

빌링 생성 시 예외를 적용한 경우

public String createBilling(String name, int number) {
    throw new NullPointerException();
}
로그인 후 복사

결과

2024-08-24T14:21:45.445+07:00 INFO 19736 --- [demo] [main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
2024-08-24T14:21:45.450+07:00 INFO 19736 --- [demo] [main] com.example.demo.DemoApplication : Started DemoApplication in 1.052 seconds (process running for 1.498)
2024-08-24T14:21:47.756+07:00 INFO 19736 --- [demo] [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2024-08-24T14:21:47.756+07:00 INFO 19736 --- [demo] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2024-08-24T14:21:47.757+07:00 INFO 19736 --- [demo] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
Prepare order for tuanh.net with order id 123 
Prepare billing for tuanh.net with order id 123 
Rollback createBilling because RollBackException
Rollback prepareBilling because RollBackException
Rollback prepareOrder because RollBackException
로그인 후 복사

내 GitHub 저장소를 확인하세요

4. 결론

요약하자면, Saga 패턴은 분산 트랜잭션을 더 작고 관리 가능한 단계로 나누어 관리하기 위한 강력한 솔루션을 제공합니다. 안무와 오케스트레이션 사이의 선택은 시스템의 특정 요구 사항과 아키텍처에 따라 달라집니다. 안무는 느슨한 결합과 탄력성을 제공하는 반면, 오케스트레이션은 중앙 집중식 제어와 보다 쉬운 모니터링을 제공합니다. Saga 패턴으로 시스템을 신중하게 설계하면 분산 마이크로서비스 아키텍처에서 일관성, 가용성 및 유연성을 얻을 수 있습니다.

질문이 있거나 시스템에 Saga 패턴을 구현하는 데 대한 추가 설명이 필요한 경우 아래에 자유롭게 의견을 남겨주세요!

에서 더 많은 게시물 읽기: 사가 패턴이 분산 트랜잭션 문제를 해결하는 방법: 방법 및 실제 사례

위 내용은 Saga 패턴이 분산 트랜잭션 문제를 해결하는 방법: 방법 및 실제 사례의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

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