Kết hợp API DeFi là cột trụ của mọi ứng dụng tài chính phi tập trung hiện đại. Bất kể bạn đang xây dựng một công cụ theo dõi danh mục, một robot giao dịch hay một công cụ tối ưu hóa lợi nhuận, bạn cần có quyền truy cập đáng tin cậy vào dữ liệu trên chuỗi trên nhiều giao thức và chuỗi. Hướng dẫn này sẽ hướng dẫn bạn qua toàn bộ quá trình kết hợp – từ lựa chọn nhà cung cấp đến mã sẵn sàng cho sản xuất – với trọng tâm đặc biệt vào lĩnh vực DeFi Việt Nam.
Tại sao việc tích hợp API DeFi quan trọng
Vấn đề dữ liệu phân mảnh
DeFi không tồn tại trên một chuỗi, một giao thức hoặc một tiêu chuẩn duy nhất. Cổ phiếu của một người dùng có thể bao gồm Ethereum mainnet, Arbitrum, Optimism, BSC, Polygon và Solana – với các vị trí trong Aave, Uniswap, Curve, GMX và hàng chục giao thức nhỏ hơn. Xử lý dữ liệu này trực tiếp thông qua các nút RPC có nghĩa là:
- Chạy các nút lưu trữ của riêng bạn (tốn kém, phức tạp)
API DeFi giải quyết vấn đề này bằng cách cung cấp một lớp trừu tượng thống nhất. Chúng tạo chỉ mục dữ liệu trên chuỗi, chuẩn hóa dữ liệu trên các chuỗi và các giao thức, và cung cấp thông qua các giao diện REST, GraphQL hoặc WebSocket.
Bối cảnh Việt Nam: Một thị trường độc đáo
Việt Nam đứng thứ #3 thế giới về việc chấp nhận tiền điện tử (Chainalysis 2024), với sự tham gia của DeFi được thúc đẩy bởi:
Mọi việc tích hợp API DeFi hướng tới Việt Nam phải đảm bảo việc theo dõi VNDC, phủ sóng các giao thức địa phương và cung cấp dữ liệu tối ưu cho thiết bị di động.
Hiểu kiến trúc dữ liệu DeFi
Trước khi chọn nhà cung cấp, hãy hiểu các lớp dữ liệu:
Layer 1: Dữ liệu nguyên liệu trên chuỗi (RPC Nodes)
- Nguồn: Ethereum, BSC, Polygon, Solana RPC endpoints
Layer 2: Layer chỉ mục (Graph, các công cụ chỉ mục tùy chỉnh)
- Nguồn: Subgraphs chuyển các sự kiện hợp đồng thành mô hình GraphQL
Layer 3: API tích hợp (Covalent, Moralis, Alchemy Enhanced, Zapper)
- Nguồn: Dữ liệu đã được tính toán trước, được chuẩn hóa trên hơn 100 chuỗi
Layer 4: API phân tích (DeFi Llama, DefiPulse, Token Terminal)
- Nguồn: Dữ liệu TVL, doanh thu, phí ở cấp giao thức
So sánh các nhà cung cấp API DeFi chính
| Nhà cung cấp | Loại | Dây đai | Các điểm kết thúc quan trọng | Bộ cấp miễn phí | Best For |
|---|---|---|---|---|---|
| Khoa học kim loại | RPC + nâng cao | 7+ EVM | getTokenBalances, getAssetTransfers, getNfts | 300M CU/tháng | Ứng dụng tập trung vào Ethereum, webhooks thời gian thực |
| QuickNode | RPC + Add-ons | 25+ | API Token, API NFT, Streams | 10M yêu cầu/tháng | RPC đa chuỗi, các bổ sung tùy chỉnh |
| Infura | RPC + Core | 10+ | RPC cơ bản, API Eth | 100k yêu cầu/ngày | Tính năng độ tin cậy của RPC cơ bản |
| Covalent | Tổng hợp | 100+ | Lớp A/B/C: số dư, giao dịch, các sự kiện trong log | 1M req/mo (Class A) | Unified multi-chain, historical depth |
| Moralis | Aggregation | EVM + Solana | Web3 API, Streams API, Token API | 40k req/day | Cross-chain, real-time streams |
| Đồ thị | Liên kết | 40+ | Subgraph GraphQL | 100K credits/day | Protocol-specific complex queries |
| Zapper | Aggregation | 10+ | /positions, /balances, /apps | Custom | DeFi position aggregation |
| DeFi Llama | Analytics | 150+ | /tvl, /yields, /protocols | Free | Market analytics, TVL tracking |
Deep Dive: Provider Categories
RPC + Enhanced (Alchemy, QuickNode, Infura)
These start as raw RPC providers but add Enhanced APIs—pre-indexed endpoints for common patterns:
alchemy_getTokenBalances— all ERC-20 balances in one callalchemy_getAssetTransfers— normalized transfer history (ERC-20, ERC-721, native)quicknode_tokenAPI— token metadata, holders, transfers
Use when: You need real-time guarantees, webhook notifications, or Ethereum-mainnet depth.
Aggregation APIs (Covalent, Moralis, Zapper)
Unified REST across chains with normalized schemas: ` GET /v1/{chain}/address/{address}/balances_v2/ GET /v1/{chain}/address/{address}/transactions_v2/ GET /v1/{chain}/address/{address}/nfts/ ` Covalent's "Class A" endpoints (balances, transactions) are free up to 1M req/mo. Class B (log events, DEX trades) costs more.
Use when: Building multi-chain portfolio trackers, need historical data fast, want single API for 50+ chains.
The Graph (Subgraphs)
Deploy or query protocol-specific subgraphs: `graphql query { positions(where: { user: "0x...", protocol: "aave-v3" }) { token { symbol } suppliedAmount borrowAmount apy } } ` Best for deep protocol queries. Hosted Service is sunsetting—migrate to Decentralized Network (pay-per-query).
Use when: You need protocol-internal state (collateral ratios, health factors, position snapshots).
Choosing the Right Provider for Your Use Case
Decision Matrix
| Your Priority | Recommended Primary | Fallback |
|---|---|---|
| Real-time tx notifications | Alchemy (webhooks) | QuickNode Streams |
| Multi-chain breadth (50+ chains) | Covalent | Moralis |
| Chiều sâu riêng của từng giao thức (Aave, Uniswap V3) | The Graph (subgraphs) | Covalent Class B |
| Solana + EVM unified | Moralis | QuickNode (Solana RPC) |
| Cost-sensitive prototype | Covalent Free / Alchemy Free | QuickNode Free |
| Production scale (1B+ req/mo) | Custom enterprise | Tái hiện đa nhà cung cấp |
Kiểm tra thực tế về chi phí
Provider pricing is non-linear. A portfolio tracker polling 500 users every 30 seconds:
- 500 users × 2,880 polls/day = 1.44M requests/day = ~43M requests/month
- Free tiers (100M–300M CU) cover prototyping only
- Growth tiers: $49–299/mo for 1–10B CU
- Key insight: Most tutorials ignore this. Plan for Growth tier from Day 1 if you have >100 active users.
Rate Limits & Scaling Strategy
| Provider | Free Limit | Burst | Scaling Approach |
|---|---|---|---|
| Alchemy | 300M CU/mo, 100 req/s | 100 req/s | Exponential backoff + fallback |
| QuickNode | 10M req/mo, 25 req/s | 25 req/s | Request queuing |
| Covalent | 1M Class A/mo | 5 req/s | Batch requests, cache aggressively |
| The Graph | 100K credits/day | N/A | Query optimization, pagination |
Production pattern: Implement a provider abstraction layer with:
- Priority-ordered fallback chain (Primary → Secondary → Tertiary)
- Request routing by data type (balances → Covalent, txs → Alchemy, analytics → DeFi Llama)
- Unified TypeScript interfaces across providers
Hands-On: Building a DeFi Portfolio Tracker
Let's build a production-ready TypeScript portfolio tracker that fetches token balances, protocol positions, and calculates USD value across multiple chains.
Project Setup
`bash mkdir defi-portfolio-tracker && cd defi-portfolio-tracker npm init -y npm install axios zod dotenv npm install -D typescript ts-node @types/node npx tsc --init --target ES2020 --module commonjs --strict --esModuleInterop --outDir dist `
Type-Safe Provider Interfaces
`typescript // types/providers.ts import { z } from 'zod';
// Unified balance response across providers export const BalanceResponseSchema = z.object({ address: z.string(), chainId: z.number(), tokens: z.array(z.object({ contractAddress: z.string(), symbol: z.string(), name: z.string(), decimals: z.number(), balance: z.string(), // raw (wei) balanceFormatted: z.string(), // human-readable usdValue: z.number().optional(), logoUrl: z.string().optional(), })), fetchedAt: z.number(), // unix timestamp });
export type BalanceResponse = z.infer
// Provider-specific configs export interface ProviderConfig { name: string; apiKey: string; baseUrl: string; rateLimit: { requests: number; windowMs: number }; }
export const PROVIDER_CONFIGS: Recordhttps://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}, rateLimit: { requests: 100, windowMs: 1000 }, }, moralis: { name: 'Moralis', apiKey: process.env.MORALIS_API_KEY!, baseUrl: 'https://deep-index.moralis.io/api/v2.2', rateLimit: { requests: 25, windowMs: 1000 }, }, }; `
Covalent Balance Fetcher (Multi-Chain)
`typescript // providers/covalent.ts import axios from 'axios'; import { BalanceResponse, BalanceResponseSchema, PROVIDER_CONFIGS } from '../types/providers';
export class CovalentProvider { private config = PROVIDER_CONFIGS.covalent; private client = axios.create({ baseURL: this.config.baseUrl, timeout: 10000, });
async getBalances(chainId: number, address: string): Promise/${chainId}/address/${address}/balances_v2/; const params = { key: this.config.apiKey, nft: 'false', 'no-nft-fetch': 'true' };
const response = await this.client.get(url, { params }); const data = response.data.data;
// Transform to unified schema const tokens = data.items .filter((item: any) => item.type === 'cryptocurrency' && parseFloat(item.balance) > 0) .map((item: any) => ({ contractAddress: item.contract_address, symbol: item.contract_ticker_symbol || 'UNKNOWN', name: item.contract_name || 'Unknown Token', decimals: item.contract_decimals, balance: item.balance, balanceFormatted: (parseFloat(item.balance) / Math.pow(10, item.contract_decimals)).toFixed(4), usdValue: item.quote || 0, logoUrl: item.logo_url, }));
const result = { address: data.address, chainId, tokens, fetchedAt: Date.now(), };
return BalanceResponseSchema.parse(result); }
async getBalancesMultiChain(address: string, chainIds: number[]): PromiseCovalent error on chain ${chainId}:, e); } } return results; } } `
Alchemy Enhanced API (Ethereum Focus)
`typescript // providers/alchemy.ts import axios from 'axios'; import { BalanceResponse, BalanceResponseSchema, PROVIDER_CONFIGS } from '../types/providers';
export class AlchemyProvider { private config = PROVIDER_CONFIGS.alchemy; private client = axios.create({ baseURL: this.config.baseUrl, timeout: 10000, });
async getTokenBalances(address: string): Promise
const response = await this.client.post('', payload); const tokens = response.data.result.tokenBalances .filter((t: any) => t.tokenBalance !== '0x0') .map((t: any) => ({ contractAddress: t.contractAddress, symbol: '', // Need separate metadata call name: '', decimals: 18, balance: t.tokenBalance, balanceFormatted: '0', usdValue: 0, }));
// Batch fetch metadata (omitted for brevity - use alchemy_getTokenMetadata)
const result = { address, chainId: 1, // Ethereum mainnet tokens, fetchedAt: Date.now(), };
return BalanceResponseSchema.parse(result); } } `
Provider Abstraction Layer with Fallback
`typescript // core/provider-registry.ts import { BalanceResponse, ProviderConfig } from '../types/providers'; import { CovalentProvider } from '../providers/covalent'; import { AlchemyProvider } from '../providers/alchemy';
export type ProviderName = 'covalent' | 'alchemy' | 'moralis';
export interface ProviderAdapter { name: ProviderName; getBalances(chainId: number, address: string): Promise
export class ProviderRegistry { private providers: Map
register(name: ProviderName, adapter: ProviderAdapter) { this.providers.set(name, adapter); }
// Route request to best provider for the data type async getBalancesWithFallback(chainId: number, address: string): Promise
for (const name of this.priorityOrder) { const provider = this.providers.get(name); if (!provider) continue;
try { const healthy = await provider.healthCheck(); if (!healthy) throw new Error(${name} health check failed);
return await provider.getBalances(chainId, address); } catch (e) { errors.push(e as Error); console.warn(${name} failed, trying next:, (e as Error).message); } }
throw new Error(All providers failed: ${errors.map(e => e.message).join('; ')}); }
// Multi-chain: route to aggregation provider (Covalent) async getMultiChainBalances(address: string, chainIds: number[]): Promise`
Protocol Position Fetcher (Aave, Uniswap, etc.)
`typescript // providers/protocol-positions.ts import axios from 'axios'; import { z } from 'zod';
export const PositionSchema = z.object({ protocol: z.string(), chainId: z.number(), positionType: z.enum(['lending', 'borrowing', 'lp', 'staking', 'farming']), tokens: z.array(z.object({ symbol: z.string(), address: z.string(), amount: z.string(), usdValue: z.number(), })), apy: z.number().optional(), healthFactor: z.number().optional(), // for lending fetchedAt: z.number(), });
export type ProtocolPosition = z.infer
export class ProtocolPositionFetcher { private covalentBase = 'https://api.covalenthq.com/v1'; private apiKey: string;
constructor(apiKey: string) { this.apiKey = apiKey; }
// Aave V3 positions via Covalent's DEX/DeFi endpoints async getAavePositions(chainId: number, address: string): Promise${this.covalentBase}/${chainId}/address/${address}/transactions_v2/; const params = { key: this.apiKey, 'protocol': 'aave' };
// Parse Aave supply/borrow events from transactions // (Simplified - production uses dedicated Aave subgraph) return []; }
// Uniswap V3 LP positions async getUniswapV3Positions(chainId: number, address: string): Promise
// Generic DeFi positions via Zapper or Covalent async getAllPositions(address: string, chainIds: number[]): Promise`
Main Application: Portfolio Aggregator
`typescript // index.ts import { ProviderRegistry } from './core/provider-registry'; import { CovalentProvider } from './providers/covalent'; import { AlchemyProvider } from './providers/alchemy'; import { ProtocolPositionFetcher } from './providers/protocol-positions'; import { BalanceResponse } from './types/providers';
const SUPPORTED_CHAINS = [ { id: 1, name: 'Ethereum' }, { id: 137, name: 'Polygon' }, { id: 42161, name: 'Arbitrum' }, { id: 10, name: 'Optimism' }, { id: 56, name: 'BSC' }, { id: 43114, name: 'Avalanche' }, ];
async function main() { const address = process.argv[2] || '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb3'; // Example
// Initialize provider registry with fallback chain const registry = new ProviderRegistry(); registry.register('covalent', new CovalentProvider()); registry.register('alchemy', new AlchemyProvider());
console.log(\n🔍 Fetching portfolio for ${address}...\n);
// Multi-chain balances via Covalent (primary for breadth) const chainIds = SUPPORTED_CHAINS.map(c => c.id); const allBalances = await registry.getMultiChainBalances(address, chainIds);
// Aggregate results let totalUsd = 0; const chainBreakdown: Record
for (const balance of allBalances) { const chain = SUPPORTED_CHAINS.find(c => c.id === balance.chainId); const chainName = chain?.name || Chain ${balance.chainId}; let chainUsd = 0;
console.log(\n📊 ${chainName} (${balance.tokens.length} tokens):); for (const token of balance.tokens) { const usd = token.usdValue || 0; chainUsd += usd; totalUsd += usd; if (usd > 0.01) { // Filter dust console.log( ${token.symbol}: ${token.balanceFormatted} ($${usd.toFixed(2)})); } } chainBreakdown[chainName] = { usd: chainUsd, tokens: balance.tokens.length }; }
console.log(\n💰 Total Portfolio Value: $${totalUsd.toFixed(2)}); console.log('\n📈 Chain Breakdown:'); for (const [chain, data] of Object.entries(chainBreakdown)) { console.log( ${chain}: $${data.usd.toFixed(2)} (${data.tokens} tokens)); }
// Protocol positions (optional) const positionFetcher = new ProtocolPositionFetcher(process.env.COVALENT_API_KEY!); const positions = await positionFetcher.getAllPositions(address, chainIds);
if (positions.length > 0) { console.log('\n🏦 Protocol Positions:'); for (const pos of positions) { console.log( ${pos.protocol} (${pos.positionType}): $${pos.tokens.reduce((s, t) => s + t.usdValue, 0).toFixed(2)}); } } }
main().catch(console.error); `
Production Best Practices
Error Handling & Retries
`typescript // utils/retry.ts export async function withRetry
for (let attempt = 0; attempt <= options.maxRetries; attempt++) { try { return await fn(); } catch (e) { lastError = e as Error;
// Don't retry on 4xx errors (client errors) if (axios.isAxiosError(e) && e.response?.status && e.response.status >= 400 && e.response.status < 500) { throw e; }
if (attempt < options.maxRetries) { const delay = Math.min( options.baseDelayMs Math.pow(2, attempt) + Math.random() 1000, options.maxDelayMs ); console.warn(Attempt ${attempt + 1} failed: ${e.message}. Retrying in ${delay}ms...); await new Promise(r => setTimeout(r, delay)); } } }
throw lastError!; } `
Caching Strategy
`typescript // utils/cache.ts interface CacheEntry
export class TTLCache
constructor(defaultTtlMs = 60000) { // 1 minute default this.defaultTtlMs = defaultTtlMs; }
get(key: string): T | null { const entry = this.cache.get(key); if (!entry) return null; if (Date.now() > entry.expiresAt) { this.cache.delete(key); return null; } return entry.data; }
set(key: string, data: T, ttlMs?: number): void { this.cache.set(key, { data, expiresAt: Date.now() + (ttlMs || this.defaultTtlMs), }); }
// Stale-while-revalidate: return stale data while fetching fresh async getOrFetch(key: string, fetcher: () => Promise
// Fetch fresh const fresh = await fetcher(); this.set(key, fresh, ttlMs); return fresh; } }
// Usage const balanceCache = new TTLCachebalances:${chainId}:${address}, () => provider.getBalances(chainId, address), 30000 ); `
Rate Limit Management
`typescript // utils/rate-limiter.ts export class TokenBucketRateLimiter { private tokens: number; private lastRefill: number;
constructor( private capacity: number, private refillRate: number // tokens per second ) { this.tokens = capacity; this.lastRefill = Date.now(); }
async take(): Promise
if (this.tokens >= 1) { this.tokens -= 1; return; }
// Wait for next token const waitMs = (1 - this.tokens) / this.refillRate * 1000; await new Promise(r => setTimeout(r, waitMs)); return this.take(); }
private refill() { const now = Date.now(); const elapsed = (now - this.lastRefill) / 1000; this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate); this.lastRefill = now; } }
// Per-provider limiters const limiters = { covalent: new TokenBucketRateLimiter(5, 5), // 5 req/s alchemy: new TokenBucketRateLimiter(100, 100), moralis: new TokenBucketRateLimiter(25, 25), };
// Wrap provider calls async function rateLimitedCall(provider: string, fn: () => Promise`
Monitoring & Alerting
`typescript // monitoring/metrics.ts interface ApiMetrics { provider: string; endpoint: string; latencyMs: number; status: 'success' | 'error'; timestamp: number; errorCode?: string; }
const metrics: ApiMetrics[] = [];
export function recordMetric(metric: ApiMetrics) { metrics.push(metric); // Keep last 10000 if (metrics.length > 10000) metrics.shift();
// Alert on high error rate const recent = metrics.filter(m => m.timestamp > Date.now() - 60000); const errors = recent.filter(m => m.status === 'error').length; if (errors > 10) { console.error(⚠️ ALERT: ${errors} errors in last minute for ${metric.provider}); // Send to alerting system (PagerDuty, Slack, etc.) } }
// Wrapper async function monitoredCall(provider: string, endpoint: string, fn: () => Promise`
Cost Optimization
- Đơn hàng theo nhóm — Covalent hỗ trợ các điểm kết thúc theo nhóm; Alchemy hỗ trợ việc phân phối đơn hàng
eth_call - Caching tích cực — Các dữ liệu thường ít thay đổi theo từng khối; TTL 30-60s là an toàn
Những điều cần lưu ý về thị trường Việt Nam
Regulatory Landscape (2024)
Vietnam currently treats cryptocurrency as "not legal tender" but not explicitly banned. Key points:
- No licensing framework for DeFi protocols yet
- Banks prohibited from crypto services (SBV Directive 2018)
- Individuals can hold/trade; tax guidance unclear
- Implication: APIs don't need KYC for read-only data, but fiat on-ramp integrations (Momo, ZaloPay) require licensed partners
VND Stablecoin Data
VNDC (VND-pegged) exists on:
- Ethereum (0x...), BSC, Polygon, Tron, Solana, HECO
- Track via standard ERC-20 balance endpoints
- Critical: Verify contract addresses per chain—avoid impersonator tokens
Vietnam-Specific Protocols
| Protocol | Type | Chain Coverage | API Notes |
|---|---|---|---|
| Kyber Network | DEX Aggregator | Ethereum, BSC, Polygon, Avalanche | KyberSwap API for quotes/swaps |
| Coin98 Wallet | Multi-chain Wallet | 40+ chains | Coin98 Extension API for portfolio |
| KardiaChain | L1 (VN-focused) | Native + EVM | KAI token, KardiaChain RPC |
| VNDC | Stablecoin | 6+ chains | Standard ERC-20, track via Covalent/Alchemy |
Mobile-First Data Delivery
Vietnamese users are predominantly mobile:
- Use Server-Sent Events (SSE) or WebSockets for push updates (lower battery than polling)
- Compress payloads (gzip, protocol buffers)
- Implement offline-first caching (IndexedDB/AsyncStorage)
- Zalo/Telegram bot integration for price alerts
FAQ
1. What's the difference between RPC nodes and DeFi APIs?
RPC nodes give you raw blockchain access (eth_call, eth_getLogs). You parse everything yourself. DeFi APIs (Covalent, Alchemy Enhanced, Moralis) give you pre-indexed, normalized data—balances, transactions, NFTs, protocol positions—via simple REST/GraphQL. Use RPC for transaction submission; use DeFi APIs for reading data.
2. How do I handle rate limits when building a DeFi dashboard?
Implement three layers: (1) Client-side cache (React Query/SWR with 30-60s staleTime), (2) Server-side cache (Redis with TTL), (3) Request queue with token bucket per provider. Use webhooks (Alchemy) or WebSockets for real-time updates instead of polling.
3. Which DeFi API is best for historical data?
Covalent excels at historical: Class A endpoints (balances, transactions) go back to genesis on 100+ chains. The Graph subgraphs provide protocol-specific history (e.g., every Aave borrow event). Alchemy getAssetTransfers covers Ethereum mainnet history well.
4. Can I use free tiers for production DeFi apps?
No. Free tiers (100M–300M CU/month) cover prototyping only. A modest portfolio tracker with 500 users polling every 30s = ~1.4B requests/month = Growth tier minimum ($49-299/mo). Plan for paid tiers from Day 1.
5. How do I get liquidity pool positions via API?
Uniswap V3: Query The Graph subgraph (positions entity). Curve/Convex: Use Covalent Class B log_events with pool contract addresses. Generic: Zapper /positions endpoint or DeFi Llama /pools. For custom protocols, deploy a subgraph.
6. What's the best way to aggregate data from multiple chains?
Use an aggregation API (Covalent/Moralis) as primary for breadth, with provider abstraction layer routing specific queries to specialists (Alchemy for Ethereum real-time, The Graph for protocol depth). Never hardcode one provider—build fallback chains.
Summary
Tích hợp API DeFi không phải là một cuộc gọi API duy nhất - đó là một quyết định về kiến trúc. Mẫu sản xuất:
- Nền tảng trừu tượng — Giao diện thống nhất, không phụ thuộc vào nhà cung cấp
- Đường lùi — Trước, sau, thứ ba theo loại dữ liệu
- Hợp đồng an toàn kiểu — Mô hình Zod kiểm tra từng phản hồi
- Caching + giới hạn tốc độ — Tuân thủ giới hạn, phục vụ dữ liệu cũ trong khi kiểm tra lại
Bắt đầu với Covalent cho độ rộng của nhiều chuỗi, thêm Alchemy cho Ethereum thời gian thực, sử dụng The Graph cho độ sâu của giao thức. Hãy xây dựng lớp trừu tượng đầu tiên – nó sẽ có ích khi (không nếu) bạn cần thay đổi nhà cung cấp.
Các bước tiếp theo: Tìm hiểu mô hình trừu tượng nhà cung cấp để có một bản implement hoàn chỉnh với các thử nghiệm, cấu hình Docker và chuỗi công cụ CI/CD.
