İçeriğe atla
Ayhan Sipahi Ayhan Sipahi

AppSync Subscription'larını AppSync Dışından Tetiklemek

AppSync subscription'ları yalnızca mutation ile tetiklenir. Downstream BFF olaylarını NONE veri kaynaklı bir mutation'a EventBridge ve CDK ile köprülemeyi inceliyorum.

AWS AppSync size yönetilen WebSocket subscription’ları verir, ama bunlar yalnızca bir AppSync GraphQL mutation’ına yanıt olarak tetiklenir. Çok servisli bir Backend-for-Frontend (BFF) içinde asıl durum downstream’de, yani sipariş, stok ve ödeme servislerinde tutulur ve bu durum değişiklikleri hiçbir zaman bir AppSync mutation’ından geçmez. Yani bir BFF’te “subscription yönetmek” aslında tek bir problemdir: downstream olaylarını NONE veri kaynaklı dahili bir mutation’a köprülemek ve sonra payload’ı AppSync’in fan-out yapmasına bırakmak. Aşağıdaki kurulum AWS CDK ile yapılan bir incelemedir; proof of concept yayından sonra çalışacağı için gelecek zamanla anlatılıyor ve burada henüz ölçülmüş hiçbir şey yok.

Saf zihinsel model şudur: bir alana subscribe olursunuz ve AppSync veritabanı değişikliklerini size akıtır. Bu model yanlıştır ve dokümanlar bu konuda nettir: “AWS AppSync’te subscription’lar bir mutation’a yanıt olarak çağrılır.” Veritabanı-değişikliğinden-subscription’a giden bir yol yoktur. Bir şeyin bir mutation çağırması gerekir. AppSync resolver, veri kaynağı ve caching temelleri için production AppSync rehberine bakın; bu temeller burada bilindiği varsayılır ve odak köprüde kalır.

Subscription’lar neden mutation’a bağlıdır

Bir subscription alanı, dinlediği mutation’ları adlandıran bir @aws_subscribe direktifi taşır:

type Mutation {
  publishOrderUpdate(input: OrderUpdateInput!): OrderUpdate
    @aws_iam
}

type Subscription {
  onOrderUpdate(orderId: ID): OrderUpdate
    @aws_subscribe(mutations: ["publishOrderUpdate"])
}

Subscription alanının kendisi hiçbir veri kaynağı adlandırmaz. Dinlediği mutation resolver’ı taşır ve AppSync, mutation’ın selection set’ini abonelere gönderir. Transport, client ile servis arasında saf WebSocket’tir; eski MQTT-over-WebSocket protokolü 1 Ocak 2022’de kaldırıldı, dolayısıyla hâlâ MQTT’den bahseden her rehber güncelliğini yitirmiştir.

Tetikleyici her zaman bir mutation olduğundan, bir aboneye ulaşmak isteyen downstream bir olay sonunda bir mutation çağırmak zorundadır. Bu tek olgu tüm deseni şekillendirir.

Köprü deseni

Bu, AWS’nin belgelediği tarif; birçok servisin olay yayınladığı bir BFF için genelleştirilmiş hâli. AWS re:Post soruyu doğrudan ortaya koyar: client tarafı mutation’larının yapmadığı dış güncellemelerden aboneleri nasıl haberdar edersiniz? Yanıt dört adımlı bir köprüdür.

  1. Downstream bir servis bir olay yayınlar. re:Post makalesi bir DynamoDB stream kullanır; çok servisli bir BFF için EventBridge’i tercih edin ki herhangi bir servis AppSync’e bağlanmadan yayın yapabilsin.
  2. EventBridge (doğrudan ya da bir Lambda üzerinden) dahili bir AppSync mutation’ı çağırır. AppSync’in aboneleri bilgilendirmesine neden olan budur.
  3. O mutation alanının, NONE veri kaynaklı bir local resolver’ı vardır. Resolver yalnızca argümanlarını yansıtır; alan çözümlemesi AppSync’ten hiç çıkmaz.
  4. Bir subscription alanı, @aws_subscribe aracılığıyla o mutation’a subscribe olur ve AppSync payload’ı fan-out yapar.

NONE veri kaynağı anahtardır. Dokümanlar local resolver’ı “yalnızca request handler’ın sonucunu response handler’a ileten” bir resolver olarak tanımlar ve en yaygın kullanım senaryosunu “bir veri kaynağı çağrısı tetiklemeden bildirim yayınlamak” olarak adlandırır. Tetikleyici mutation saf bir echo olmalıdır; içine bir veri deposuna yazma koyarsanız çift yazma ve drift yaratırsınız.

// resolvers/publishOrderUpdate.js  (NONE veri kaynağı, JS runtime)
export function request(ctx) {
  return { payload: ctx.args.input };
}

export function response(ctx) {
  return ctx.result;
}
Aboneler (onOrderUpdate)AppSync (publishOrderUpdate, NONE resolver)EventBridgeSiparis ServisiAboneler (onOrderUpdate)AppSync (publishOrderUpdate, NONE resolver)EventBridgeSiparis Servisilocal resolver argumanlari yansitir, veri kaynagi cagrisi yokOrderStatusChanged yayinlapublishOrderUpdate mutation cagirenhanced filtrelerle fan-out

Tetikleyici bacak: önce native target, fallback olarak Lambda

Tetikleyici bacak, en ilginç pratik gerilimin yaşandığı yerdir. CDK README’si o mutation’ı çağırmanın iki yolunu belgeler ve doğru varsayılan, en az kodu olan yoldur.

Seçenek A, native EventBridge target. aws-cdk-lib/aws-events-targets paketi, bir EventBridge kuralından doğrudan bir GraphQL operasyonu çağıran bir targets.AppSync target’ı sunar. Yolda hiç Lambda yoktur. README, API’nin “AWS_IAM yetkilendirme moduyla yapılandırılması gerektiğini” ve target’ın graphQLEndpointArn’a dayandığını belirtir.

import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';

rule.addTarget(
  new targets.AppSync(api, {
    graphQLOperation:
      'mutation Publish($input: OrderUpdateInput!) { publishOrderUpdate(input: $input) { orderId status } }',
    variables: events.RuleTargetInput.fromObject({
      input: {
        orderId: events.EventField.fromPath('$.detail.orderId'),
        status: events.EventField.fromPath('$.detail.status'),
      },
    }),
  }),
);

Olay payload’ı olduğu gibi kullanılabilir durumdaysa, köprünün tamamı budur. Deploy edilecek bir fonksiyon, cold start ya da test edilecek fazladan bir kod yolu yoktur.

Seçenek B, Lambda tetikleyici. Bir Lambda’ya yalnızca olay ile mutation arasında kod gerektiğinde başvurun: payload’ı toplamak ya da dönüştürmek, özel auth mantığı uygulamak veya bir olayı birkaç mutation’a fan-out etmek. Lambda, AppSync endpoint’ine SigV4 ile imzalanmış bir GraphQL çağrısı yapar ve execution role’unun belirli mutation alanına scope’lanmış appsync:GraphQL iznine ihtiyacı vardır.

Hayir, ham cift yonlu

Hayir, cihaz olcekli fan-out

Evet

Evet

Hayir: transform, fan-out, ozel auth

Downstream olay

Clientlar GraphQL konusuyor ve olay bir mutation a eslesiyor mu?

API Gateway WebSocket

AWS IoT Core

Payload oldugu gibi kullanilabilir mi?

Native EventBridge to AppSync target

Lambda tetikleyici, SigV4 cagri

Trade-off küçük ama gerçek. Native target tüm bir hareketli parçayı ortadan kaldırır; varsayılan olmasının nedeni budur. Lambda size mantık çalıştıracak bir yer kazandırır, karşılığında sahiplenilecek bir fonksiyon, bir cold start yolu ve sıkıca scope’lanması gereken bir IAM role getirir. Native ile başlayın; bir olay gerçekten mutation değişkenlerine sığdırılamadığında Lambda’yı ekleyin.

CDK kurulumu

API, sunucu tarafı tetikleyicinin mutation’ı çağırabilmesi için varsayılan yetkilendirme modu olarak IAM kullanır ve client trafiği için ek modlar olarak Cognito veya API key ekler. Bir şema eklemenin güncel yolu Definition.fromFile’dır; eski schema: prop’unun yerini almıştır.

const api = new appsync.GraphqlApi(this, 'BffApi', {
  name: 'bff',
  definition: appsync.Definition.fromFile(
    path.join(__dirname, 'schema.graphql'),
  ),
  authorizationConfig: {
    defaultAuthorization: { authorizationType: appsync.AuthorizationType.IAM },
    // client trafigi icin additionalAuthorizationModes olarak Cognito / API key ekle
  },
  xrayEnabled: true,
});

Bunu bir BFF yapan toplama (aggregation) query resolver’ları bir Direct Lambda veri kaynağı kullanır; bu, platform kitlesi için temiz okunur:

const aggregateFn = new lambda.Function(this, 'AggregateFn', {
  /* runtime, handler, code */
});
const aggregateDs = api.addLambdaDataSource('aggregateDs', aggregateFn);

aggregateDs.createResolver('GetOrderResolver', {
  typeName: 'Query',
  fieldName: 'order',
});

Bir Direct Lambda veri kaynağı mapping template gerektirmez: AppSync bir request template, bir response template ya da hiçbirini sunmanıza izin verir ve hiçbiri verilmediğinde tüm context’i fonksiyona geçirip sonucunu olduğu gibi döndürür. Yukarıdaki createResolver çağrısının yaptığı tam olarak budur; MappingTemplate.lambdaRequest() / lambdaResult()’a yalnızca AppSync ile fonksiyon arasında payload’ı yeniden şekillendirmeniz gerektiğinde başvurun.

Tetikleyici mutation, bir NONE veri kaynağı ve daha önce gösterilen JS local resolver’ı alır:

const noneDs = api.addNoneDataSource('publishDs');

noneDs.createResolver('PublishOrderUpdateResolver', {
  typeName: 'Mutation',
  fieldName: 'publishOrderUpdate',
  runtime: appsync.FunctionRuntime.JS_1_0_0,
  code: appsync.Code.fromAsset(
    path.join(__dirname, 'resolvers/publishOrderUpdate.js'),
  ),
});

README’nin JS örnekleri çoğunlukla pipeline resolver’ları gösterir (AppsyncFunction artı pipelineConfig’li Resolver). createResolver({ runtime, code }) üzerinden bir unit (pipeline olmayan) JS resolver, bu NONE tetikleyici için güncel L2 tarafından kabul edilebilir ya da edilmeyebilir. Her iki şekil de belgeli olduğundan plan, önce unit formu denemek ve cdk synth reddederse pipeline şekline geri dönmek:

// Unit JS resolver kabul edilmezse fallback:
const publishFn = new appsync.AppsyncFunction(this, 'PublishFn', {
  api,
  dataSource: noneDs,
  name: 'publishOrderUpdate',
  runtime: appsync.FunctionRuntime.JS_1_0_0,
  code: appsync.Code.fromAsset(
    path.join(__dirname, 'resolvers/publishOrderUpdate.js'),
  ),
});
new appsync.Resolver(this, 'PublishResolver', {
  api,
  typeName: 'Mutation',
  fieldName: 'publishOrderUpdate',
  runtime: appsync.FunctionRuntime.JS_1_0_0,
  code: appsync.Code.fromInline(
    'export function request(){return {}} export function response(ctx){return ctx.prev.result}',
  ),
  pipelineConfig: [publishFn],
});

Sunucu tarafı mutation için IAM

Client’a bakan alanlar Cognito veya API key modunu korur; tetikleyici mutation IAM’e izin vermelidir. Alanı şemada @aws_iam ile işaretleyin (daha önce gösterildi), sonra çağırıcıya o alanın ARN’sine scope’lanmış appsync:GraphQL izni verin. CDK, hiçbir ARN’yi elle yazmamak için grant yardımcıları sunar:

// native EventBridge target'in role'u ya da Lambda tetikleyicinin role'u icin
api.grantMutation(triggerRole, 'publishOrderUpdate');

// granular karsiligi:
api.grant(
  triggerRole,
  appsync.IamResource.ofType('Mutation', 'publishOrderUpdate'),
  'appsync:GraphQL',
);

Bu yardımcıların ürettiği temel policy, aksiyonu tek bir alana scope’lar:

{
  "Effect": "Allow",
  "Action": ["appsync:GraphQL"],
  "Resource": [
    "arn:aws:appsync:REGION:ACCOUNT_ID:apis/GRAPHQL_ID/types/Mutation/fields/publishOrderUpdate"
  ]
}

Auth modu uyuşmazlığı yaygın bir tuzaktır. Client’lar Cognito veya bir API key ile kimlik doğrular; sunucu IAM ve SigV4 ile doğrular. Multi-auth yapılandırıldığında, tetikleyici alan hem client modunu hem IAM’i kabul etmelidir; aksi hâlde sunucu tarafı mutation, client’ın bakış açısından sessizce başarısız olur.

Tetikleyici bacağı sağlamlaştırmak: retry ve dead-letter queue

Sessiz başarısızlık riski bir pitfall maddesinden fazlasını hak ediyor. Tetikleyici bacak bir olayı düşürdüğünde hiçbir abone bir hata görmez; güncelleme yalnızca hiç gelmez. Güvenlik ağı, her EventBridge target’ının kabul ettiği retry policy ve dead-letter queue’dur; bu yüzden ikisini de, ilk kayıp güncellemeden sonra sonradan eklemek yerine, tetikleyici target’a en baştan bağlamayı planlıyorum.

const triggerDlq = new sqs.Queue(this, 'PublishTriggerDlq');

rule.addTarget(
  new targets.AppSync(api, {
    graphQLOperation:
      'mutation Publish($input: OrderUpdateInput!) { publishOrderUpdate(input: $input) { orderId status } }',
    variables: events.RuleTargetInput.fromObject({ /* olaydan eslenir */ }),
    retryAttempts: 5,
    maxEventAge: Duration.hours(2),
    deadLetterQueue: triggerDlq,
  }),
);

İki başarısızlık modu farklı davranır ve dokümanlar bu konuda nettir. Geçici bir başarısızlık retryAttempts’e kadar, ya da maxEventAge dolana dek, backoff ile yeniden denenir; ancak o zaman olay DLQ’ya düşer. Diğer hatalar retry’ı tümüyle atlar: eksik izinler, artık var olmayan bir target ya da geçersiz bir endpoint doğrudan DLQ’ya gider, çünkü temeldeki sorun düzeltilene kadar retry bir işe yaramaz. DLQ standart bir SQS kuyruğudur (FIFO desteklenmez) ve dead-letter’a düşen her mesaj hata kodunu, tükenen-retry koşulunu, retry deneme sayısını ve rule ile target ARN’lerini taşır; bu da kötü bir IAM grant’ini gerçek bir downstream kesintisinden ayırmaya yeter.

Lambda fallback yolu kendi katmanını ekler: fonksiyondaki bir on-failure destination ya da dead-letter queue, EventBridge olayı devrettikten sonra fonksiyon içinde başarısız olanı yakalar. Her iki durumda da aboneye dönük bir başarısızlık yalnızca sunucu tarafında sinyal verir; bu yüzden plan, rule’un failed-invocation metriği ve DLQ derinliği üzerine bir CloudWatch alarmı, sebep düzeldikten sonra da bir redrive. Bu olmadan, bozuk bir tetikleyici bacak tam olarak sessiz bir sisteme benzer.

Subscription’ları yönetmek: client başına yönlendirme

Filtre olmadan, bağlı her client her olayı alır. Bu hem bir firehose hem bir maliyet problemidir, çünkü iletilen her mesaj bir real-time update olarak faturalandırılır. Enhanced subscription filtreleri bunu çözer. Filtreler, subscription alanına bağlı bir resolver’ın (NONE veri kaynağı) response handler’ında JSON olarak yazılır ve extensions.setSubscriptionFilter() kullanılır:

import { util, extensions } from '@aws-appsync/utils';

export function request(ctx) {
  return { payload: null };
}

export function response(ctx) {
  const filter = {
    or: [
      { orderId: { eq: ctx.args.orderId } },
      { status: { in: ['shipped', 'delivered'] } },
    ],
  };
  extensions.setSubscriptionFilter(
    util.transform.toSubscriptionFilter(filter),
  );
  return null; // enhanced filtreler kullanilirken zorunlu
}

Tek bir filtre içinde kurallar AND ile birleşir; bir gruptaki filtreler arasında OR ile birleşir. Enhanced filtreleme ve basic (yalnızca argümana dayalı) filtreleme birbirini dışlar: resolver bir enhanced filtre ayarladığında, AppSync artık argüman tabanlı eşleştirme uygulamaz. Bu da subscription resolver’ını, her client’ın ne göreceğine karar veren tek yer yapar; yetkilendirme için istediğiniz tam olarak budur:

  • Yetkilendirme kısıtı. Çağırıcının kimliğini resolver’da okuyun ve kullanıcı id’sini filtreye enjekte edin ki bir client yalnızca kendi siparişlerini alsın.
  • Client’a bağlı daraltma. Bir filter: String argümanı açın, client’ın bir JSON string geçirmesine izin verin ve bunu resolver içinde yetkilendirme kısıtıyla birleştirin. Birleştirmeyi resolver sahiplendiği için, client akışı daraltabilir ama backend’in izin verdiğinin ötesine asla genişletemez.

Kullanıcı başına yetkilendirme için subscription resolver’ı ctx.identity’yi okur ve kullanıcı id’sini filtreye enjekte eder. Cognito user pool’ları altında bu alan ctx.identity.sub’tur, yani kimliği doğrulanmış kullanıcının UUID’si. Asimetriye dikkat: abone yolu Cognito, ama tetikleyici yol IAM ve bir IAM kimliğinde sub yoktur — bunun yerine username, userArn ve cognitoIdentityId sunar.

İçselleştirmeye değer bir incelik: bir subscription’da owner: null geçmek, sahibi olmayan öğelere kaydolur; bu, owner’ı tamamen atlamaktan (tüm öğeler) farklıdır. Sessizce yanlış dilimi isteyen bir client filtresi yazmak kolaydır.

Maliyet ve limitler

AppSync GraphQL fiyatlandırması, EC2 oranlarındaki veri transferine ek olarak üç bileşene sahiptir. Tetikleyici mutation bir veri-değişikliği operasyonu olarak faturalandırılır ve bir mesaj alan her abone bir real-time update sayılır; dolayısıyla fan-out o satırı çoğaltır.

BileşenOranNeyin sayıldığı
Query ve veri değişikliği operasyonlarımilyon başına $4.00Tetikleyici mutation dâhil tüm mutation’lar
Real-time update’lermilyon başına $2.00Her dışa giden yayın mesajı ve WebSocket operasyonu; fan-out bunu çoğaltır
Bağlantı-dakikalarımilyon dakika başına $0.08Bir client’ın WebSocket’i açık tuttuğu süre

AWS, fiyatlandırma sayfasında işlenmiş bir örnek yayınlar: aylık 2.500 aktif kullanıcı, her biri 1.500 bağlı dakika, ayda 1.000 gönderilen ve 1.000 alınan mesaj olan bir sohbet uygulaması toplamda $10.00 operasyon + $0.21 veri transferi + $5.00 real-time + $0.30 bağlanabilirlik = ayda $15.51 eder. Bunu AWS’nin gösterimi olarak görün, belirli bir BFF için projeksiyon olarak değil.

Tasarımda hesaba katılacak birkaç belgeli limit: subscription mesaj payload’ı mesaj başına 240 KB ile sınırlıdır ve ayarlanamaz; request yürütme süresi 30 saniyedir; handler kod boyutu 32 KB’dir; değerlendirilen resolver yanıtı 5 MB’dir; şema belgesi 1 MB’dir. Ayrıca client bağlantısı başına varsayılan 200 subscription sınırı vardır ve bu ayarlanabilir. Kotalar tablosu hesap başına eşzamanlı subscription için bir tavan yayınlamaz, dolayısıyla orada birini varsaymayın.

Sık yapılan hatalar

  • Tetikleyici mutation içinde bir veri deposuna yazmak. NONE resolver saf bir echo olmalıdır. Buradaki bir yazma, downstream doğru kaynağına karşı çift yazma ve veri drift’i yaratır.
  • Client başına filtreleri unutmak. O zaman her client her olayı alır; bu hem bir firehose hem bir real-time-update maliyet patlamasıdır, çünkü her iletim faturalandırılır.
  • Yanlış scope’lanmış IAM. appsync:GraphQL alanın ARN’sini hedeflemeli ve alan, sunucu çağırıcı için @aws_iam taşımalıdır. Multi-auth ile alan hem client modunu hem IAM’i kabul etmelidir.
  • Sessiz tetikleyici-bacak başarısızlığı. Düşen bir olay hiçbir aboneye ulaşmaz ve client tarafında hata oluşturmaz; dolayısıyla daha önce ele alınan retry policy ve dead-letter queue tek güvenlik ağıdır.
  • Ürünleri karıştırmak. Channel namespace’li appsync.EventApi ayrı bir pub/sub ürünüdür. Onun addChannelNamespace snippet’lerini bir GraphqlApi kurulumuna yapıştırmayın; arama sonuçlarında benzer görünürler.
  • Subscription resolver’ından null olmayan bir değer döndürmek enhanced filtreler açıkken. return null olmalıdır.

Legacy üzerine bir not: güncel AWS tutorial’ları hem NONE local resolver hem enhanced filtreler için JS @aws-appsync/utils runtime’ını kullanır. Birçok topluluk yazısı ve eski AWS sayfası hâlâ $util.toJson($context.arguments) gibi VTL gösterir. VTL hâlâ çalışır, ama önerilen yol JS’tir (FunctionRuntime.JS_1_0_0).

Kapanış

Client’ların zaten GraphQL konuştuğu ve downstream olayların domain mutation’ları olarak modellenebildiği bir AppSync BFF için köprü deseni doğru varsayılandır: olayları EventBridge üzerinden NONE veri kaynaklı bir mutation’a yönlendirin ve client başına yönlendirmeyi enhanced filtrelere bırakın, çalıştırılacak ikinci bir WebSocket yığını olmadan. O köprü içinde native EventBridge to AppSync target ile başlayın ve yalnızca payload dönüşümü, çoklu-mutation fan-out’u ya da özel auth gerektiğinde bir Lambda tetikleyici ekleyin. Sınır nettir: push doğal olarak bir mutation değilse ya da client’lar GraphQL client’ı değilse, API Gateway WebSocket ham çift yönlü mesajlaşma verir; cihaz ölçeğinde topic-tree fan-out’ta, AWS IoT Core amaca özel araçtır. Sonraki adım, herhangi bir deploy’dan önce yukarıda hâlâ açık olan tek soruyu — NONE tetikleyicinin bir unit JS resolver mı aldığı yoksa pipeline şekline mi ihtiyaç duyduğunu — cdk synth ile sabitlemek.

Kaynaklar

İlgili yazılar