ما الذي يجعل OpenResty مميزًا جدًا؟

API7.ai

October 14, 2022

OpenResty (NGINX + Lua)

في المقالات السابقة، تعلمت عن الركيزتين الأساسيتين لـ OpenResty: NGINX وLuaJIT، وأنا متأكد من أنك مستعد لبدء تعلم واجهات برمجة التطبيقات (APIs) التي يوفرها OpenResty.

ولكن لا تتعجل. قبل أن تفعل ذلك، تحتاج إلى قضاء بعض الوقت الإضافي للتعرف على مبادئ ومفاهيم OpenResty الأساسية.

المبادئ

Diagram1

تحتوي عمليات Master وWorker في OpenResty على LuaJIT VM، والذي يتم مشاركته من قبل جميع الكوروتينات داخل نفس العملية، ويتم تشغيل كود Lua فيه.

وفي نفس اللحظة الزمنية، يمكن لكل عملية Worker معالجة طلبات من مستخدم واحد فقط، مما يعني أن كوروتين واحد فقط يعمل. قد يكون لديك سؤال: بما أن NGINX يمكنه دعم C10K (عشرات الآلاف من التزامن)، ألا يحتاج إلى معالجة 10,000 طلب في نفس الوقت؟

بالطبع لا. يستخدم NGINX epoll لدفع الأحداث لتقليل الانتظار والتوقف عن العمل بحيث يمكن استخدام أكبر قدر ممكن من موارد وحدة المعالجة المركزية (CPU) لمعالجة طلبات المستخدمين. بعد كل شيء، يتم تحقيق الأداء العالي فقط عندما تتم معالجة الطلبات الفردية بسرعة كافية. إذا تم استخدام وضع متعدد الخيوط بحيث يتوافق طلب واحد مع خيط واحد، فإنه مع C10K، يمكن استنفاد الموارد بسهولة.

على مستوى OpenResty، تعمل كوروتينات Lua بالتزامن مع آلية أحداث NGINX. إذا حدثت عملية I/O مثل استعلام قاعدة بيانات MySQL في كود Lua، فإنها ستقوم أولاً باستدعاء yield للكوروتين Lua لتعليق نفسه ثم تسجيل رد اتصال في NGINX؛ بعد اكتمال عملية I/O (والتي يمكن أن تكون أيضًا مهلة زمنية أو خطأ)، سيتم استدعاء رد الاتصال resume في NGINX لإيقاظ كوروتين Lua. هذا يكمل التعاون بين التزامن في Lua ودفع الأحداث في NGINX، مما يتجنب كتابة ردود الاتصال في كود Lua.

يمكننا النظر إلى الرسم التالي الذي يصف العملية بأكملها. كل من lua_yield وlua_resume هما جزء من lua_CFunction المقدم من Lua.

Diagram2

من ناحية أخرى، إذا لم تكن هناك عمليات I/O أو sleep في كود Lua، مثل جميع عمليات التشفير وفك التشفير المكثفة، فإن LuaJIT VM سيتم احتلاله بواسطة كوروتين Lua حتى يتم معالجة الطلب بأكمله.

لقد قدمت مقتطفًا من الكود المصدري لـ ngx.sleep أدناه لمساعدتك على فهم هذا بشكل أكثر وضوحًا. يقع هذا الكود في ngx_http_lua_sleep.c، والذي يمكنك العثور عليه في دليل src لمشروع lua-nginx-module.

في ngx_http_lua_sleep.c، يمكننا رؤية التنفيذ الملموس لوظيفة sleep. يجب عليك أولاً تسجيل واجهة برمجة التطبيقات Lua ngx.sleep مع الدالة C ngx_http_lua_ngx_sleep.

void ngx_http_lua_inject_sleep_api(lua_State *L)
{
     lua_pushcfunction(L, ngx_http_lua_ngx_sleep);
     lua_setfield(L, -2, "sleep");
}

التالي هو الدالة الرئيسية لـ sleep، وقد قمت باستخراج بضعة أسطر فقط من الكود الرئيسي هنا.

static int ngx_http_lua_ngx_sleep(lua_State *L)
{
    coctx->sleep.handler = ngx_http_lua_sleep_handler;
    ngx_add_timer(&coctx->sleep, (ngx_msec_t) delay);
    return lua_yield(L, 0);
}

كما ترى:

  • هنا يتم إضافة دالة رد الاتصال ngx_http_lua_sleep_handler أولاً.
  • ثم يتم استدعاء ngx_add_timer، وهي واجهة مقدمة من NGINX، لإضافة مؤقت إلى حلقة أحداث NGINX.
  • أخيرًا، يتم استخدام lua_yield لتعليق التزامن في Lua، مما يعطي السيطرة إلى حلقة أحداث NGINX.

يتم تشغيل دالة رد الاتصال ngx_http_lua_sleep_handler عند اكتمال عملية النوم. تقوم باستدعاء ngx_http_lua_sleep_resume وفي النهاية تستيقظ كوروتين Lua باستخدام lua_resume. يمكنك استرداد تفاصيل الاستدعاء بنفسك في الكود حتى لا أتطرق إلى التفاصيل هنا.

ngx.sleep هو مجرد أبسط مثال، ولكن من خلال تحليله، يمكنك رؤية المبادئ الأساسية لوحدة lua-nginx-module.

المفاهيم الأساسية

بعد تحليل المبادئ، دعونا ننعش ذاكرتنا ونسترجع مفهومين مهمين في OpenResty: المراحل وعدم الحظر.

OpenResty، مثل NGINX، لديه مفهوم المراحل، ولكل مرحلة دورها المميز:

  • set_by_lua، والذي يستخدم لتعيين المتغيرات.
  • rewrite_by_lua، لإعادة التوجيه، التحويل، إلخ.
  • access_by_lua، للوصول، الأذونات، إلخ.
  • content_by_lua، لتوليد محتوى الإرجاع.
  • header_filter_by_lua، لمعالجة تصفية رأس الاستجابة.
  • body_filter_by_lua، لتصفية جسم الاستجابة.
  • log_by_lua، لتسجيل الأحداث.

بالطبع، إذا لم يكن منطق الكود الخاص بك معقدًا للغاية، فمن الممكن تنفيذه كله في مرحلة rewrite أو content.

ولكن لاحظ أن واجهات برمجة التطبيقات في OpenResty لها قيود على استخدام المراحل. كل واجهة برمجة تطبيقات لديها قائمة بالمراحل التي يمكن استخدامها فيها، وستحصل على خطأ إذا استخدمتها خارج النطاق. هذا يختلف كثيرًا عن لغات البرمجة الأخرى.

كمثال، سأستخدم ngx.sleep. من الوثائق، أعلم أنه يمكن استخدامه فقط في السياقات التالية ولا يشمل مرحلة log.

context: rewrite_by_lua*, access_by_lua*, content_by_lua*, ngx.timer.*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*_

وإذا كنت لا تعرف هذا، واستخدمت sleep في مرحلة log التي لا تدعمها:

location / {
    log_by_lua_block {
        ngx.sleep(1)
     }
}

في سجل أخطاء NGINX، هناك إشارة بمستوى error.

[error] 62666#0: *6 failed to run log_by_lua*: log_by_lua(nginx.conf:14):2: API disabled in the context of log_by_lua*
stack traceback:
    [C]: in function 'sleep'

لذلك، قبل استخدام واجهة برمجة التطبيقات، تذكر دائمًا استشارة الوثائق لتحديد ما إذا كان يمكن استخدامها في سياق الكود الخاص بك.

بعد مراجعة مفهوم المراحل، دعونا نراجع عدم الحظر. أولاً، دعونا نوضح أن جميع واجهات برمجة التطبيقات المقدمة من OpenResty هي غير محظورة.

سأستمر مع متطلب النوم لمدة ثانية واحدة كمثال. إذا كنت تريد تنفيذ ذلك في Lua، يجب عليك القيام بذلك.

function sleep(s)
   local ntime = os.time() + s
   repeat until os.time() > ntime
end

بما أن Lua القياسية لا تحتوي على وظيفة sleep، فأنا أستخدم حلقة هنا للاستمرار في تحديد ما إذا كان الوقت المحدد قد تم الوصول إليه. هذا التنفيذ محظور، وخلال الثانية التي يعمل فيها sleep، لا يقوم Lua بأي شيء بينما الطلبات الأخرى التي تحتاج إلى المعالجة تنتظر فقط.

ومع ذلك، إذا قمنا بالتبديل إلى ngx.sleep(1)، وفقًا للكود المصدري الذي قمنا بتحليله أعلاه، يمكن لـ OpenResty معالجة الطلبات الأخرى (مثل request B) خلال هذه الثانية. سيتم حفظ سياق الطلب الحالي (دعنا نسميه request A) ويتم إيقاظه بواسطة آلية أحداث NGINX ثم العودة إلى request A، بحيث تكون وحدة المعالجة المركزية دائمًا في حالة عمل طبيعية.

المتغيرات ودورة الحياة

بالإضافة إلى هذين المفهومين المهمين، فإن دورة حياة المتغيرات هي أيضًا منطقة سهلة للخطأ في تطوير OpenResty.

كما قلت سابقًا، في OpenResty، أنصحك بتعريف جميع المتغيرات كمتغيرات محلية واستخدام أدوات مثل luacheck وlua-releng للكشف عن المتغيرات العالمية. هذا ينطبق أيضًا على الوحدات، مثل التالي.

local ngx_re = require "ngx.re"

في OpenResty، باستثناء المرحلتين init_by_lua وinit_worker_by_lua، يتم تعيين جدول معزول للمتغيرات العالمية لجميع المراحل لتجنب تلويث الطلبات الأخرى أثناء المعالجة. حتى في هاتين المرحلتين حيث يمكنك تعريف المتغيرات العالمية، يجب عليك محاولة تجنب ذلك.

كقاعدة عامة، يجب حل المشكلات التي يتم محاولة حلها باستخدام المتغيرات العالمية بشكل أفضل باستخدام المتغيرات في الوحدات وسيكون ذلك أكثر وضوحًا. التالي هو مثال على متغير في وحدة.

local _M = {}

_M.color = {
      red = 1,
      blue = 2,
      green = 3
  }

  return _M

لقد قمت بتعريف وحدة في ملف يسمى hello.lua، والذي يحتوي على الجدول color، ثم أضفت التكوين التالي إلى nginx.conf.

location / {
    content_by_lua_block {
        local hello = require "hello"
        ngx.say(hello.color.green)
     }
}

سيقوم هذا التكوين بطلب الوحدة في مرحلة content وطباعة قيمة green كجسم استجابة HTTP.

قد تتساءل لماذا متغير الوحدة مذهل إلى هذا الحد؟

سيتم تحميل الوحدة مرة واحدة فقط في نفس عملية Worker؛ بعد ذلك، ستشارك جميع الطلبات التي يتم التعامل معها بواسطة Worker البيانات في الوحدة. نقول أن البيانات "العالمية" مناسبة للتغليف في الوحدات لأن عمليات Worker في OpenResty معزولة تمامًا عن بعضها البعض، لذلك يتم تحميل الوحدة بشكل مستقل من قبل كل Worker، ولا يمكن للبيانات في الوحدة أن تعبر بين Workers.

أما بالنسبة للتعامل مع البيانات التي تحتاج إلى المشاركة بين Workers، سأترك ذلك لفصل لاحق، حتى لا تضطر إلى الخوض فيه هنا.

ومع ذلك، هناك شيء واحد يمكن أن يحدث خطأ هنا: عند الوصول إلى متغيرات الوحدة، من الأفضل أن تبقى للقراءة فقط ولا تحاول تعديلها، وإلا ستحصل على race في حالة التزامن العالي، وهو خطأ لا يمكن اكتشافه بواسطة اختبار الوحدة، والذي يحدث أحيانًا عبر الإنترنت ويصعب تحديده.

على سبيل المثال، القيمة الحالية لمتغير الوحدة green هي 3، وتقوم بعملية plus 1 في الكود الخاص بك، فهل قيمة green الآن 4؟ ليس بالضرورة؛ يمكن أن تكون 4، 5، أو 6 لأن OpenResty لا يقوم بالتحقق عند الكتابة إلى متغير الوحدة. ثم هناك منافسة، ويتم تحديث قيمة متغير الوحدة بواسطة طلبات متعددة في نفس الوقت.

بعد أن تحدثنا عن المتغيرات العالمية والمحلية ومتغيرات الوحدة، دعونا نناقش المتغيرات العابرة للمراحل.

هناك حالات نحتاج فيها إلى متغيرات تعبر المراحل ويمكن قراءتها وكتابتها. المتغيرات مثل $host، $scheme، إلخ، والتي نعرفها في NGINX، لا يمكن إنشاؤها ديناميكيًا حتى لو كانت تفي بشرط العبور بين المراحل، ويجب عليك تعريفها في ملف التكوين قبل أن تتمكن من استخدامها. على سبيل المثال، إذا كتبت شيئًا مثل التالي.

location /foo {
      set $my_var ; # تحتاج إلى إنشاء متغير $my_var أولاً
      content_by_lua_block {
          ngx.var.my_var = 123
      }
  }

يوفر OpenResty ngx.ctx لحل هذا النوع من المشكلات. إنه جدول Lua يمكن استخدامه لتخزين البيانات القائمة على الطلب في Lua بنفس عمر الطلب الحالي. دعونا ننظر إلى هذا المثال من الوثائق الرسمية.

location /test {
      rewrite_by_lua_block {
          ngx.ctx.foo = 76
      }
      access_by_lua_block {
          ngx.ctx.foo = ngx.ctx.foo + 3
      }
      content_by_lua_block {
          ngx.say(ngx.ctx.foo)
      }
  }

يمكنك أن ترى أننا قمنا بتعريف متغير foo يتم تخزينه في ngx.ctx. هذا المتغير يعبر مراحل rewrite، access، وcontent وأخيرًا يطبع القيمة في مرحلة content، وهي 79 كما توقعنا.

بالطبع، ngx.ctx له حدوده.

على سبيل المثال، الطلبات الفرعية التي تم إنشاؤها باستخدام ngx.location.capture سيكون لديها بيانات ngx.ctx الخاصة بها، مستقلة عن بيانات ngx.ctx للطلب الأصلي.

ثم مرة أخرى، إعادة التوجيه الداخلية التي تم إنشاؤها باستخدام ngx.exec تدمر ngx.ctx الأصلي للطلب وتعيد إنشاؤه بـ ngx.ctx فارغ.

كل من هذه الحدود لديها أمثلة كود مفصلة في الوثائق الرسمية، لذا يمكنك التحقق منها بنفسك إذا كنت مهتمًا.

الخلاصة

أخيرًا، سأقول بضع كلمات أخرى. نحن نتعلم مبادئ OpenResty وبعض المفاهيم المهمة، ولكنك لست بحاجة إلى حفظها. بعد كل شيء، دائمًا ما تكون منطقية وتأتي إلى الحياة عندما يتم دمجها مع المتطلبات الحقيقية والكود.

أتساءل كيف تفهمها؟ مرحبًا بك في ترك تعليق ومناقشتي، وأيضًا مرحبًا بك في مشاركة هذه المقالة مع زملائك وأصدقائك. نتواصل معًا، ونتقدم معًا.