04 thg 5, 2025·7 phút đọc

Mẫu repository CRUD với generics trong Go cho một lớp dữ liệu gọn gàng

Học một mẫu repository CRUD thực tế với generics trong Go để tái dùng logic list/get/create/update/delete với ràng buộc dễ đọc, không dùng reflection và code rõ ràng.

Mẫu repository CRUD với generics trong Go cho một lớp dữ liệu gọn gàng

Tại sao các repository CRUD hay trở nên lộn xộn trong Go

Các repository CRUD ban đầu đơn giản. Bạn viết GetUser, rồi ListUsers, rồi tương tự cho Orders, rồi Invoices. Sau vài thực thể, lớp dữ liệu biến thành một đống bản sao gần giống nhau, nơi những khác biệt nhỏ dễ bị bỏ sót.

Điều lặp lại thường không phải là SQL. Đó là luồng xung quanh: chạy truy vấn, scan hàng, xử lý “không tìm thấy”, ánh xạ lỗi từ DB, áp mặc định phân trang, và chuyển input về đúng kiểu.

Các điểm nóng quen thuộc là: code Scan bị nhân đôi, mẫu context.Context và transaction lặp lại, boilerplate xử lý LIMIT/OFFSET (đôi khi kèm tổng số), cùng việc kiểm tra “0 rows nghĩa là không tìm thấy”, và các biến thể copy-paste của INSERT ... RETURNING id.

Khi việc lặp lại trở nên đau, nhiều nhóm tìm đến reflection. Nó hứa “viết một lần”: lấy bất kỳ struct nào và điền từ cột tại runtime. Giá phải trả lộ ra sau đó. Code dùng nhiều reflection khó đọc hơn, hỗ trợ IDE kém đi, và lỗi chuyển từ thời gian biên dịch sang thời gian chạy. Những thay đổi nhỏ, như đổi tên trường hoặc thêm cột nullable, trở thành bất ngờ chỉ hiện ra trong test hoặc production.

Tái sử dụng an toàn về kiểu nghĩa là chia sẻ luồng CRUD mà không đánh đổi các tiện ích hàng ngày của Go: chữ ký rõ ràng, kiểm tra kiểu bởi compiler, và autocomplete hữu ích. Với generics, bạn có thể tái dùng các thao tác như Get[T]List[T] trong khi vẫn yêu cầu mỗi thực thể cung cấp những phần không thể suy ra, ví dụ cách scan một hàng thành T.

Pattern này cố ý tập trung vào lớp truy cập dữ liệu. Nó giữ SQL và ánh xạ nhất quán và… tẻ nhạt. Nó không cố gắng mô hình hoá domain, áp luật nghiệp vụ, hay thay thế logic ở cấp service.

Mục tiêu thiết kế (và những gì không cố gắng giải quyết)

Một pattern repository tốt làm cho truy cập DB hàng ngày trở nên dễ đoán. Bạn nên có thể đọc một repository và nhanh biết nó làm gì, chạy SQL nào và trả về lỗi gì.

Mục tiêu đơn giản:

  • An toàn kiểu đầu tới cuối (IDs, thực thể và kết quả không phải là any)
  • Ràng buộc giải thích ý định mà không lạm dụng thủ thuật kiểu
  • Ít boilerplate hơn nhưng không che giấu hành vi quan trọng
  • Hành vi nhất quán giữa List/Get/Create/Update/Delete

Những thứ không phải mục tiêu cũng quan trọng. Đây không phải ORM. Nó không nên đoán mapping trường, tự động join bảng hay âm thầm thay đổi truy vấn. “Magic mapping” sẽ đẩy bạn trở lại với reflection, tag và các edge case.

Hãy giả định một workflow SQL bình thường: SQL rõ ràng (hoặc query builder mỏng), ranh giới transaction rõ ràng, và lỗi có thể lý giải. Khi có lỗi, lỗi nên cho biết “not found”, “conflict/constraint violation”, hoặc “DB unavailable”, chứ không phải một “repository error” mơ hồ.

Quyết định then chốt là cái gì nên generic và cái gì giữ cho từng thực thể:

  • Generic: luồng (chạy query, scan, trả giá trị typed, dịch lỗi phổ thông).
  • Theo thực thể: ý nghĩa (tên bảng, cột chọn, join, và chuỗi SQL).

Cố gắng ép mọi thực thể vào một hệ thống lọc chung thường làm code khó đọc hơn là viết hai truy vấn rõ ràng.

Chọn ràng buộc cho thực thể và ID

Phần lớn code CRUD lặp lại vì mọi bảng có các bước cơ bản giống nhau, nhưng mọi thực thể có các trường riêng. Với generics, mánh khóe là chia sẻ một hình dạng nhỏ và để mọi thứ khác tự do.

Bắt đầu bằng cách quyết định repository thực sự cần biết gì về một thực thể. Với nhiều nhóm, phần phổ quát duy nhất là ID. Timestamps có thể hữu ích, nhưng chúng không phải lúc nào cũng có, và ép chúng vào mọi kiểu thường làm mô hình cảm thấy giả.

Chọn kiểu ID phù hợp

Kiểu ID nên khớp cách bạn nhận diện hàng trong DB. Một số dự án dùng int64, số khác dùng UUID string. Nếu bạn muốn một cách tiếp cận chung, hãy làm ID thành generic. Nếu toàn bộ codebase của bạn dùng một kiểu ID, cố định nó có thể rút ngắn chữ ký.

Ràng buộc mặc định tốt cho ID là comparable, vì bạn sẽ so sánh ID, dùng làm key map và truyền chúng khắp nơi.

type ID interface {
	comparable
}

type Entity[IDT ID] interface {
	GetID() IDT
	SetID(IDT)
}

Giữ ràng buộc thực thể ở mức tối thiểu

Tránh yêu cầu các trường bằng cách nhúng struct hoặc mẹo type-set như ~struct{...}. Chúng trông mạnh nhưng ghép chặt kiểu domain của bạn với pattern repository.

Thay vào đó, chỉ yêu cầu những gì luồng CRUD chung cần:

  • Lấy và đặt ID (để Create có thể trả về ID, và Update/Delete có thể nhắm tới nó)

Nếu sau này bạn thêm tính năng như soft deletes hoặc optimistic locking, thêm các interface nhỏ opt-in (ví dụ GetVersion/SetVersion) và chỉ dùng chúng khi cần. Các interface nhỏ có xu hướng bền khi có thay đổi.

Giao diện repository generic mà vẫn dễ đọc

Một giao diện repository nên mô tả những gì app cần, không phải những gì DB làm. Nếu giao diện trông giống SQL, nó sẽ làm rò rỉ chi tiết khắp nơi.

Giữ số phương thức nhỏ và dễ đoán. Đặt context.Context lên trước, sau đó là input chính (ID hoặc dữ liệu), rồi các tuỳ chọn đóng gói trong struct.

type Repository[T any, ID comparable, CreateIn any, UpdateIn any, ListQ any] interface {
	Get(ctx context.Context, id ID) (T, error)
	List(ctx context.Context, q ListQ) ([]T, error)
	Create(ctx context.Context, in CreateIn) (T, error)
	Update(ctx context.Context, id ID, in UpdateIn) (T, error)
	Delete(ctx context.Context, id ID) error
}

Với List, tránh ép một kiểu filter toàn cục. Filters là phần khác nhau nhất giữa các thực thể. Cách thực tế là dùng các kiểu query riêng theo thực thể cộng thêm một hình dạng phân trang nhỏ bạn có thể nhúng.

type Page struct {
	Limit  int
	Offset int
}

Xử lý lỗi là nơi các repository thường ồn ào. Quyết định trước những lỗi mà caller được phép phân nhánh. Một tập đơn giản thường đủ:

  • ErrNotFound khi ID không tồn tại
  • ErrConflict cho vi phạm unique hoặc xung đột version
  • ErrValidation khi input không hợp lệ (nếu repo thực hiện validate)

Mọi thứ khác có thể là lỗi thấp cấp được bọc lại (DB/network). Với hợp đồng đó, code service có thể xử lý “not found” hoặc “conflict” mà không cần quan tâm lưu trữ là PostgreSQL hay thứ khác sau này.

Làm sao tránh reflection trong khi vẫn tái dùng luồng

Turn repos into real APIs
Model your data and generate a Go backend without repeating CRUD by hand.
Build Backend

Reflection thường len lỏi khi bạn muốn một đoạn code “điền bất kỳ struct nào”. Điều đó che giấu lỗi cho đến thời gian chạy và làm quy tắc mù mờ.

Cách sạch hơn là chỉ tái dùng những phần tẻ nhạt: chạy truy vấn, lặp rows, kiểm tra số hàng bị ảnh hưởng, và bọc lỗi nhất quán. Giữ việc ánh xạ vào/ra structs rõ ràng.

Tách trách nhiệm: SQL, mapping, luồng chung

Một chia tách thực tế như sau:

  • Theo thực thể: giữ các chuỗi SQL và thứ tự tham số ở một chỗ
  • Theo thực thể: viết các hàm mapping nhỏ scan rows thành struct cụ thể
  • Generic: cung cấp luồng chung thực hiện query và gọi mapper

Bằng vậy, generics giảm lặp mà không che giấu DB đang làm gì.

Đây là một trừu tượng nhỏ cho phép bạn truyền *sql.DB hoặc *sql.Tx mà phần còn lại không cần quan tâm:

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

Generics nên (và không nên) làm gì

Lớp generic không nên cố “hiểu” struct của bạn. Thay vào đó, nó nên nhận các hàm rõ ràng bạn cung cấp, ví dụ:

  • một binder biến input thành tham số truy vấn
  • một scanner đọc cột thành thực thể

Ví dụ, một repository Customer có thể lưu SQL dưới dạng hằng (selectByID, insert, update) và implement scanCustomer(rows) một lần. Một List generic có thể xử lý vòng lặp, context và bọc lỗi, còn scanCustomer giữ việc ánh xạ an toàn kiểu và rõ ràng.

Nếu bạn thêm một cột, cập nhật SQL và scanner. Compiler sẽ giúp bạn tìm chỗ bị ảnh hưởng.

Từng bước: triển khai pattern

Mục tiêu là một luồng tái dùng cho List/Get/Create/Update/Delete trong khi giữ mỗi repository trung thực về SQL và mapping.

1) Định nghĩa các kiểu lõi

Bắt đầu với ít ràng buộc nhất có thể. Chọn kiểu ID phù hợp và giao diện repository dễ đoán.

type ID interface{ ~int64 | ~string }

type Repo[E any, K ID] interface {
	Get(ctx context.Context, id K) (E, error)
	List(ctx context.Context, limit, offset int) ([]E, error)
	Create(ctx context.Context, e *E) error
	Update(ctx context.Context, e *E) error
	Delete(ctx context.Context, id K) error
}

2) Thêm một executor cho DB và transaction

Đừng buộc code generic vào *sql.DB hoặc *sql.Tx. Phụ thuộc vào một interface executor nhỏ khớp với những gì bạn gọi (QueryContext, ExecContext, QueryRowContext). Khi đó service có thể truyền DB hoặc transaction mà không thay đổi code repository.

3) Xây base generic với luồng chia sẻ

Tạo một baseRepo[E,K] lưu executor và vài trường hàm. Base xử lý các phần tẻ nhạt: gọi query, map “not found”, kiểm tra affected rows và trả lỗi nhất quán.

4) Triển khai các phần theo thực thể

Mỗi repository thực thể cung cấp những gì không thể generic:

  • SQL cho list/get/create/update/delete
  • một hàm scan(row) chuyển row thành E
  • một hàm bind(...) trả về args cho query

5) Nối các repo cụ thể và dùng chúng từ service

Xây NewCustomerRepo(exec Executor) *CustomerRepo nhúng hoặc bọc baseRepo. Lớp service phụ thuộc vào giao diện Repo[E,K] và quyết định khi nào bắt transaction; repository chỉ dùng executor nó được cấp.

Xử lý List/Get/Create/Update/Delete mà không có bất ngờ

Keep inputs consistent
Separate create and update inputs cleanly so handlers stay predictable as schemas evolve.
Try Now

Repository generic chỉ hữu ích khi mọi phương thức hành xử giống nhau ở mọi nơi. Phần lớn khó chịu đến từ những khác biệt nhỏ: repo này order by created_at, repo kia order by id; repo này trả nil, nil cho hàng thiếu, repo kia trả lỗi.

List: phân trang và sắp xếp ổn định

Chọn một kiểu phân trang và áp dụng nhất quán. Offset pagination (limit/offset) đơn giản và phù hợp cho giao diện quản trị. Cursor pagination tốt hơn cho endless scroll nhưng cần khóa sắp xếp ổn định.

Dù chọn gì, hãy làm việc sắp xếp rõ ràng và ổn định. Sắp xếp theo cột duy nhất (thường là khóa chính) ngăn mục nhảy giữa các trang khi có hàng mới xuất hiện.

Get: tín hiệu “không tìm thấy” rõ ràng

Get(ctx, id) nên trả thực thể typed và một tín hiệu thiếu bản ghi rõ ràng, thường là lỗi sentinel như ErrNotFound. Tránh trả giá trị zero với lỗi nil. Người gọi không thể phân biệt “không tồn tại” và “các trường rỗng”.

Hãy thành thói quen này sớm: kiểu dùng cho dữ liệu, lỗi dùng cho trạng thái.

Trước khi triển khai phương thức, quyết định một vài điều và giữ nhất quán:

  • Create: bạn chấp nhận kiểu input (không có ID, không có timestamp) hay toàn bộ thực thể? Nhiều đội thích Create(ctx, in CreateX) để ngăn người gọi đặt các trường do server quản lý.
  • Update: là thay thế toàn bộ hay patch? Nếu là patch, đừng dùng struct thường nơi giá trị zero mơ hồ. Dùng con trỏ, kiểu nullable, hoặc field mask rõ ràng.
  • Delete: xóa cứng hay xóa mềm? Nếu soft delete, quyết định Get có ẩn bản ghi bị xóa không.

Cũng quyết định phương thức ghi trả gì. Các lựa chọn ít bất ngờ là trả thực thể đã update (sau mặc định DB) hoặc chỉ trả ID và ErrNotFound khi không có thay đổi.

Chiến lược testing cho phần generic và phần theo thực thể

Prototype admin tools faster
Build internal tools with UI, auth, and business logic without writing endless repository code.
Create Tool

Cách tiếp cận này chỉ có giá trị nếu dễ dàng để tin tưởng. Tách test theo cùng ranh giới như code: test helper chung một lần, rồi test SQL và scan của từng thực thể riêng.

Xử lý các phần chung như các hàm thuần khi có thể: chuẩn hoá phân trang, ánh xạ sort keys tới cột cho phép, hoặc xây WHERE fragments. Chúng có thể được bao phủ bởi unit test nhanh.

Với list queries, test theo bảng (table-driven) hoạt động tốt vì các edge case là vấn đề chính. Bao phủ các trường hợp như filter rỗng, sort key không hợp lệ, limit 0, limit vượt max, offset âm, và ranh giới “trang kế” khi bạn fetch thêm một hàng.

Giữ test per-entity tập trung vào điều thực sự đặc thù: SQL mong đợi chạy và cách rows scan vào kiểu entity. Dùng SQL mock hoặc DB test nhẹ và đảm bảo scan logic xử lý nulls, cột tuỳ chọn và chuyển kiểu.

Nếu pattern hỗ trợ transaction, test commit/rollback với một fake executor nhỏ ghi lại các cuộc gọi và mô phỏng lỗi:

  • Begin trả về một executor scoped tx
  • khi lỗi, rollback được gọi đúng một lần
  • khi thành công, commit được gọi đúng một lần
  • nếu commit thất bại, lỗi được trả nguyên vẹn

Bạn cũng có thể thêm vài “contract tests” mà mọi repository phải pass: create rồi get trả về cùng dữ liệu, update thay đổi trường mong muốn, delete khiến get trả ErrNotFound, và list trả thứ tự ổn định với cùng input.

Những lỗi thường gặp và bẫy

Generics dễ khiến ta xây một repository cho mọi thứ. Truy cập dữ liệu đầy khác biệt nhỏ, và những khác biệt đó quan trọng.

Một vài bẫy thường gặp:

  • Tổng quát hóa quá mức đến khi mọi phương thức nhận một túi tuỳ chọn khổng lồ (joins, search, permissions, soft deletes, caching). Khi đó bạn đã dựng một ORM thứ hai.
  • Ràng buộc quá khôn ngoan. Nếu người đọc phải giải mã type sets để hiểu một thực thể cần gì, abstraction đó tốn hơn lợi.
  • Xử lý input như model DB. Khi Create và Update dùng cùng struct bạn scan từ rows, chi tiết DB rò rỉ vào handlers và tests, và thay đổi schema sẽ lan ra khắp app.
  • Hành vi im lặng trong List: sắp xếp không ổn định, mặc định không nhất quán, hoặc quy tắc paging khác nhau theo thực thể.
  • Xử lý not-found buộc caller phải parse chuỗi lỗi thay vì dùng errors.Is.

Một ví dụ cụ thể: ListCustomers trả khách hàng theo thứ tự khác nhau mỗi lần vì repository không đặt ORDER BY. Phân trang rồi bị trùng hoặc bỏ sót bản ghi giữa các yêu cầu. Hãy đặt sắp xếp rõ ràng (thậm chí chỉ bằng khóa chính) và giữ mặc định nhất quán.

Checklist nhanh trước khi áp dụng

Ship a clean data layer
Use visual models to keep data access consistent as requirements change.
Try AppMaster

Trước khi bạn triển khai repository generic vào mọi package, đảm bảo nó loại bỏ lặp mà không che giấu hành vi DB quan trọng.

Bắt đầu với tính nhất quán. Nếu một repo nhận context.Context và repo khác thì không, hoặc một trả (T, error) trong khi khác trả (*T, error), thì đau sẽ hiện khắp nơi: services, tests và mocks.

Đảm bảo mỗi thực thể vẫn có một nơi rõ ràng cho SQL của nó. Generics nên tái dùng luồng (scan, validate, map errors), không phải phân tán truy vấn khắp chuỗi string.

Một số kiểm tra ngăn phần lớn bất ngờ:

  • Một quy ước chữ ký duy nhất cho List/Get/Create/Update/Delete
  • Một quy tắc not-found nhất quán dùng cho mọi repo
  • Sắp xếp list ổn định đã được document và test
  • Cách sạch để chạy cùng code trên *sql.DB*sql.Tx (qua executor interface)
  • Ranh giới rõ ràng giữa code generic và quy tắc thực thể (validate và business checks nằm ngoài lớp generic)

Nếu bạn đang dựng công cụ nội bộ nhanh trong AppMaster và sau đó xuất hoặc mở rộng mã Go sinh ra, các kiểm tra này giúp giữ lớp dữ liệu dễ đoán và dễ test.

Ví dụ thực tế: xây repository Customer

Đây là hình dạng một Customer repository nhỏ giữ an toàn kiểu mà không quá rườm rà.

Bắt đầu với model lưu trữ. Giữ ID có kiểu mạnh để bạn không lẫn với ID khác:

type CustomerID int64

type Customer struct {
	ID     CustomerID
	Name   string
	Status string // "active", "blocked", "trial"...
}

Bây giờ tách "API chấp nhận gì" khỏi "bạn lưu gì". Đây là nơi Create và Update nên khác nhau.

type CreateCustomerInput struct {
	Name   string
	Status string
}

type UpdateCustomerInput struct {
	Name   *string
	Status *string
}

Base generic của bạn có thể xử lý luồng chia sẻ (thực thi SQL, scan, map lỗi), trong khi Customer repo nắm SQL và mapping riêng cho Customer. Với góc nhìn của service, giao diện vẫn sạch:

type CustomerRepo interface {
	Create(ctx context.Context, in CreateCustomerInput) (Customer, error)
	Update(ctx context.Context, id CustomerID, in UpdateCustomerInput) (Customer, error)
	Get(ctx context.Context, id CustomerID) (Customer, error)
	Delete(ctx context.Context, id CustomerID) error
	List(ctx context.Context, q CustomerListQuery) ([]Customer, int, error)
}

Với List, coi filters và pagination là một object yêu cầu hạng nhất. Nó giúp call sites dễ đọc và khó quên limit.

type CustomerListQuery struct {
	Status *string // filter
	Search *string // name contains
	Limit  int
	Offset int
}

Từ đây, pattern mở rộng tốt: sao chép cấu trúc cho thực thể tiếp theo, giữ input tách biệt khỏi model lưu, và giữ scan rõ ràng để thay đổi luôn hiển nhiên và có lợi từ compiler.

Câu hỏi thường gặp

What problem do generic CRUD repositories in Go actually solve?

Sử dụng generics để tái dùng luồng (thực thi truy vấn, vòng lặp scan, xử lý not-found, mặc định phân trang, ánh xạ lỗi), nhưng vẫn giữ SQL và việc ánh xạ hàng (row mapping) rõ ràng cho từng thực thể. Cách này giảm lặp lại mà không biến lớp truy cập dữ liệu thành “phép màu” ở thời gian chạy.

Why avoid reflection-based “scan any struct” CRUD helpers?

Reflection che giấu quy tắc ánh xạ và đẩy lỗi sang thời gian chạy. Bạn mất kiểm tra của trình biên dịch, hỗ trợ IDE kém đi, và những thay đổi nhỏ ở schema trở thành bất ngờ. Kết hợp generics với các hàm scanner rõ ràng giữ an toàn kiểu trong khi vẫn chia sẻ các phần lặp lại.

What’s a sensible constraint for an ID type?

Một lựa chọn hợp lý là comparable, vì ID thường được so sánh, dùng làm key trong map và truyền đi khắp nơi. Nếu hệ thống của bạn dùng nhiều kiểu ID (ví dụ int64 và UUID string), làm ID thành generic sẽ tránh ép một kiểu cho toàn bộ repo.

What should the entity constraint include (and not include)?

Giữ cho nó tối thiểu: thường chỉ những gì luồng CRUD chung cần, như GetID()SetID(). Tránh ép các trường chung bằng cách nhúng struct hoặc kỹ thuật type-set phức tạp, vì điều đó sẽ ghép chặt kiểu miền (domain types) với pattern repository và làm refactor khó khăn.

How do I support both *sql.DB and *sql.Tx cleanly?

Dùng một interface executor nhỏ (thường gọi là DBTX) chỉ gồm các phương thức bạn gọi, như QueryContext, QueryRowContext, và ExecContext. Khi đó code repository có thể chạy trên cả *sql.DB hoặc *sql.Tx mà không cần tách nhánh hay nhân đôi phương thức.

What’s the best way to signal “not found” from Get?

Trả về giá trị zero cùng với lỗi nil khi “không tìm thấy” sẽ khiến người gọi phải đoán liệu thực thể đó có thật sự không tồn tại hay chỉ có các trường rỗng. Một sentinel chung như ErrNotFound giữ trạng thái trong kênh lỗi, để code dịch vụ có thể phân nhánh an toàn với errors.Is.

Should Create/Update take the full entity struct?

Tách inputs khỏi mô hình lưu trữ. Ưu tiên Create(ctx, CreateInput)Update(ctx, id, UpdateInput) để người gọi không thể đặt các trường thuộc quyền quản lý của server như ID hoặc timestamp. Với cập nhật dạng patch, dùng con trỏ (hoặc kiểu nullable) để phân biệt "không set" và "set về giá trị zero".

How do I keep List pagination from returning inconsistent results?

Luôn đặt ORDER BY rõ ràng và ổn định, lý tưởng là trên một cột duy nhất như khoá chính. Không có điều này, phân trang có thể bỏ sót hoặc lặp bản ghi giữa các yêu cầu khi có hàng mới xuất hiện hoặc khi cách planner thay đổi thứ tự quét.

What error contract should repositories provide to services?

Cung cấp một tập nhỏ các lỗi để người gọi có thể phân nhánh, như ErrNotFoundErrConflict, và bọc mọi lỗi khác với ngữ cảnh từ lỗi DB bên dưới. Đừng bắt người gọi phải phân tích chuỗi lỗi; nhắm tới kiểm tra bằng errors.Is kèm một thông điệp hữu ích cho log.

How should I test a generic repository pattern without over-testing it?

Kiểm thử các helper chung một lần (chuẩn hoá phân trang, ánh xạ not-found, kiểm tra affected-row), sau đó kiểm thử riêng SQL và logic scan của từng thực thể. Thêm vài “contract tests” nhỏ cho mỗi repository: tạo rồi get phải trả về cùng dữ liệu, update thay đổi đúng trường, delete khiến get trả ErrNotFound, và list có thứ tự ổn định.

Dễ dàng bắt đầu
Tạo thứ gì đó tuyệt vời

Thử nghiệm với AppMaster với gói miễn phí.
Khi bạn sẵn sàng, bạn có thể chọn đăng ký phù hợp.

Bắt đầu