اختبار A / B المرن مع AWS Lambda @ Edge
نشرت: 2022-03-11كانت شبكات توصيل المحتوى (CDN) مثل Amazon CloudFront ، حتى وقت قريب ، جزءًا بسيطًا نسبيًا من البنية التحتية للويب. تقليديا ، تم تصميم تطبيقات الويب حولها ، حيث يتم التعامل معها في الغالب على أنها ذاكرة تخزين مؤقت سلبية بدلاً من كونها مكونًا نشطًا.
لقد غيرت Lambda @ Edge والتقنيات المماثلة كل ذلك وفتحت عالمًا كاملاً من الاحتمالات من خلال تقديم طبقة جديدة من المنطق بين تطبيق الويب ومستخدميه. Lambda @ Edge ، المتوفرة منذ منتصف عام 2017 ، هي ميزة جديدة في AWS تقدم مفهوم تنفيذ التعليمات البرمجية في شكل Lambdas مباشرة على خوادم CloudFront الحافة.
أحد الاحتمالات الجديدة التي يقدمها Lambda @ Edge هو حل نظيف لاختبار A / B من جانب الخادم. يعد اختبار A / B طريقة شائعة لاختبار أداء الأشكال المتعددة لموقع الويب من خلال عرضها في نفس الوقت على شرائح مختلفة من جمهور موقع الويب.
ماذا تعني Lambda @ Edge لاختبار A / B.
يتمثل التحدي التقني الرئيسي في اختبار A / B في تقسيم حركة المرور الواردة بشكل صحيح دون التأثير على جودة بيانات التجربة أو موقع الويب نفسه بأي شكل من الأشكال.
هناك طريقتان رئيسيتان لتنفيذه: من جانب العميل ومن جانب الخادم .
- يتضمن جانب العميل تشغيل جزء صغير من كود JavaScript في متصفح المستخدم النهائي الذي يختار المتغير الذي سيتم عرضه. هناك نوعان من الجوانب السلبية الهامة لهذا النهج - وأبرزها أنه يمكن أن يبطئ العرض ويسبب الخفقان أو مشكلات أخرى في العرض. هذا يعني أن مواقع الويب التي تسعى إلى تحسين وقت التحميل أو لديها معايير عالية لتجربة المستخدم الخاصة بها ستميل إلى تجنب هذا النهج.
- يلغي اختبار A / B من جانب الخادم معظم هذه المشكلات ، حيث يتم اتخاذ القرار بشأن المتغير الذي سيتم إرجاعه بالكامل من جانب المضيف. يعرض المتصفح ببساطة كل متغير بشكل طبيعي كما لو كان الإصدار القياسي من موقع الويب.
مع وضع ذلك في الاعتبار ، قد تتساءل لماذا لا يستخدم الجميع اختبار A / B من جانب الخادم. لسوء الحظ ، فإن نهج جانب الخادم ليس سهلاً في التنفيذ مثل نهج جانب العميل ، وغالبًا ما يتطلب إعداد تجربة نوعًا من التدخل على التعليمات البرمجية من جانب الخادم أو تكوين الخادم.
لتعقيد الأمور أكثر ، غالبًا ما يتم تقديم تطبيقات الويب الحديثة مثل SPAs كحزم من التعليمات البرمجية الثابتة مباشرة من دلو S3 دون الحاجة إلى استخدام خادم ويب. حتى عندما يكون خادم الويب متورطًا ، فغالبًا ما يكون من غير المجدي تغيير منطق جانب الخادم لإعداد اختبار A / B. يمثل وجود CDN عقبة أخرى ، حيث قد يؤثر التخزين المؤقت على أحجام المقطع أو ، على العكس من ذلك ، يمكن أن يؤدي هذا النوع من تجزئة حركة المرور إلى خفض أداء شبكة CDN.
ما تقدمه Lambda @ Edge هو طريقة لتوجيه طلبات المستخدم عبر متغيرات التجربة قبل أن تصل إلى خوادمك. يمكن العثور على مثال أساسي لحالة الاستخدام هذه مباشرةً في وثائق AWS. على الرغم من كونها مفيدة كدليل على المفهوم ، إلا أن بيئة الإنتاج ذات التجارب المتزامنة المتعددة قد تحتاج إلى شيء أكثر مرونة وقوة.
علاوة على ذلك ، بعد العمل قليلاً مع Lambda @ Edge ، من المحتمل أن تدرك أن هناك بعض الفروق الدقيقة التي يجب أن تكون على دراية بها عند بناء الهندسة المعمارية الخاصة بك.
على سبيل المثال ، يستغرق نشر Edge Lambdas وقتًا ، ويتم توزيع سجلاتهم عبر مناطق AWS. ضع في اعتبارك هذا إذا كنت بحاجة إلى تصحيح أخطاء التكوين الخاص بك لتجنب أخطاء 502.
سيعرض هذا البرنامج التعليمي مطوري AWS على طريقة لتنفيذ اختبار A / B من جانب الخادم باستخدام Lambda @ Edge بطريقة يمكن إعادة استخدامها عبر التجارب دون تعديل وإعادة نشر Edge Lambdas. إنه يعتمد على نهج المثال في وثائق AWS والبرامج التعليمية المماثلة الأخرى ، ولكن بدلاً من الترميز الثابت لقواعد تخصيص حركة المرور في Lambda نفسها ، يتم استرداد القواعد بشكل دوري من ملف تكوين على S3 يمكنك تغييره في أي وقت.
نظرة عامة على نهج اختبار Lambda @ Edge A / B
الفكرة الأساسية وراء هذا النهج هي جعل CDN يعين كل مستخدم إلى مقطع ثم يوجه المستخدم إلى تكوين الأصل المرتبط. تسمح CloudFront بالتوزيع للإشارة إلى إما S3 أو الأصول المخصصة ، وفي هذا النهج ، ندعم كليهما.
سيتم تخزين تعيين المقاطع لتجربة المتغيرات في ملف JSON على S3. تم اختيار S3 هنا من أجل البساطة ، ولكن يمكن أيضًا استردادها من قاعدة بيانات أو أي شكل آخر من أشكال التخزين التي يمكن لـ Lambda الوصول إليها.
ملاحظة: هناك بعض القيود - راجع المقالة الاستفادة من البيانات الخارجية في Lambda @ Edge على مدونة AWS لمزيد من المعلومات.
تطبيق
يمكن تشغيل Lambda @ Edge من خلال أربعة أنواع مختلفة من أحداث CloudFront:
في هذه الحالة ، سنقوم بتشغيل Lambda في كل من الأحداث الثلاثة التالية:
- طلب عارض
- طلب المنشأ
- استجابة المشاهد
سينفذ كل حدث خطوة في العملية التالية:
- abtesting-lambda-vreq : يوجد معظم المنطق في lambda. أولاً ، تتم قراءة ملف تعريف ارتباط معرف فريد أو إنشاؤه للطلب الوارد ، ثم يتم تجزئته إلى نطاق [0 ، 1]. ثم يتم جلب خريطة تخصيص حركة المرور من S3 وتخزينها مؤقتًا عبر عمليات التنفيذ. وأخيرًا ، تُستخدم القيمة المجزأة لاختيار تكوين أصل ، والذي يتم تمريره كرأس مشفر بتنسيق JSON إلى Lambda التالية.
- abtesting-lambda-oreq : يقرأ هذا تكوين الأصل من Lambda السابقة ويوجه الطلب وفقًا لذلك.
- abtesting-lambda-vres : يضيف هذا فقط رأس Set-Cookie لحفظ ملف تعريف الارتباط المعرّف الفريد في متصفح المستخدم.
لنقم أيضًا بإعداد ثلاث حاويات S3 ، سيحتوي اثنان منها على محتويات كل متغير من متغيرات التجربة ، بينما سيحتوي الثالث على ملف JSON مع خريطة تخصيص حركة المرور.
في هذا البرنامج التعليمي ، ستبدو الحاويات كما يلي:
- abtesting-ttblog -a عام
- index.html
- abtesting-ttblog-b public
- index.html
- abtesting-ttblog-map خاص
- map.json
مصدر الرمز
أولاً ، لنبدأ بخريطة تخصيص حركة المرور:
map.json
{ "segments": [ { "weight": 0.7, "host": "abtesting-ttblog-a.s3.amazonaws.com", "origin": { "s3": { "authMethod": "none", "domainName": "abtesting-ttblog-a.s3.amazonaws.com", "path": "", "region": "eu-west-1" } } }, { "weight": 0.3, "host": "abtesting-ttblog-b.s3.amazonaws.com", "origin": { "s3": { "authMethod": "none", "domainName": "abtesting-ttblog-b.s3.amazonaws.com", "path": "", "region": "eu-west-1" } } } ] }
كل شريحة لها وزن حركة المرور ، والتي سيتم استخدامها لتخصيص كمية مقابلة من حركة المرور. نقوم أيضًا بتضمين التكوين الأصلي والمضيف. يتم وصف تنسيق التكوين الأصلي في وثائق AWS.
abtesting-lambda-vreq
'use strict'; const aws = require('aws-sdk'); const COOKIE_KEY = 'abtesting-unique-id'; const s3 = new aws.S3({ region: 'eu-west-1' }); const s3Params = { Bucket: 'abtesting-ttblog-map', Key: 'map.json', }; const SEGMENT_MAP_TTL = 3600000; // TTL of 1 hour const fetchSegmentMapFromS3 = async () => { const response = await s3.getObject(s3Params).promise(); return JSON.parse(response.Body.toString('utf-8')); } // Cache the segment map across Lambda invocations let _segmentMap; let _lastFetchedSegmentMap = 0; const fetchSegmentMap = async () => { if (!_segmentMap || (Date.now() - _lastFetchedSegmentMap) > SEGMENT_MAP_TTL) { _segmentMap = await fetchSegmentMapFromS3(); _lastFetchedSegmentMap = Date.now(); } return _segmentMap; } // Just generate a random UUID const getRandomId = () => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); }; // This function will hash any string (our random UUID in this case) // to a [0, 1) range const hashToInterval = (s) => { let hash = 0, i = 0; while (i < s.length) { hash = ((hash << 5) - hash + s.charCodeAt(i++)) << 0; } return (hash + 2147483647) % 100 / 100; } const getCookie = (headers, cookieKey) => { if (headers.cookie) { for (let cookieHeader of headers.cookie) { const cookies = cookieHeader.value.split(';'); for (let cookie of cookies) { const [key, val] = cookie.split('='); if (key === cookieKey) { return val; } } } } return null; } const getSegment = async (p) => { const segmentMap = await fetchSegmentMap(); let weight = 0; for (const segment of segmentMap.segments) { weight += segment.weight; if (p < weight) { return segment; } } console.error(`No segment for value ${p}. Check the segment map.`); } exports.handler = async (event, context, callback) => { const request = event.Records[0].cf.request; const headers = request.headers; let uniqueId = getCookie(headers, COOKIE_KEY); if (uniqueId === null) { // This is what happens on the first visit: we'll generate a new // unique ID, then leave it the cookie header for the // viewer response lambda to set permanently later uniqueId = getRandomId(); const cookie = `${COOKIE_KEY}=${uniqueId}`; headers.cookie = headers.cookie || []; headers.cookie.push({ key: 'Cookie', value: cookie }); } // Get a value between 0 and 1 and use it to // resolve the traffic segment const p = hashToInterval(uniqueId); const segment = await getSegment(p); // Pass the origin data as a header to the origin request lambda // The header key below is whitelisted in Cloudfront const headerValue = JSON.stringify({ host: segment.host, origin: segment.origin }); headers['x-abtesting-segment-origin'] = [{ key: 'X-ABTesting-Segment-Origin', value: headerValue }]; callback(null, request); };
هنا ، نقوم بشكل صريح بإنشاء معرف فريد لهذا البرنامج التعليمي ، ولكن من الشائع جدًا أن تحتوي معظم مواقع الويب على معرف عميل آخر يمكن استخدامه بدلاً من ذلك. سيؤدي هذا أيضًا إلى إلغاء الحاجة إلى استجابة المشاهد Lambda.

لاعتبارات الأداء ، يتم تخزين قواعد تخصيص حركة المرور مؤقتًا عبر استدعاءات Lambda بدلاً من جلبها من S3 عند كل طلب. في هذا المثال ، قمنا بإعداد TTL للذاكرة المؤقتة لمدة ساعة واحدة.
لاحظ أنه يجب إدراج رأس X-ABTesting-Segment-Origin
في القائمة البيضاء في CloudFront ؛ خلاف ذلك ، سيتم مسحه من الطلب قبل وصوله إلى طلب الأصل Lambda.
abtesting-lambda-oreq
'use strict'; const HEADER_KEY = 'x-abtesting-segment-origin'; // Origin Request handler exports.handler = (event, context, callback) => { const request = event.Records[0].cf.request; const headers = request.headers; const headerValue = headers[HEADER_KEY] && headers[HEADER_KEY][0] && headers[HEADER_KEY][0].value; if (headerValue) { const segment = JSON.parse(headerValue); headers['host'] = [{ key: 'host', value: segment.host }]; request.origin = segment.origin; } callback(null, request); };
طلب الأصل Lambda واضح جدًا. تتم قراءة التكوين الأصلي والمضيف من رأس X-ABTesting-Origin
الذي تم إنشاؤه في الخطوة السابقة وتم إدخاله في الطلب. هذا يوجه CloudFront لتوجيه الطلب إلى الأصل المقابل في حالة فقدان ذاكرة التخزين المؤقت.
abtesting-lambda-vres
'use strict'; const COOKIE_KEY = 'abtesting-unique-id'; const getCookie = (headers, cookieKey) => { if (headers.cookie) { for (let cookieHeader of headers.cookie) { const cookies = cookieHeader.value.split(';'); for (let cookie of cookies) { const [key, val] = cookie.split('='); if (key === cookieKey) { return val; } } } } return null; } const setCookie = function (response, cookie) { console.log(`Setting cookie ${cookie}`); response.headers['set-cookie'] = response.headers['set-cookie'] || []; response.headers['set-cookie'] = [{ key: "Set-Cookie", value: cookie }]; } exports.handler = (event, context, callback) => { const request = event.Records[0].cf.request; const headers = request.headers; const response = event.Records[0].cf.response; const cookieVal = getCookie(headers, COOKIE_KEY); if (cookieVal != null) { setCookie(response, `${COOKIE_KEY}=${cookieVal}`); callback(null, response); return; } console.log(`no ${COOKIE_KEY} cookie`); callback(null, response); }
أخيرًا ، استجابة العارض Lambda مسؤولة عن إعادة ملف تعريف الارتباط المعرّف الفريد الذي تم إنشاؤه في رأس Set-Cookie
. كما ذكرنا أعلاه ، إذا تم استخدام معرّف عميل فريد بالفعل ، فيمكن حذف Lambda تمامًا.
في الواقع ، حتى في هذه الحالة ، يمكن تعيين ملف تعريف الارتباط مع إعادة توجيه بواسطة طلب العارض Lambda. ومع ذلك ، قد يضيف هذا بعض وقت الاستجابة ، لذلك في هذه الحالة ، نفضل القيام بذلك في دورة استجابة طلب واحدة.
يمكن أيضًا العثور على الرمز على GitHub.
أذونات Lambda
كما هو الحال مع أي حافة Lambda ، يمكنك استخدام مخطط CloudFront عند إنشاء Lambda. بخلاف ذلك ، ستحتاج إلى إنشاء دور مخصص وإرفاق نموذج سياسة "أذونات Lambda @ Edge الأساسية".
بالنسبة لطلب العارض Lambda ، ستحتاج أيضًا إلى السماح بالوصول إلى حاوية S3 التي تحتوي على ملف تخصيص حركة المرور.
نشر Lambdas
يختلف إعداد Lambdas إلى حد ما عن سير عمل Lambda القياسي. في صفحة تكوين Lamba ، انقر فوق "إضافة مشغل" وحدد CloudFront. سيؤدي هذا إلى فتح مربع حوار صغير يسمح لك بربط Lambda بتوزيع CloudFront.
حدد الحدث المناسب لكل من Lambdas الثلاثة واضغط على "Deploy". سيبدأ هذا عملية نشر رمز الوظيفة على خوادم CloudFront الطرفية.
ملاحظة: إذا كنت بحاجة إلى تعديل حافة Lambda وإعادة نشرها ، فأنت بحاجة إلى نشر إصدار جديد يدويًا أولاً.
إعدادات CloudFront
لكي يكون توزيع CloudFront قادرًا على توجيه حركة المرور إلى الأصل ، ستحتاج إلى إعداد كل منها على حدة في لوحة الأصول.
إعداد التكوين الوحيد الذي ستحتاج إلى تغييره هو إضافة رأس X-ABTesting-Segment-Origin
إلى القائمة البيضاء. في وحدة تحكم CloudFront ، اختر التوزيع الخاص بك ثم اضغط على "تعديل" لتغيير إعدادات التوزيع.
في صفحة تحرير السلوك ، حدد القائمة البيضاء من القائمة المنسدلة في خيار ذاكرة التخزين المؤقت استنادًا إلى رؤوس الطلبات المحددة وأضف رأس X-ABTesting-Segment-Origin مخصص إلى القائمة:
إذا قمت بنشر Edge Lambdas كما هو موضح في القسم السابق ، فيجب أن تكون مرتبطة بالفعل بالتوزيع الخاص بك ويتم سردها في القسم الأخير من صفحة تحرير السلوك .
حل جيد مع محاذير بسيطة
يمكن أن يكون اختبار A / B من جانب الخادم أمرًا صعبًا للتنفيذ بشكل صحيح لمواقع الويب عالية الحركة التي يتم نشرها خلف خدمات CDN مثل CloudFront. في هذه المقالة ، أوضحنا كيف يمكن استخدام Lambda @ Edge كحل جديد لهذه المشكلة عن طريق إخفاء تفاصيل التنفيذ في شبكة CDN نفسها ، مع تقديم حل نظيف وموثوق لتشغيل تجارب A / B.
ومع ذلك ، فإن Lambda @ Edge لها بعض العيوب. الأهم من ذلك ، أن استدعاءات Lambda الإضافية بين أحداث CloudFront يمكن أن تُضاف من حيث زمن الوصول والتكلفة ، لذلك يجب قياس تأثيرها على توزيع CloudFront بعناية أولاً.
علاوة على ذلك ، تعد Lambda @ Edge ميزة حديثة نسبيًا ولا تزال متطورة من AWS ، لذلك بطبيعة الحال ، لا تزال تشعر ببعض الخشونة حول الحواف. قد لا يزال المستخدمون الأكثر تحفظًا يرغبون في الانتظار بعض الوقت قبل وضعه في مثل هذه النقطة الحرجة من بنيتهم التحتية.
ومع ذلك ، فإن الحلول الفريدة التي تقدمها تجعلها ميزة لا غنى عنها لشبكات CDN ، لذلك ليس من غير المعقول أن نتوقع أن يتم تبنيها على نطاق واسع في المستقبل.