Middy Alternatifleri: Özel AWS Lambda Middleware Framework'ü Geliştirme
Bir Lambda filosu Middy'nin statik middleware modelini ne zaman aşar, projeye özel bir motor istek başına konfigürasyonu nasıl çözer, bakımı neye mal olur
Middy, küçük bir Lambda filosunun tipik middleware ihtiyaçlarını karşılar ama jenerik middleware-zinciri modelinin dengeleri, bir servis ortak bir middleware yığınını paylaşan yaklaşık 50 fonksiyona ulaştığında ölçülebilir hale gelir: çağrı başına ek yük, middleware zincirinin cold-start maliyeti ve ortak bir wrapper’ın başka türlü bağlantısız fonksiyonlar arasında yarattığı kenetlenme. Bu ölçekte soru şuna döner: Middy’nin soyutlamaları üzerine katmanlamaya devam mı edilir, AWS Lambda Powertools ile değiştirilir mi, yoksa yalnızca filonun gerçekten kullandığı hook’lar için ödeme yapan projeye özel bir middleware framework’ü mü yazılır.
Varsayılan cevap Middy’de kalmaktır. Bakımı yapılıyor, dokümante edilmiş durumda ve çağrı başına maliyeti, bir handler’ın yaptığı ilk network çağrısının yanında kayboluyor. Kendi motorunuzu yazmak yalnızca ölçebildiğiniz bir kısıt karşısında değer kazanır: istek başına çözülmesi gereken konfigürasyon, zincirin her cold start’a eklediği init süresi ya da hiçbir code review turunun yerleştiremediği handler kuralları.
Middy’nin Modeli Nerede Yetmiyor#
İstek Başına Konfigürasyon#
En net örnek multi-tenant validation. Her tenant kendi kurallarını taşır: biri UK posta kodu kontrolü ister, diğeri Alman VAT numarası, üçüncüsü başka hiçbir yerde bulunmayan bir kural seti.
Middy, middleware seçeneklerini handler modülü yüklenirken çözer:
import middy from '@middy/core'
import validator from '@middy/validator'
import { transpileSchema } from '@middy/validator/transpile'
// transpileSchema şemayı bir kez, modül yüklenirken derler
const schema = transpileSchema(getSchemaForTenant(process.env.TENANT_ID))
export const handler = middy(businessLogic)
.use(validator({ eventSchema: schema })) // her tenant için tek şema
Şema seçiminin istek başına yapılması gerekir ama derlenmiş validator, modülün ömrü boyunca sabit kalır. Alışıldık workaround, koşullu mantığı tekrar handler’ın içine taşımaktır; bu da middleware’in sağlaması gereken ayrışmayı ortadan kaldırır.
Bedel, validation’ı zaten sahiplenen middleware’in yanında ayrıca bakımı yapılan ikinci bir validation katmanıdır.
Bundle Boyutu ve Cold Start#
Yığına eklenen her Middy paketi deployment artifact’ine girer ve artifact, ilk çağrı çalışmadan önce indirilip başlatılmak zorundadır. Zincirin kendisi de init maliyeti getirir: her .use(), motorun modül yüklenirken birleştirdiği hook’ları kaydeder.
Bu maliyetlerin hiçbiri tek başına dramatik değil. Önem kazandıkları yer, gecikmeye duyarlı ve nadiren sıcak kalan fonksiyonlardır; çünkü ikisi de her cold start’ta ödenir ve ikisi de çoğu takımın izlediği sıcak yol rakamlarında görünmez. API Gateway arkasındaki senkron bir API bunu hisseder. Düzenli trafik alan bir SQS tüketicisi hissetmez.
Takım İçinde Tutarsız Zincirler#
Farklı servisler üzerinde çalışan birden fazla developer arasında middleware kullanım pattern’leri tutarsız hale gelir:
// Developer A'nın yaklaşımı
export const handler = middy(businessLogic)
.use(httpJsonBodyParser())
.use(validator())
.use(httpErrorHandler())
// Developer B'nin yaklaşımı (sıralama farklı!)
export const handler = middy(businessLogic)
.use(httpErrorHandler()) // Error handling önce mi?
.use(httpJsonBodyParser())
.use(validator())
// Developer C'nin yaklaşımı
export const handler = middy(businessLogic)
.use(customAuth()) // Team-specific middleware
.use(httpJsonBodyParser())
// Validator hiç yok!
Üçü de derlenir. Üçü de hata yolunda farklı davranır ve tip sisteminden hiçbir itiraz gelmez; review’lar bir kısmını yakalar, gerisi yayına çıkar, çünkü bir kuralın build’i patlatma yolu yoktur.
Özel Bir Middleware Framework’ü Tasarlamak#
Yerine geçecek motorun çözmesi gereken tek şey bu üç problem. Middy’nin yaptığı diğer her şey, bir ihtiyaç doğana kadar yazılmadan kalabilir.
Bir Kez Derlenen Zincir#
Motor, çağrı başına tek bir context nesnesi tutar ve zinciri ilk çalıştırmada birleştirir:
interface LightweightContext {
event: any
context: any
response?: any
metadata: Map<string, any> // Memory efficient storage
startTime: number
}
type MiddlewareHandler = (
ctx: LightweightContext,
next: () => Promise<void>
) => Promise<void>
class CustomMiddlewareEngine {
private middlewares: MiddlewareHandler[] = []
private isCompiled = false
private compiledChain?: (ctx: LightweightContext) => Promise<void>
private errorHandler?: (error: unknown, ctx: LightweightContext) => any
use(middleware: MiddlewareHandler): this {
if (this.isCompiled) {
throw new Error('Cannot add middleware after compilation')
}
this.middlewares.push(middleware)
return this
}
onError(handler: (error: unknown, ctx: LightweightContext) => any): this {
this.errorHandler = handler
return this
}
// Performance için middleware chain'ini pre-compile et
private compile(): void {
const chain = this.middlewares.reduceRight(
(next, middleware) => (ctx: LightweightContext) =>
middleware(ctx, () => next(ctx)),
() => Promise.resolve()
)
this.compiledChain = chain
this.isCompiled = true
}
async execute(event: any, context: any): Promise<any> {
if (!this.isCompiled) this.compile()
const ctx: LightweightContext = {
event,
context,
metadata: new Map(),
startTime: Date.now()
}
try {
if (!this.compiledChain) {
throw new Error('Middleware chain not compiled')
}
await this.compiledChain(ctx)
return ctx.response
} catch (error) {
if (!this.errorHandler) throw error
return this.errorHandler(error, ctx)
}
}
}
Zincir bir kez birleştirilir ve her sıcak çağrıda yeniden kullanılır; böylece reduceRight maliyeti tüm isteklerde değil, container’ın ömründeki ilk istekte ödenir. İlk çalıştırmadan sonra zinciri dondurmak, bir handler’ın istek anında sessizce middleware eklemesini engeller.
İstek Başına Çözülen Konfigürasyon#
Multi-tenant validation problemi için middleware kendi konfigürasyonunu çalışma anında çözer:
interface DynamicValidationOptions {
getSchema: (ctx: LightweightContext) => Promise<any>
cacheKey?: (ctx: LightweightContext) => string
}
const dynamicValidator = (options: DynamicValidationOptions): MiddlewareHandler => {
const schemaCache = new Map<string, any>()
return async (ctx, next) => {
let schema: any
if (options.cacheKey) {
const key = options.cacheKey(ctx)
schema = schemaCache.get(key)
if (!schema) {
schema = await options.getSchema(ctx)
schemaCache.set(key, schema)
}
} else {
schema = await options.getSchema(ctx)
}
const isValid = validateAgainstSchema(ctx.event, schema)
if (!isValid) {
throw new ValidationError('Invalid request data')
}
await next()
}
}
// Multi-tenant desteği ile kullanım
const handler = new CustomMiddlewareEngine()
.use(dynamicValidator({
getSchema: async (ctx) => {
const tenantId = ctx.event.pathParameters?.tenantId
return await getTenantSchema(tenantId)
},
cacheKey: (ctx) => `tenant:${ctx.event.pathParameters?.tenantId}`
}))
Şema istek başına çözülür ve cache, bir tenant için ilk çağrıdan sonra bu çözümü sıcak yolun dışında tutar. Cache, execution environment içinde yaşar; yani sıcak çağrılar boyunca kalır ve container ile birlikte kaybolur. Tenant sayısı açık uçluysa cache’e bir üst sınır koyun; uzun ömürlü bir container’daki sınırsız Map, yavaş çalışan bir memory leak’tir.
Yükleme Anında Uygulanan Standartlar#
Her handler’ın geçtiği tek yer factory olduğu için, kuralların uygulanacağı yer de orasıdır:
interface TeamStandards {
required: string[]
order: string[]
}
const standards: TeamStandards = {
required: ['auth', 'validation', 'errorHandler'],
order: ['auth', 'validation', 'businessLogic', 'errorHandler']
}
// İsimlendirilmiş middleware, factory'nin kurduğu zinciri denetleyebilsin diye
const registry: Record<string, () => MiddlewareHandler> = {
auth: authMiddleware,
validation: validationMiddleware,
errorHandler: errorHandlerMiddleware
}
const createStandardHandler = (businessLogic: MiddlewareHandler) => {
const missing = standards.required.filter((name) => !standards.order.includes(name))
if (missing.length > 0) {
throw new Error(`Required middleware missing: ${missing.join(', ')}`)
}
const engine = new CustomMiddlewareEngine()
for (const name of standards.order) {
engine.use(name === 'businessLogic' ? businessLogic : registry[name]())
}
return engine
}
Kontrol, handler modülü yüklenirken çalışır; yani auth eksik bir zincir, ona ihtiyaç duyan ilk istekte değil deploy sırasında patlar. Factory’yi baypas eden bir handler hâlâ mümkün ama artık bu, review sırasında alışılmadık bir .use() sırasını fark etmeye değil tek bir grep’e bakıyor.
Geçmeden Önce Neyi Ölçmeli#
Yeniden yazmayı yalnızca kendi filonuzdan gelen rakamlar haklı çıkarır ve bu rakamların, motorun tek satırı yazılmadan önce var olması gerekir. Kararı dört ölçüm verir:
- Init süresi. CloudWatch Logs’taki
REPORTsatırı her cold start içinInit Durationtaşır. Aynı handler’ı biri tam Middy yığınıyla diğeri yığın çıkarılmış halde iki kez deploy edin; aradaki fark zincirin başlangıçta ödettiği bedeldir. - Deploy edilen artifact boyutu. Bundle’ı
node_modulesüzerinden değil, tree-shaking sonrası ölçün. Bundler’lar bağımlılık listesinin ima ettiğinin büyük bir kısmını atar ve Lambda’nın indirdiği şey artifact’tir. - Sıcak çağrı ek yükü. Zinciri ölçün ve farkı custom metrik olarak yayın. Bu değer handler’daki ilk DynamoDB veya HTTP çağrısının yanında küçük kalıyorsa endpoint’i yavaşlatan şey zincir değildir.
- Gerçekten kullanılan hook sayısı. Filonun çağırdığı farklı Middy middleware’lerini sayın. Bu sayı üç olduğunda özel bir motorun sahipliği ucuz, on iki olduğunda pahalıdır.
Ölçümler zincirin downstream I/O yanında yuvarlama hatası kaldığını söylüyorsa performans argümanı biter. Kararı bundan sonra istek başına konfigürasyon ve kural uygulama tek başına taşımak zorundadır.
Aynı Zincirin İki Motordaki Hali#
Middy Yaklaşımı:
export const handler = middy(businessLogic)
.use(httpJsonBodyParser())
.use(httpCors({ origin: 'https://app.example.com' }))
.use(validator({ eventSchema: transpileSchema(schema) }))
.use(httpErrorHandler())
.use(httpSecurityHeaders())
Custom Framework:
const handler = new CustomMiddlewareEngine()
.use(jsonParser())
.use(corsHandler({ origin: 'https://app.example.com' }))
.use(requestValidator(schema))
.use(businessLogicWrapper(businessLogic))
.use(errorHandler())
.use(securityHeaders())
Yüzeyler birbirine benziyor, ama fark sahiplikte: ikinci yığındaki her satır, takımın yazdığı, test ettiği ve yamadığı koddur.
Tek Çağrıdan Uzun Yaşayan Durum#
Bir breaker da bir response cache de durumu çağrılar arasında execution environment içinde tutar.
Circuit Breaker#
interface CircuitBreakerOptions {
failureThreshold: number
recoveryTimeout: number
monitor?: (state: 'open' | 'closed' | 'half-open') => void
}
const circuitBreaker = (options: CircuitBreakerOptions): MiddlewareHandler => {
let failures = 0
let lastFailure = 0
let state: 'open' | 'closed' | 'half-open' = 'closed'
return async (ctx, next) => {
const now = Date.now()
// Recovery denemesi yapmalı mıyız kontrol et
if (state === 'open' && now - lastFailure > options.recoveryTimeout) {
state = 'half-open'
options.monitor?.(state)
}
// Circuit açıksa request'leri blokla
if (state === 'open') {
throw new Error('Circuit breaker is open - service temporarily unavailable')
}
try {
await next()
// Başarı - failure'ları resetle
if (failures > 0) {
failures = 0
state = 'closed'
options.monitor?.(state)
}
} catch (error) {
failures++
lastFailure = now
if (failures >= options.failureThreshold) {
state = 'open'
options.monitor?.(state)
}
throw error
}
}
}
Lambda içindeki her breaker için geçerli bir uyarı: sayaç execution environment içinde yaşar, yani her sıcak container kendi sayacını tutar. Yirmi eşzamanlı container, her biri açılmadan önce kendi hata sayısını bekleyen yirmi bağımsız breaker demektir; bu, tek bir sıcak container’ın bozulmuş bir bağımlılığı dövmesini durdurmaya yeter, ama filo geneli bir breaker değildir ve downstream çağrısına konacak bir timeout’un yerini tutmaz.
Zincir İçinde Response Cache’leme#
interface CacheOptions {
ttl: number
keyGenerator: (ctx: LightweightContext) => string
shouldCache: (ctx: LightweightContext) => boolean
invalidateOn?: string[]
}
const smartCache = (options: CacheOptions): MiddlewareHandler => {
const cache = new Map<string, { data: any, expires: number }>()
return async (ctx, next) => {
const cacheKey = options.keyGenerator(ctx)
const now = Date.now()
// Cache hit kontrolü
if (options.shouldCache(ctx)) {
const cached = cache.get(cacheKey)
if (cached && cached.expires > now) {
ctx.response = cached.data
ctx.metadata.set('cache', 'hit')
return // Kalan middleware'leri atla
}
}
await next()
// Response'u cache'le
if (ctx.response && options.shouldCache(ctx)) {
cache.set(cacheKey, {
data: ctx.response,
expires: now + options.ttl
})
ctx.metadata.set('cache', 'miss')
}
}
}
// Intelligent caching ile kullanım
const handler = new CustomMiddlewareEngine()
.use(smartCache({
ttl: 5 * 60 * 1000, // 5 dakika
keyGenerator: (ctx) => `user:${ctx.event.pathParameters?.userId}`,
shouldCache: (ctx) => ctx.event.httpMethod === 'GET'
}))
.use(businessLogicWrapper(getUserProfile))
next() çağrılmadan dönmek zincirin geri kalanını atlar. Middy’de de aynı kaçış kapısı var: before middleware’inde request.earlyResponse. Yani kısa devre tek başına özel motor gerekçesi değildir. Özel zincirin kazandırdığı şey, key generator’ın, TTL’in ve invalidation kurallarının bir middleware seçenek nesnesi ile handler arasında bölünmek yerine sahip olduğunuz tek bir modülde durmasıdır.
Handler’ları Middy’den Taşımak#
Tüm handler’ları aynı anda çeviren bir migration’ın geri dönüş planı yoktur. Aşağıdaki sıra etki alanını dar tutar.
Middy Zincirine Custom Middleware Karıştırmak#
// Custom middleware'i mevcut Middy ile karıştır
export const handler = middy(businessLogic)
.use(customPerformanceMiddleware()) // Bizim custom
.use(httpJsonBodyParser()) // Middy
.use(customValidation()) // Bizim custom
.use(httpErrorHandler()) // Middy
Eksik Karşılıkları Yazmak#
// Tüm Middy middleware'leri için custom equivalent'lar inşa et
const customJsonParser = (): MiddlewareHandler => {
return async (ctx, next) => {
if (ctx.event.body && typeof ctx.event.body === 'string') {
try {
ctx.event.body = JSON.parse(ctx.event.body)
} catch (error) {
throw new Error('Invalid JSON body')
}
}
await next()
}
}
Kırpma ve Standartlaştırma#
Her Middy middleware’inin bir karşılığı olduktan sonra kırpma başlar: çağrılmayan hook’ları atmak ve yalnızca tek bir fonksiyon çağrısını saran hook’ları satır içine almak. Yukarıdaki dört ölçümü bu noktada aynı handler üzerinde tekrarlayın.
Geriye standart zinciri en az dirençli yol haline getirmek kalır: onu üreten bir factory fonksiyonu, başka türlü kurulmuş handler’ları işaretleyen bir lint kuralı veya review kontrolü ve hangi hook’un nerede çalıştığını anlatan kısa bir doküman.
Motoru Sahiplenmenin Bedeli#
Özel bir zincir cold start’tan gerçek süre kırpabilir ve karşılığında ürüne gidecek geliştirme zamanını harcar. Gecikme yazılı bir gereksinimse bu takas alınmaya değer.
Bitmeyen kısım bakımdır. Özel kod, kimsenin planlamadığı Node.js yükseltmesi dahil özel bakım demektir. Bu işi bugün Middy’nin bakımcıları soğuruyor; yeniden yazımdan sonra takım soğuracak.
Kazancı belirleyen şey benimsenmedir. Takımın etrafından dolaştığı bir framework, yerini aldığı kütüphaneden daha kötüdür; çünkü artık kod tabanında iki kural seti vardır ve varsayılanlar ile dokümantasyon da bu teknik işin parçasıdır.
Zinciri Test Etmek#
Sessizce bozulmaya en yatkın kısım zincir sırasıdır, bu yüzden açık bir testi hak eder:
describe('Custom Middleware Framework', () => {
test('should execute middleware chain in order', async () => {
const executionOrder: string[] = []
const middleware1 = async (ctx: any, next: Function) => {
executionOrder.push('before-1')
await next()
executionOrder.push('after-1')
}
const middleware2 = async (ctx: any, next: Function) => {
executionOrder.push('before-2')
await next()
executionOrder.push('after-2')
}
const engine = new CustomMiddlewareEngine()
.use(middleware1)
.use(middleware2)
await engine.execute({}, {})
expect(executionOrder).toEqual([
'before-1', 'before-2', 'after-2', 'after-1'
])
})
test('should handle circuit breaker correctly', async () => {
const failingMiddleware = async () => {
throw new Error('Service unavailable')
}
const engine = new CustomMiddlewareEngine()
.use(circuitBreaker({ failureThreshold: 2, recoveryTimeout: 1000 }))
.use(failingMiddleware)
// İlk failure
await expect(engine.execute({}, {})).rejects.toThrow('Service unavailable')
// İkinci failure - circuit'i açmalı
await expect(engine.execute({}, {})).rejects.toThrow('Service unavailable')
// Üçüncü request - circuit breaker tarafından bloklanmalı
await expect(engine.execute({}, {})).rejects.toThrow('Circuit breaker is open')
})
})
Geçiş Öncesi Kontroller#
Özel middleware framework’ü production trafiği taşımadan önce:
- Aynı handler için öncesi ve sonrası
Init Durationkaydedildi -
next()öncesi hata fırlatanlar dahil her middleware’in hata yolu kapsandı - Zincir sırası, sıralama değişince patlayan bir testle sabitlendi
- Yeni metrik isimleri için alarm’lar güncellendi
- Rollback, alias tekrar Middy sürümüne çevrilerek test edildi
- Hook sırası handler’ların yanında dokümante edildi
Middy Nerede Kalıyor#
Varsayılan Middy olarak kalıyor; yerine geçme gerekçesinin gösterebileceğiniz bir ölçümden gelmesi gerekir.
Rakamlar gerçekten o yönü gösteriyorsa önce yüksek trafikli tek bir handler’ı taşıyın, Middy sürümünü bir alias arkasında deploy edilebilir tutun ve filonun geri kalanına dokunmadan aynı iş yükünde Init Duration değerlerini karşılaştırın.
Kaynaklar#
- Middy.js - Resmi Dokümantasyon (yeni sekmede açılır) - AWS Lambda için Middy middleware motoru hakkında yaşam döngüsü kancaları ve resmi middleware paketleri dahil kapsamlı kılavuz
- Middy - Başlarken (yeni sekmede açılır) - Lambda handler’larını Middy ile sarmalamaya adım adım giriş
- Middy - Hata Yönetimi (yeni sekmede açılır) - onError yaşam döngüsü kancaları ve http-error-handler middleware ile hata yönetimi
- Middy - Erken Kesme (yeni sekmede açılır) - Bir middleware’in zincirin geri kalanını nasıl durdurup doğrudan yanıt döndürdüğü, cache örneğiyle
- Middy - Validator Middleware (yeni sekmede açılır) - eventSchema’nın neden derlenmiş bir ajv validator aldığı ve derlemeyi transpileSchema’nın nerede yaptığı
- Lambda çalışma ortamı yaşam döngüsü - AWS Lambda (yeni sekmede açılır) - Bir cold start’ın gerçekte neyin bedelini ödediğini belirleyen init, invoke ve shutdown fazları
- Node.js ile Lambda Fonksiyonları Oluşturma - AWS Lambda (yeni sekmede açılır) - Node.js çalışma zamanı, handler kuralları ve dağıtımı kapsayan resmi AWS dokümantasyonu
- AWS Lambda için Powertools (TypeScript) (yeni sekmede açılır) - Lambda fonksiyonlarında yapılandırılmış loglama, izleme ve doğrulama için AWS tarafından sağlanan geliştirici araç seti
AWS Lambda Middleware Uzmanlığı
Middy temellerinden production ölçeği Lambda uygulamaları için özel middleware framework'leri oluşturmaya
Bu serideki tüm yazılar
İlgili yazılar
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
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
AWS Lambda'da Node.js'den Go'ya geçiş ne zaman kendini amorti eder, ne zaman etmez: karar çerçevesi, serverless Go pattern'ları ve maliyet matematiği.
go · nodejs · serverless +5
Mimari ağırlığını runtime'ın init-amortismanına göre seç: single-purpose Lambda'da yalın handler, Lambdalith'te orta, tam OOP/DI yalnızca uzun ömürlü runtime'da.
architecture · lambda · serverless +3
AWS Lambda fonksiyonlarını nasıl bölmeli: varsayılan single-purpose, single-domain Lambdalith'i hak edilmiş istisna gör, kararı veren platform güçlerini tanı.
lambda · serverless · architecture +2