注文処理システムの実装: パート 基盤のセットアップ

王林
リリース: 2024-09-05 22:31:33
オリジナル
1114 人が閲覧しました

Implementing an Order Processing System: Part  Setting Up the Foundation

1. はじめにと目標

マイクロサービス オーケストレーションに Temporal を使用した高度な注文処理システムの実装に関する包括的なブログ シリーズの最初の部分へようこそ。このシリーズでは、複雑で長時間実行されるワークフローを処理できる、堅牢でスケーラブルで保守可能なシステムの構築の複雑さを探っていきます。

私たちの旅は、プロジェクトの基礎を築くことから始まります。この投稿を終えると、Golang で実装され、ワー​​クフロー オーケストレーションのために Temporal と統合され、Postgres データベースによってサポートされる、完全に機能する CRUD REST API が完成します。最新のツールとベスト プラクティスを使用して、コードベースがクリーンで効率的で、保守が容易であることを保証します。

この投稿の目標:

  1. Go モジュールを使用して適切に構造化されたプロジェクトをセットアップする
  2. Gin と oapi-codegen を使用して基本的な CRUD API を実装します
  3. Postgres データベースをセットアップし、移行を実装する
  4. データベースとの対話を使用した単純な時間ワークフローを作成する
  5. テスト容易性と保守性を向上させるために依存関係の注入を実装します
  6. Docker を使用してアプリケーションをコンテナ化します
  7. docker-compose を使用して完全なローカル開発環境を提供する

注文処理システムの構築を始めましょう!

2. 理論的背景と概念

実装を開始する前に、使用する主要なテクノロジーと概念を簡単に確認してみましょう。

ゴラン

Go は、そのシンプルさ、効率性、および同時プログラミングの優れたサポートで知られる、静的に型付けされたコンパイル言語です。その標準ライブラリと堅牢なエコシステムにより、マイクロサービスの構築に最適です。

時間的

Temporal は、分散アプリケーションの開発を簡素化するマイクロサービス オーケストレーション プラットフォームです。これにより、複雑で長時間実行されるワークフローを単純な手続き型コードとして記述し、失敗や再試行を自動的に処理できます。

ジン Web フレームワーク

Gin は、Go で書かれた高性能 HTTP Web フレームワークです。これは、パフォーマンスが大幅に向上し、メモリ使用量が削減されたマティーニのような API を提供します。

OpenAPI と oapi-codegen

OpenAPI (以前は Swagger として知られていました) は、RESTful Web サービスを記述、生成、消費、視覚化するための機械可読インターフェイス ファイルの仕様です。 oapi-codegen は、OpenAPI 3.0 仕様から Go コードを生成するツールです。これにより、最初に API コントラクトを定義し、サーバー スタブとクライアント コードを生成できます。

SQL

sqlc は SQL からタイプセーフな Go コードを生成します。これにより、プレーンな SQL クエリを記述し、データベースと対話するための完全にタイプセーフな Go コードを生成できるため、実行時エラーの可能性が減り、保守性が向上します。

ポストグレ

PostgreSQL は、信頼性、機能の堅牢性、パフォーマンスで知られる強力なオープンソースのオブジェクト リレーショナル データベース システムです。

Docker と docker-compose

Docker を使用すると、アプリケーションとその依存関係をコンテナーにパッケージ化し、さまざまな環境間での一貫性を確保できます。 docker-compose は、マルチコンテナ Docker アプリケーションを定義して実行するためのツールであり、ローカル開発環境をセットアップするために使用します。

基本を説明したので、システムの実装を始めましょう。

3. 段階的な実装ガイド

3.1 プロジェクト構造のセットアップ

まず、プロジェクト ディレクトリを作成し、基本構造を設定しましょう:

mkdir order-processing-system
cd order-processing-system

# Create directory structure
mkdir -p cmd/api \
         internal/api \
         internal/db \
         internal/models \
         internal/service \
         internal/workflow \
         migrations \
         pkg/logger \
         scripts

# Initialize Go module
go mod init github.com/yourusername/order-processing-system

# Create main.go file
touch cmd/api/main.go

ログイン後にコピー

この構造は、標準の Go プロジェクト レイアウトに従います。

  • cmd/api: アプリケーションのメイン エントリ ポイントが含まれます
  • 内部: このプロジェクトに固有であり、他のプロジェクトによってインポートされることを意図していないパッケージを格納します
  • 移行: データベース移行ファイルを保存します
  • pkg: 他のプロジェクトによってインポートできるパッケージが含まれています
  • scripts: 開発と展開のためのユーティリティ スクリプトを保持します

3.2 メイクファイルの作成

一般的なタスクを簡素化するために Makefile を作成しましょう:

touch Makefile

ログイン後にコピー

次のコンテンツを Makefile に追加します:

.PHONY: generate build run test clean

generate:
    @echo "Generating code..."
    go generate ./...

build:
    @echo "Building..."
    go build -o bin/api cmd/api/main.go

run:
    @echo "Running..."
    go run cmd/api/main.go

test:
    @echo "Running tests..."
    go test -v ./...

clean:
    @echo "Cleaning..."
    rm -rf bin

.DEFAULT_GOAL := build

ログイン後にコピー

この Makefile は、コードの生成、アプリケーションの構築、実行、テストの実行、ビルド アーティファクトのクリーンアップのためのターゲットを提供します。

3.3 基本的な CRUD API の実装

3.3.1 OpenAPI 仕様の定義

api/openapi.yaml という名前のファイルを作成し、API 仕様を定義します。

openapi: 3.0.0
info:
  title: Order Processing API
  version: 1.0.0
  description: API for managing orders in our processing system

paths:
  /orders:
    get:
      summary: List all orders
      responses:
        '200':
          description: Successful response
          content:
            application/json:    
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Order'
    post:
      summary: Create a new order
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'

  /orders/{id}:
    get:
      summary: Get an order by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '404':
          description: Order not found
    put:
      summary: Update an order
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateOrderRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '404':
          description: Order not found
    delete:
      summary: Delete an order
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '204':
          description: Successful response
        '404':
          description: Order not found

components:
  schemas:
    Order:
      type: object
      properties:
        id:
          type: integer
        customer_id:
          type: integer
        status:
          type: string
          enum: [pending, processing, completed, cancelled]
        total_amount:
          type: number
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateOrderRequest:
      type: object
      required:
        - customer_id
        - total_amount
      properties:
        customer_id:
          type: integer
        total_amount:
          type: number
    UpdateOrderRequest:
      type: object
      properties:
        status:
          type: string
          enum: [pending, processing, completed, cancelled]
        total_amount:
          type: number

ログイン後にコピー

この仕様は、注文に対する基本的な CRUD 操作を定義します。

3.3.2 APIコードの生成

oapi-codegen をインストールします:

go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@latest

ログイン後にコピー

Generate the server code:

oapi-codegen -package api -generate types,server,spec api/openapi.yaml > internal/api/api.gen.go

ログイン後にコピー

This command generates the Go code for our API, including types, server interfaces, and the OpenAPI specification.

3.3.3 Implement the API Handler

Create a new file internal/api/handler.go:

package api

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

type Handler struct {
    // We'll add dependencies here later
}

func NewHandler() *Handler {
    return &Handler{}
}

func (h *Handler) RegisterRoutes(r *gin.Engine) {
    RegisterHandlers(r, h)
}

// Implement the ServerInterface methods

func (h *Handler) GetOrders(c *gin.Context) {
    // TODO: Implement
    c.JSON(http.StatusOK, []Order{})
}

func (h *Handler) CreateOrder(c *gin.Context) {
    var req CreateOrderRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // TODO: Implement order creation logic
    order := Order{
        Id: 1,
        CustomerId: req.CustomerId,
        Status: "pending",
        TotalAmount: req.TotalAmount,
    }

    c.JSON(http.StatusCreated, order)
}

func (h *Handler) GetOrder(c *gin.Context, id int) {
    // TODO: Implement
    c.JSON(http.StatusOK, Order{Id: id})
}

func (h *Handler) UpdateOrder(c *gin.Context, id int) {
    var req UpdateOrderRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // TODO: Implement order update logic
    order := Order{
        Id: id,
        Status: *req.Status,
    }

    c.JSON(http.StatusOK, order)
}

func (h *Handler) DeleteOrder(c *gin.Context, id int) {
    // TODO: Implement
    c.Status(http.StatusNoContent)
}

ログイン後にコピー

This implementation provides a basic structure for our API handlers. We’ll flesh out the actual logic when we integrate with the database and Temporal workflows.

3.4 Setting Up the Postgres Database

3.4.1 Create a docker-compose file

Create a docker-compose.yml file in the project root:

version: '3.8'

services:
  postgres:
    image: postgres:13
    environment:
      POSTGRES_USER: orderuser
      POSTGRES_PASSWORD: orderpass
      POSTGRES_DB: orderdb
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

ログイン後にコピー

This sets up a Postgres container for our local development environment.

3.4.2 Implement Database Migrations

Install golang-migrate:

go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

ログイン後にコピー

Create our first migration:

migrate create -ext sql -dir migrations -seq create_orders_table

ログイン後にコピー

Edit the migrations/000001_create_orders_table.up.sql file:

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    status VARCHAR(20) NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status);

ログイン後にコピー

Edit the migrations/000001_create_orders_table.down.sql file:

DROP TABLE IF EXISTS orders;

ログイン後にコピー

3.4.3 Run Migrations

Add a new target to our Makefile:

migrate-up:
    @echo "Running migrations..."
    migrate -path migrations -database "postgresql://orderuser:orderpass@localhost:5432/orderdb?sslmode=disable" up

migrate-down:
    @echo "Reverting migrations..."
    migrate -path migrations -database "postgresql://orderuser:orderpass@localhost:5432/orderdb?sslmode=disable" down

ログイン後にコピー

Now we can run migrations with:

make migrate-up

ログイン後にコピー

3.5 Implementing Database Operations with sqlc

3.5.1 Install sqlc

go install github.com/kyleconroy/sqlc/cmd/sqlc@latest

ログイン後にコピー

3.5.2 Configure sqlc

Create a sqlc.yaml file in the project root:

version: "2"
sql:
  - engine: "postgresql"
    queries: "internal/db/queries.sql"
    schema: "migrations"
    gen:
      go:
        package: "db"
        out: "internal/db"
        emit_json_tags: true
        emit_prepared_queries: false
        emit_interface: true
        emit_exact_table_names: false

ログイン後にコピー

3.5.3 Write SQL Queries

Create a file internal/db/queries.sql:

-- name: GetOrder :one
SELECT * FROM orders
WHERE id = $1 LIMIT 1;

-- name: ListOrders :many
SELECT * FROM orders
ORDER BY id;

-- name: CreateOrder :one
INSERT INTO orders (
  customer_id, status, total_amount
) VALUES (
  $1, $2, $3
)
RETURNING *;

-- name: UpdateOrder :one
UPDATE orders
SET status = $2, total_amount = $3, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *;

-- name: DeleteOrder :exec
DELETE FROM orders
WHERE id = $1;

ログイン後にコピー

3.5.4 Generate Go Code

Add a new target to our Makefile:

generate-sqlc:
    @echo "Generating sqlc code..."
    sqlc generate

ログイン後にコピー

Run the code generation:

make generate-sqlc

ログイン後にコピー

This will generate Go code for interacting with our database in the internal/db directory.

3.6 Integrating Temporal

3.6.1 Set Up Temporal Server

Add Temporal to our docker-compose.yml:

  temporal:
    image: temporalio/auto-setup:1.13.0
    ports:
      - "7233:7233"
    environment:
      - DB=postgresql
      - DB_PORT=5432
      - POSTGRES_USER=orderuser
      - POSTGRES_PWD=orderpass
      - POSTGRES_SEEDS=postgres
    depends_on:
      - postgres

  temporal-admin-tools:
    image: temporalio/admin-tools:1.13.0
    depends_on:
      - temporal

ログイン後にコピー

3.6.2 Implement a Basic Workflow

Create a file internal/workflow/order_workflow.go:

package workflow

import (
    "time"

    "go.temporal.io/sdk/workflow"
    "github.com/yourusername/order-processing-system/internal/db"
)

func OrderWorkflow(ctx workflow.Context, order db.Order) error {
    logger := workflow.GetLogger(ctx)
    logger.Info("OrderWorkflow started", "OrderID", order.ID)

    // Simulate order processing
    err := workflow.Sleep(ctx, 5*time.Second)
    if err != nil {
        return err
    }

    // Update order status
    err = workflow.ExecuteActivity(ctx, UpdateOrderStatus, workflow.ActivityOptions{
        StartToCloseTimeout: time.Minute,
    }, order.ID, "completed").Get(ctx, nil)
    if err != nil {
        return err
    }

    logger.Info("OrderWorkflow completed", "OrderID", order.ID)
    return nil
}

func UpdateOrderStatus(ctx workflow.Context, orderID int64, status string) error {
    // TODO: Implement database update
    return nil
}

ログイン後にコピー

This basic workflow simulates order processing by waiting for 5 seconds and then updating the order status to “completed”.

3.6.3 Integrate Workflow with API

Update the internal/api/handler.go file to include Temporal client and start the workflow:

package api

import (
    "context"
    "net/http"

    "github.com/gin-gonic/gin"
    "go.temporal.io/sdk/client"
    "github.com/yourusername/order-processing-system/internal/db"
    "github.com/yourusername/order-processing-system/internal/workflow"
)

type Handler struct {
    queries *db.Queries
    temporalClient client.Client
}

func NewHandler(queries *db.Queries, temporalClient client.Client) *Handler {
    return &Handler{
        queries: queries,
        temporalClient: temporalClient,
    }
}

// ... (previous handler methods)

func (h *Handler) CreateOrder(c *gin.Context) {
    var req CreateOrderRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    order, err := h.queries.CreateOrder(c, db.CreateOrderParams{
        CustomerID: req.CustomerId,
        Status: "pending",
        TotalAmount: req.TotalAmount,
    })
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    // Start Temporal workflow
    workflowOptions := client.StartWorkflowOptions{
        ID: "order-" + order.ID,
        TaskQueue: "order-processing",
    }
    _, err = h.temporalClient.ExecuteWorkflow(context.Background(), workflowOptions, workflow.OrderWorkflow, order)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start workflow"})
        return
    }

    c.JSON(http.StatusCreated, order)
}

// ... (implement other handler methods)

ログイン後にコピー

3.7 Implementing Dependency Injection

Create a new file internal/service/service.go:

package service

import (
    "database/sql"

    "github.com/yourusername/order-processing-system/internal/api"
    "github.com/yourusername/order-processing-system/internal/db"
    "go.temporal.io/sdk/client"
)

type Service struct {
    DB *sql.DB
    Queries *db.Queries
    TemporalClient client.Client
    Handler *api.Handler
}

func NewService() (*Service, error) {
    // Initialize database connection
    db, err := sql.Open("postgres", "postgresql://orderuser:orderpass@localhost:5432/orderdb?sslmode=disable")
    if err != nil {
        return nil, err
    }

    // Initialize Temporal client
    temporalClient, err := client.NewClient(client.Options{
        HostPort: "localhost:7233",
    })
    if err != nil {
        return nil, err
    }

    // Initialize queries
    queries := db.New(db)

    // Initialize handler
    handler := api.NewHandler(queries, temporalClient)

    return &Service{
        DB: db,
        Queries: queries,
        TemporalClient: temporalClient,
        Handler: handler,
    }, nil
}

func (s *Service) Close() {
    s.DB.Close()
    s.TemporalClient.Close()
}

ログイン後にコピー

3.8 Update Main Function

Update the cmd/api/main.go file:

package main

import (
    "log"

    "github.com/gin-gonic/gin"
    _ "github.com/lib/pq"
    "github.com/yourusername/order-processing-system/internal/service"
)

func main() {
    svc, err := service.NewService()
    if err != nil {
        log.Fatalf("Failed to initialize service: %v", err)
    }
    defer svc.Close()

    r := gin.Default()
    svc.Handler.RegisterRoutes(r)

    if err := r.Run(":8080"); err != nil {
        log.Fatalf("Failed to run server: %v", err)
    }
}

ログイン後にコピー

3.9 Dockerize the Application

Create a Dockerfile in the project root:

# Build stage
FROM golang:1.17-alpine AS build

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /order-processing-system ./cmd/api

# Run stage
FROM alpine:latest

WORKDIR /

COPY --from=build /order-processing-system /order-processing-system

EXPOSE 8080

ENTRYPOINT ["/order-processing-system"]

ログイン後にコピー

Update the docker-compose.yml file to include our application:

version: '3.8'

services:
  postgres:
    # ... (previous postgres configuration)

  temporal:
    # ... (previous temporal configuration)

  temporal-admin-tools:
    # ... (previous temporal-admin-tools configuration)

  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - postgres
      - temporal
    environment:
      - DB_HOST=postgres
      - DB_USER=orderuser
      - DB_PASSWORD=orderpass
      - DB_NAME=orderdb
      - TEMPORAL_HOST=temporal:7233

ログイン後にコピー

4. Code Examples with Detailed Comments

Throughout the implementation guide, we’ve provided code snippets with explanations. Here’s a more detailed look at a key part of our system: the Order Workflow.

package workflow

import (
    "time"

    "go.temporal.io/sdk/workflow"
    "github.com/yourusername/order-processing-system/internal/db"
)

// OrderWorkflow defines the workflow for processing an order
func OrderWorkflow(ctx workflow.Context, order db.Order) error {
    logger := workflow.GetLogger(ctx)
    logger.Info("OrderWorkflow started", "OrderID", order.ID)

    // Simulate order processing
    // In a real-world scenario, this could involve multiple activities such as
    // inventory check, payment processing, shipping arrangement, etc.
    err := workflow.Sleep(ctx, 5*time.Second)
    if err != nil {
        return err
    }

    // Update order status
    // We use ExecuteActivity to run the status update as an activity
    // This allows for automatic retries and error handling
    err = workflow.ExecuteActivity(ctx, UpdateOrderStatus, workflow.ActivityOptions{
        StartToCloseTimeout: time.Minute,
    }, order.ID, "completed").Get(ctx, nil)
    if err != nil {
        return err
    }

    logger.Info("OrderWorkflow completed", "OrderID", order.ID)
    return nil
}

// UpdateOrderStatus is an activity that updates the status of an order
func UpdateOrderStatus(ctx workflow.Context, orderID int64, status string) error {
    // TODO: Implement database update
    // In a real implementation, this would use the db.Queries to update the order status
    return nil
}

ログイン後にコピー

This workflow demonstrates several key concepts:

  1. Use of Temporal’s workflow.Context for managing the workflow lifecycle.
  2. Logging within workflows using workflow.GetLogger.
  3. Simulating long-running processes with workflow.Sleep.
  4. Executing activities within a workflow using workflow.ExecuteActivity.
  5. Handling errors and returning them to be managed by Temporal.

5. Testing and Validation

For this initial setup, we’ll focus on manual testing to ensure our system is working as expected. In future posts, we’ll dive into unit testing, integration testing, and end-to-end testing strategies.

To manually test our system:

  1. Start the services:
docker-compose up

ログイン後にコピー
  1. Use a tool like cURL or Postman to send requests to our API:

  2. Check the logs to ensure the Temporal workflow is being triggered and completed successfully.

6. Challenges and Considerations

While setting up this initial version of our order processing system, we encountered several challenges and considerations:

  1. Database Schema Design : Designing a flexible yet efficient schema for orders is crucial. We kept it simple for now, but in a real-world scenario, we might need to consider additional tables for order items, customer information, etc.

  2. Error Handling : Our current implementation has basic error handling. In a production system, we’d need more robust error handling and logging, especially for the Temporal workflows.

  3. Configuration Management : We hardcoded configuration values for simplicity. In a real-world scenario, we’d use environment variables or a configuration management system.

  4. 보안 : 현재 설정에는 인증이나 승인이 포함되어 있지 않습니다. 생산 시스템에서는 적절한 보안 조치를 구현해야 합니다.

  5. 확장성 : Temporal은 워크플로 확장성에 도움이 되지만 트래픽이 많은 시스템에서는 데이터베이스 확장성과 API 성능을 고려해야 합니다.

  6. 모니터링 및 관찰 가능성 : 아직 모니터링 또는 관찰 도구를 구현하지 않았습니다. 프로덕션 시스템에서 이는 애플리케이션을 유지 관리하고 문제를 해결하는 데 매우 중요합니다.

7. 다음 단계 및 2부 미리보기

이 시리즈의 첫 번째 부분에서는 주문 처리 시스템의 기반을 마련했습니다. 우리는 기본 CRUD API, 데이터베이스 통합 및 간단한 임시 워크플로우를 갖추고 있습니다.

다음 부분에서는 임시 워크플로와 활동에 대해 더 자세히 알아보겠습니다. 우리는 다음을 탐구할 것입니다:

  1. 더 복잡한 주문 처리 로직 구현
  2. Temporal을 사용하여 장기 실행 워크플로 처리
  3. 워크플로에서 재시도 논리 및 오류 처리 구현
  4. 안전한 업데이트를 위한 버전 관리 워크플로
  5. 분산 트랜잭션을 위한 사가 패턴 구현
  6. 임시 워크플로우 모니터링 및 관찰

또한 보다 현실적인 주문 처리 로직으로 API를 구체화하고 시스템이 복잡해짐에 따라 깔끔하고 유지 관리 가능한 코드를 유지하기 위한 패턴을 탐색할 것입니다.

주문 처리 시스템을 한 단계 더 발전시킬 2부도 기대해주세요!


도움이 필요하신가요?

어려운 문제에 직면했거나 새로운 아이디어나 프로젝트에 대한 외부 관점이 필요합니까? 내가 도와줄 수 있어요! 대규모 투자를 하기 전에 기술 개념 증명을 구축하려는 경우나 어려운 문제에 대한 지침이 필요한 경우 제가 도와드리겠습니다.

제공되는 서비스:

  • 문제 해결: 혁신적인 솔루션으로 복잡한 문제를 해결합니다.
  • 상담: 프로젝트에 대한 전문가의 조언과 신선한 관점을 제공합니다.
  • 개념 증명: 아이디어를 테스트하고 검증하기 위한 예비 모델 개발

저와 함께 일하는 데 관심이 있으시면 hangaikevin@gmail.com으로 이메일을 보내주세요.

당신의 도전을 기회로 바꾸세요!

以上が注文処理システムの実装: パート 基盤のセットアップの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

ソース:dev.to
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート