AWS CDK Link Kısaltıcı Bölüm 1: Proje Kurulumu & Temel Altyapı
AWS CDK, DynamoDB ve Lambda ile production-grade link kısaltıcı kurulumu. Mimari kararlar, proje yapısı ve ölçekte ayakta kalan şema seçimleri.
AWS üzerinde link kısaltıcı yapmak; yönlendirme gecikmesi, URL doğrulama ve link bazlı analitik zorunlu hale gelene kadar kolay görünür. Aylık milyonlarca yönlendirmede her mimari karar (depolama motoru, caching katmanı, CDN konumu) hem maliyeti hem de kuyruk gecikmesini doğrudan etkiler.
Sağlam duran varsayılan şu: tek bir DynamoDB tablosu, her rota için ayrı ve küçük birer Lambda, yönlendirme yolunun önünde CloudFront. Proje yapısını ve tablo şemasını ilk günden doğru kurarsanız serinin sonraki bölümleri migration değil ekleme olur. Yönlendirme döngüleri, kötü niyetli URL’ler ve kötüye kullanım trafiği de sonraya bırakılacak başlıklar değil: create handler’ın yazmadan önce neyi doğrulayacağını bunlar belirler.
Mimariye Genel Bakış#
CDK kodunu yazmadan önce mimariyi çizin. Geri dönüşü pahalı olan kararlar depolama ve caching tarafında. Serinin geri kalanının üzerine kurulduğu yapı şöyle:
Her katman kendi başına ölçekleniyor ve cache’ten dönen bir yönlendirme Lambda’ya da DynamoDB’ye de hiç uğramıyor. Önemli kararlar:
- CloudFront ile önbellekleme - Aynı yönlendirme için Lambda’yı neden 10.000 kez çalıştırasın?
- RDS yerine DynamoDB - Büyük ölçekte tahmin edilebilir performans, connection pooling baş ağrısı yok
- Ayrı Lambda fonksiyonları - İşler ters gittiğinde ölçekleme ve debug etmesi daha kolay
- Sıcak yollar için DAX - Çünkü o viral link veritabanınızı döver
CDK Proje Kurulumu#
cdk init ile yetinmeyin. Proje yapısına ayıracağınız birkaç dakika, stack sayısı arttığında sizi bir refactor’dan kurtarır. Ayrı stack’ler ve yeniden kullanılabilir construct’lar, environment’a özel konfigürasyonu handler’ların dışında tutar.
# TypeScript ile projeyi baştan oluştur
mkdir link-shortener && cd link-shortener
npx cdk init app --language typescript
# Gerçekten ihtiyacımız olan bağımlılıkları yükle (CDK v2)
npm install aws-cdk-lib@latest constructs@latest \
@aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb zod
# Akıl sağlığı için dev bağımlılıkları
npm install -D @types/aws-lambda @types/node esbuild \
prettier eslint tsx \
@typescript-eslint/parser @typescript-eslint/eslint-plugin
Proje yapınız şöyle görünmeli:
link-shortener/
├── bin/
│ └── link-shortener.ts # CDK app giriş noktası
├── lib/
│ ├── stacks/
│ │ ├── api-stack.ts # API Gateway + Lambda
│ │ ├── database-stack.ts # DynamoDB tabloları
│ │ └── cdn-stack.ts # CloudFront dağıtımı
│ └── constructs/
│ ├── link-table.ts # DynamoDB construct
│ └── lambda-function.ts # Yeniden kullanılabilir Lambda construct
├── src/
│ ├── handlers/
│ │ ├── create.ts # Kısa link oluştur
│ │ ├── redirect.ts # Yönlendirmeleri yönet
│ │ └── analytics.ts # Tıklamaları takip et
│ └── utils/
│ ├── id-generator.ts # Kısa ID üretimi
│ └── url-validator.ts # URL doğrulama
├── test/
└── cdk.json
DynamoDB Şema Tasarımı#
Çoğu tutorial id ve url içeren basit bir tablo gösterir. Bu düzen; tekilleştirme, link bazlı analitik ve özel slug ihtiyacı çıktığı anda yetersiz kalır. İki GSI’lı tek tablo üçünü birden karşılar:
// lib/constructs/link-table.ts
import { Table, AttributeType, BillingMode, StreamViewType } from 'aws-cdk-lib/aws-dynamodb';
import { RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
export class LinkTable extends Construct {
public readonly table: Table;
constructor(scope: Construct, id: string) {
super(scope, id);
this.table = new Table(this, 'LinksTable', {
partitionKey: {
name: 'PK',
type: AttributeType.STRING,
},
sortKey: {
name: 'SK',
type: AttributeType.STRING,
},
billingMode: BillingMode.PAY_PER_REQUEST, // Buradan başla, pattern'lerini öğrenince provisioned'a geç
pointInTimeRecovery: true, // Çünkü birisi önemli bir şeyi silecek
stream: StreamViewType.NEW_AND_OLD_IMAGES, // Analitik ve debugging için
removalPolicy: RemovalPolicy.RETAIN, // Production verisini asla yanlışlıkla silme
});
// Orijinal URL ile arama için GSI (tekilleştirme)
this.table.addGlobalSecondaryIndex({
indexName: 'GSI1',
partitionKey: {
name: 'GSI1PK',
type: AttributeType.STRING,
},
sortKey: {
name: 'GSI1SK',
type: AttributeType.STRING,
},
});
// Analitik sorguları için GSI
this.table.addGlobalSecondaryIndex({
indexName: 'GSI2',
partitionKey: {
name: 'GSI2PK',
type: AttributeType.STRING,
},
sortKey: {
name: 'CreatedAt',
type: AttributeType.NUMBER,
},
});
}
}
Neden bu şema? Tabloda tutulan kayıtların şekli şöyle:
// Tablodaki örnek kayıtlar
const linkRecord = {
PK: 'LINK#abc123', // Kısa kod
SK: 'METADATA', // Gelecekteki genişlemeye izin verir
GSI1PK: 'URL#https://example.com/very/long/url',
GSI1SK: 'LINK#abc123', // Tekilleştirme için
GSI2PK: 'USER#user123', // Kim oluşturdu
CreatedAt: 1706544000000, // Sıralama için timestamp
OriginalUrl: 'https://example.com/very/long/url',
ClickCount: 0,
ExpiresAt: 1738080000000, // TTL
Tags: ['campaign-2024', 'email'],
CustomSlug: 'summer-sale', // Opsiyonel özel slug
};
const clickRecord = {
PK: 'LINK#abc123',
SK: `CLICK#${Date.now()}#${uuid}`, // Benzersiz tıklama olayı
UserAgent: 'Mozilla/5.0...',
IPHash: 'hashed-ip', // Gizlilik uyumlu
Referer: 'https://twitter.com',
Timestamp: 1706544000000,
};
Bu tasarım şunları sağlar:
- Bir link için tüm veriyi tek istekle sorgula
- URL’leri verimli tekilleştir
- Analitik için bireysel tıklamaları takip et
- Çakışma olmadan özel slug’ları destekle
- TTL ile linkleri otomatik expire et
Create Handler#
Create handler URL’yi doğrular, GSI1 üzerinden tekilleştirir ve üretilen kısa ID çakıştığında yeniden dener:
// src/handlers/create.ts
import type { APIGatewayProxyHandlerV2WithLambdaAuthorizer } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
import { generateShortId } from '../utils/id-generator';
import { validateUrl } from '../utils/url-validator';
const client = new DynamoDBClient({});
const ddb = DynamoDBDocumentClient.from(client, {
marshallOptions: { removeUndefinedValues: true },
});
const TABLE_NAME = process.env.TABLE_NAME!;
const DOMAIN = process.env.SHORT_DOMAIN!;
type AuthContext = { userId?: string };
export const handler: APIGatewayProxyHandlerV2WithLambdaAuthorizer<AuthContext> = async (event) => {
const startTime = Date.now();
const userId = event.requestContext.authorizer?.lambda?.userId;
try {
const body = JSON.parse(event.body || '{}');
const { url, customSlug, expiresInDays = 365, tags = [] } = body;
// URL'yi doğrula (production'da sık karşılaşılan bir sorun kaynağı)
const validation = await validateUrl(url);
if (!validation.isValid) {
return {
statusCode: 400,
body: JSON.stringify({
error: validation.error,
details: validation.details
}),
};
}
// Mevcut kısa link kontrolü (tekilleştirme)
const existing = await ddb.send(new QueryCommand({
TableName: TABLE_NAME,
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :pk',
ExpressionAttributeValues: {
':pk': `URL#${url}`,
},
Limit: 1,
}));
if (existing.Items?.length) {
const existingLink = existing.Items[0];
console.log(`Tekilleştirme bulundu: ${existingLink.PK}`);
return {
statusCode: 200,
body: JSON.stringify({
shortUrl: `${DOMAIN}/${existingLink.PK.replace('LINK#', '')}`,
isNew: false,
processingTime: Date.now() - startTime,
}),
};
}
// Çakışma tespiti ile kısa ID üret
let shortId = customSlug || generateShortId();
let attempts = 0;
const maxAttempts = 5;
while (attempts < maxAttempts) {
try {
await ddb.send(new PutCommand({
TableName: TABLE_NAME,
Item: {
PK: `LINK#${shortId}`,
SK: 'METADATA',
GSI1PK: `URL#${url}`,
GSI1SK: `LINK#${shortId}`,
GSI2PK: `USER#${userId ?? 'ANONYMOUS'}`,
CreatedAt: Date.now(),
OriginalUrl: url,
ClickCount: 0,
ExpiresAt: Date.now() + (expiresInDays * 24 * 60 * 60 * 1000),
Tags: tags,
CreatedBy: userId,
SourceIP: event.requestContext?.http?.sourceIp,
},
ConditionExpression: 'attribute_not_exists(PK)',
}));
break; // Başarılı!
} catch (error: any) {
if (error.name === 'ConditionalCheckFailedException') {
if (customSlug) {
return {
statusCode: 409,
body: JSON.stringify({
error: 'Özel slug zaten mevcut',
suggestion: generateShortId(),
}),
};
}
shortId = generateShortId(); // Başka ID dene
attempts++;
} else {
throw error;
}
}
}
// Tüm denemeler çakıştı: yazılmamış bir kayıt için asla başarı dönme
if (attempts >= maxAttempts) {
return {
statusCode: 503,
body: JSON.stringify({ error: 'Kısa ID ayrılamadı, tekrar deneyin' }),
};
}
return {
statusCode: 201,
body: JSON.stringify({
shortUrl: `${DOMAIN}/${shortId}`,
shortId,
expiresAt: new Date(Date.now() + (expiresInDays * 24 * 60 * 60 * 1000)).toISOString(),
processingTime: Date.now() - startTime,
}),
};
} catch (error) {
console.error('Kısa link oluşturma hatası:', error);
return {
statusCode: 500,
body: JSON.stringify({
error: 'Sunucu hatası',
requestId: event.requestContext?.requestId,
}),
};
}
};
ID Üreteci Tasarımı#
Kütüphane seçimi, alfabe ve uzunluk kadar belirleyici değil. Daraltılmış bir alfabe üzerinde crypto.randomBytes kullanmak; kodu kısa, sesli okunduğunda karışmayan ve tahmin edilmesi zor tutar:
// src/utils/id-generator.ts
import { randomBytes } from 'crypto';
// Belirsiz karakterler (0, O, l, I) çıkarıldı; kod sesli okunduğunda karışmasın
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const ID_LENGTH = 7; // 58 karakter, 58^7 yaklaşık 2,2 trilyon kombinasyon
export function generateShortId(length: number = ID_LENGTH): string {
const bytes = randomBytes(length);
let id = '';
for (let i = 0; i < length; i++) {
id += ALPHABET[bytes[i] % ALPHABET.length];
}
return id;
}
// Özel slug'lar için doğrulama kuralları
export function validateCustomSlug(slug: string): { valid: boolean; reason?: string } {
if (slug.length < 3) {
return { valid: false, reason: 'Çok kısa (min 3 karakter)' };
}
if (slug.length > 50) {
return { valid: false, reason: 'Çok uzun (max 50 karakter)' };
}
// Sadece alfanümerik ve tire, alfanümerik ile başlayıp bitmeli
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/.test(slug)) {
return { valid: false, reason: 'Geçersiz karakter veya format' };
}
// Gerçek rotaları gölgeleyecek rezerve kelimeler
const reserved = ['api', 'admin', 'dashboard', 'login', 'logout', 'static', 'health'];
if (reserved.includes(slug.toLowerCase())) {
return { valid: false, reason: 'Rezerve kelime' };
}
return { valid: true };
}
Lokal Geliştirme Ortamı#
İlk günden lokal geliştirmeyi düzgün kurun. Her console.log değişikliğinde AWS’ye deploy etmek hem yavaş hem pahalıdır:
// local-dev.ts
import express from 'express';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { handler as createHandler } from './src/handlers/create';
import { handler as redirectHandler } from './src/handlers/redirect';
const app = express();
app.use(express.json());
// AWS servislerini lokal mockla
process.env.TABLE_NAME = 'local-links';
process.env.SHORT_DOMAIN = 'http://localhost:3000';
process.env.AWS_REGION = 'us-east-1';
// Lambda handler'ları Express için sarma
const lambdaToExpress = (handler: any) => async (req: any, res: any) => {
const event = {
body: JSON.stringify(req.body),
pathParameters: req.params,
queryStringParameters: req.query,
requestContext: {
http: {
sourceIp: req.ip,
},
requestId: Math.random().toString(36),
},
};
const result = await handler(event);
res.status(result.statusCode).json(JSON.parse(result.body));
};
app.post('/create', lambdaToExpress(createHandler));
app.get('/:id', lambdaToExpress(redirectHandler));
app.listen(3000, () => {
console.log('Lokal dev sunucusu http://localhost:3000 üzerinde çalışıyor');
console.log('DynamoDB Local port 8000 üzerinde gerekli');
});
DynamoDB’yi lokal çalıştır:
docker run -p 8000:8000 amazon/dynamodb-local \
-jar DynamoDBLocal.jar -sharedDb -inMemory
Deploy Script Yapılandırması#
// package.json scripts
{
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"test": "jest",
"cdk": "cdk",
"local": "tsx watch local-dev.ts",
"deploy:dev": "cdk deploy --all --context environment=dev",
"deploy:prod": "cdk deploy --all --context environment=prod --require-approval never",
"destroy:dev": "cdk destroy --all --context environment=dev",
"synth": "cdk synth --quiet",
"diff": "cdk diff --all"
}
}
Sık Karşılaşılan Tuzaklar#
-
On-demand DynamoDB ile başla: Erişim pattern’leri başta bilinmez. Trafik öngörülebilir hale gelince, provisioned kapasite tahminini on-demand faturasıyla karşılaştırıp öyle geçin.
-
Tıklama loglarını örnekle: Her tıklama için bir log satırı, CloudWatch faturanızı trafikle aynı eğriye bindirir. Yaklaşık %1 örnekleyin, gerisi için metrik kullanın.
-
Agresif önbellekle: Viral bir link bir saatte 500.000 tıklama alabilir. Cache dostu bir yönlendirme yanıtıyla bunların neredeyse tamamını CloudFront karşılar.
-
URL’leri düzgün doğrula: Birisi
javascript:alert('xss')için kısa link oluşturmaya çalışacak. Birisi yönlendirme döngüleri oluşturacak. Birisi servisi phishing için kullanacak. Bunları planlayın. -
İlk günden rate limiting: Olmadan, bir script bir ürün lansmanı sırasında 10 dakikada 100.000 link oluşturabilir.
Sonraki Adımlar#
Bölüm 2 redirect handler’ı ve önbellekleme stratejisini kuruyor, hacim büyüdükçe ucuz kalan analitiği ekliyor ve create endpoint’inin önüne rate limiting koyuyor. Seri sonrasında Bölüm 3 ile özel domainler, toplu işlemler ve güvenlik katmanlarına; Bölüm 4 ile deployment, maliyet ayarı ve monitoring’e; Bölüm 5 ile çok bölgeli ölçeklendirme ve uzun vadeli bakıma geçiyor.
Bu serinin tüm kodu GitHub (yeni sekmede açılır)’da.
Tek tablo, on-demand kapasite ve önde cache düzeni; yönlendirme trafiği okuma ağırlıklı olduğu ve kısa kodlar anlamsız kaldığı sürece geçerli. Analitik sorguları linkler arasında tarama gerektirmeye başladığında, düzenli trafik provisioned kapasiteyi ucuzlattığında veya bir uyum gereksinimi tıklama verisini aynı tablodan çıkarmaya zorladığında bu düzeni yeniden gözden geçirin.
Kaynaklar#
- Tutorial: Create a CRUD HTTP API with Lambda and DynamoDB (yeni sekmede açılır) - Lambda ve DynamoDB destekli sunucusuz HTTP API kurulumunu anlatan resmi API Gateway eğitimi; link kısaltıcının temel deseni.
- Deploying Lambda functions with AWS CDK (yeni sekmede açılır) - TypeScript ile Lambda fonksiyonlarını tanımlayan ve dağıtan resmi CDK eğitimi.
- Tutorial: Create a serverless Hello World application - AWS CDK v2 (yeni sekmede açılır) - API Gateway REST API ve Lambda fonksiyonunu birleştiren uçtan uca CDK örneği.
- Best practices for designing and using partition keys effectively in DynamoDB (yeni sekmede açılır) - Kısa kod tablosu tasarımı için kritik olan DynamoDB bölüm anahtarı düzeni rehberi.
- Best practices for developing and deploying cloud infrastructure with the AWS CDK (yeni sekmede açılır) - Yapı yeniden kullanımı, ortam yapılandırması ve durum bilgili kaynak yönetimine ilişkin CDK en iyi uygulamaları.
- AWS CDK API Reference (v2) (yeni sekmede açılır) - aws-lambda, aws-apigateway ve aws-dynamodb dahil tüm CDK yapıları için eksiksiz API referansı.
AWS CDK Link Kısaltıcı: Sıfırdan Production'a
AWS CDK, Node.js Lambda ve DynamoDB ile production-grade bir link kısaltma servisi kurulumu hakkında 5 bölümlük kapsamlı seri. Gerçek production hikayeleri, performans optimizasyonu ve maliyet yönetimi dahil.
Bu serideki tüm yazılar
İlgili yazılar
AWS Lambda, API Gateway, DynamoDB ve Step Functions için hızlı geri bildirim ve production güvenilirliği sağlayan kapsamlı bir test stratejisi oluşturmayı öğrenin.
lambda · testing · serverless +8
Middy builder pattern, Zod validation, feature flags ve secrets management ile sürdürülebilir, type-safe Lambda middleware nasıl inşa edilir öğren.
lambda · middleware · typescript +7
Yönlendirme motoru, analytics toplama ve API Gateway yapılandırması: günlük milyonlarca yönlendirme için performans optimizasyonları ve debugging stratejileri.
aws-cdk · lambda · api-gateway +5
Middy'nin middleware kalıplarıyla Lambda geliştirmesini nasıl dönüştürdüğünü, tekrarlayan şablonlardan temiz, sürdürülebilir serverless fonksiyonlara geçişi keşfedin
lambda · middleware · serverless +5
İç servis katmanı kurmadan önce kurup kurmayacağınıza karar verin. Katmanın çağrı başına maliyeti, VPC Lattice'in kazandığı hacim ve direct invoke'un hâlâ kazandığı an.
aws · aws-cdk · lambda +4