Maison > développement back-end > Golang > le corps du texte

Implémentation d'un système de traitement des commandes : partie Mise en place de la fondation

王林
Libérer: 2024-09-05 22:31:33
original
1012 Les gens l'ont consulté

Implementing an Order Processing System: Part  Setting Up the Foundation

1. Introduction et objectifs

Bienvenue dans la première partie de notre série complète de blogs sur la mise en œuvre d'un système sophistiqué de traitement des commandes utilisant Temporal pour l'orchestration des microservices. Dans cette série, nous explorerons les subtilités de la création d’un système robuste, évolutif et maintenable, capable de gérer des flux de travail complexes et de longue durée.

Notre voyage commence par la mise en place des bases de notre projet. À la fin de cet article, vous disposerez d'une API CRUD REST entièrement fonctionnelle implémentée dans Golang, intégrée à Temporal pour l'orchestration des flux de travail et soutenue par une base de données Postgres. Nous utiliserons des outils modernes et les meilleures pratiques pour garantir que notre base de code est propre, efficace et facile à entretenir.

Objectifs de ce post :

  1. Mettre en place un projet bien structuré à l'aide des modules Go
  2. Implémenter une API CRUD de base en utilisant Gin et oapi-codegen
  3. Configurer une base de données Postgres et mettre en œuvre des migrations
  4. Créez un flux de travail temporel simple avec interaction avec la base de données
  5. Implémenter l'injection de dépendances pour une meilleure testabilité et maintenabilité
  6. Containeriser notre application à l'aide de Docker
  7. Fournir un environnement de développement local complet à l'aide de docker-compose

Plongeons-nous et commençons à créer notre système de traitement des commandes !

2. Contexte théorique et concepts

Avant de commencer la mise en œuvre, passons brièvement en revue les technologies et concepts clés que nous utiliserons :

Golang

Go est un langage compilé à typage statique connu pour sa simplicité, son efficacité et son excellente prise en charge de la programmation simultanée. Sa bibliothèque standard et son écosystème robuste en font un excellent choix pour créer des microservices.

Temporel

Temporal est une plateforme d'orchestration de microservices qui simplifie le développement d'applications distribuées. Il nous permet d'écrire des flux de travail complexes et de longue durée sous forme de code procédural simple, gérant automatiquement les échecs et les tentatives.

Cadre Web Gin

Gin est un framework Web HTTP hautes performances écrit en Go. Il fournit une API de type martini avec de bien meilleures performances et une utilisation moindre de la mémoire.

OpenAPI et oapi-codegen

OpenAPI (anciennement connu sous le nom de Swagger) est une spécification de fichiers d'interface lisibles par machine permettant de décrire, de produire, de consommer et de visualiser des services Web RESTful. oapi-codegen est un outil qui génère du code Go à partir des spécifications OpenAPI 3.0, nous permettant de définir d'abord notre contrat API et de générer des stubs de serveur et du code client.

sqlc

sqlc génère du code Go de type sécurisé à partir de SQL. Il nous permet d'écrire des requêtes SQL simples et de générer du code Go entièrement sécurisé pour interagir avec notre base de données, réduisant ainsi le risque d'erreurs d'exécution et améliorant la maintenabilité.

PostgreSQL

PostgreSQL est un système de base de données objet-relationnel open source puissant, connu pour sa fiabilité, la robustesse de ses fonctionnalités et ses performances.

Docker et docker-compose

Docker nous permet de regrouper notre application et ses dépendances dans des conteneurs, garantissant ainsi la cohérence entre différents environnements. docker-compose est un outil permettant de définir et d'exécuter des applications Docker multi-conteneurs, que nous utiliserons pour configurer notre environnement de développement local.

Maintenant que nous avons couvert les bases, commençons à mettre en œuvre notre système.

3. Guide de mise en œuvre étape par étape

3.1 Mise en place de la structure du projet

Tout d'abord, créons notre répertoire de projet et mettons en place la structure de base :

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

Copier après la connexion

Cette structure suit la disposition standard du projet Go :

  • cmd/api : Contient le point d'entrée principal de l'application
  • interne : héberge les packages spécifiques à ce projet et non destinés à être importés par d'autres projets
  • migrations : stocke les fichiers de migration de base de données
  • pkg : contient des packages pouvant être importés par d'autres projets
  • scripts : contient des scripts utilitaires pour le développement et le déploiement

3.2 Création du Makefile

Créons un Makefile pour simplifier les tâches courantes :

touch Makefile

Copier après la connexion

Ajoutez le contenu suivant au 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

Copier après la connexion

Ce Makefile fournit des cibles pour générer du code, créer l'application, l'exécuter, exécuter des tests et nettoyer les artefacts de construction.

3.3 Implémentation de l'API CRUD de base

3.3.1 Définir la spécification OpenAPI

Créez un fichier nommé api/openapi.yaml et définissez notre spécification 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

Copier après la connexion

Cette spécification définit nos opérations CRUD de base pour les commandes.

3.3.2 Générer du code API

Installer oapi-codegen :

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

Copier après la connexion

Generate the server code:

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

Copier après la connexion

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)
}

Copier après la connexion

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:

Copier après la connexion

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

Copier après la connexion

Create our first migration:

migrate create -ext sql -dir migrations -seq create_orders_table

Copier après la connexion

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);

Copier après la connexion

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

DROP TABLE IF EXISTS orders;

Copier après la connexion

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

Copier après la connexion

Now we can run migrations with:

make migrate-up

Copier après la connexion

3.5 Implementing Database Operations with sqlc

3.5.1 Install sqlc

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

Copier après la connexion

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

Copier après la connexion

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;

Copier après la connexion

3.5.4 Generate Go Code

Add a new target to our Makefile:

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

Copier après la connexion

Run the code generation:

make generate-sqlc

Copier après la connexion

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

Copier après la connexion

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
}

Copier après la connexion

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)

Copier après la connexion

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()
}

Copier après la connexion

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)
    }
}

Copier après la connexion

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"]

Copier après la connexion

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

Copier après la connexion

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
}

Copier après la connexion

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

Copier après la connexion
  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 部分,我们将把我们的订单处理系统提升到一个新的水平!


需要帮助吗?

您是否面临着具有挑战性的问题,或者需要外部视角来看待新想法或项目?我可以帮忙!无论您是想在进行更大投资之前建立技术概念验证,还是需要解决困难问题的指导,我都会为您提供帮助。

提供的服务:

  • 解决问题:通过创新的解决方案解决复杂问题。
  • 咨询:为您的项目提供专家建议和新观点。
  • 概念验证:开发初步模型来测试和验证您的想法。

如果您有兴趣与我合作,请通过电子邮件与我联系:hungaikevin@gmail.com。

让我们将挑战转化为机遇!

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

source:dev.to
Déclaration de ce site Web
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn
Tutoriels populaires
Plus>
Derniers téléchargements
Plus>
effets Web
Code source du site Web
Matériel du site Web
Modèle frontal
À propos de nous Clause de non-responsabilité Sitemap
Site Web PHP chinois:Formation PHP en ligne sur le bien-être public,Aidez les apprenants PHP à grandir rapidement!