اختيار بديل Tech Stack - الصعود والهبوط

نشرت: 2022-03-11

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

في هذا المنشور ، نشارك قصة حول استخراج خدمة جديدة من تطبيق مترابط - منصة Toptal . نوضح المكدس الفني الذي اخترناه ولماذا ، ونحدد بعض المشكلات التي واجهناها أثناء تنفيذ الخدمة.

خدمة Toptal's Chronicles هي تطبيق يتعامل مع جميع إجراءات المستخدم التي يتم تنفيذها على منصة Toptal Platform. الإجراءات هي في الأساس إدخالات السجل. عندما يفعل المستخدم شيئًا ما (على سبيل المثال ، ينشر منشور مدونة ، ويوافق على وظيفة ، وما إلى ذلك) ، يتم إنشاء إدخال سجل جديد.

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

هناك عدد من الأسباب وراء قرارنا باستخراج الخدمة وتحسين المكدس:

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

جدول الإجراءات - جداول قاعدة البيانات

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

نظرة عامة على العمارة

يتكون تطبيق Chronicles من ثلاثة أجزاء يمكن أن تكون مستقلة إلى حد ما ويتم تشغيلها في حاويات Docker منفصلة.

  • مستهلك كافكا هو مستهلك كافكا ضعيف جدًا في كارافكا لرسائل إنشاء الدخول. يقوم بإدراج جميع الرسائل المستلمة في قائمة الانتظار إلى Sidekiq.
  • عامل Sidekiq هو عامل يعالج رسائل كافكا وينشئ إدخالات في جدول قاعدة البيانات.
  • نقاط نهاية GraphQL:
    • تعرض نقطة النهاية العامة واجهة برمجة تطبيقات بحث الإدخال ، والتي تُستخدم في وظائف النظام الأساسي المختلفة (على سبيل المثال ، لتقديم تلميحات أدوات التعليق على أزرار الفحص ، أو عرض محفوظات تغييرات الوظيفة).
    • توفر نقطة النهاية الداخلية القدرة على إنشاء قواعد العلامات والقوالب من عمليات ترحيل البيانات.

تستخدم السجلات التاريخية للاتصال بقاعدتي بيانات مختلفتين:

  • قاعدة البيانات الخاصة بها (حيث نقوم بتخزين قواعد العلامات والقوالب)
  • قاعدة بيانات النظام الأساسي (حيث نقوم بتخزين الإجراءات التي يقوم بها المستخدم وعلاماتها وعلاماتها)

في عملية استخراج التطبيق ، قمنا بترحيل البيانات من قاعدة بيانات النظام الأساسي وإغلاق اتصال النظام الأساسي.

خطة بداءية

في البداية ، قررنا استخدام Hanami وكل النظام البيئي الذي يوفره افتراضيًا (نموذج hanami مدعوم من ROM.rb ، و dry-rb ، و hanami-newrelic ، إلخ). لقد وعدنا اتباع طريقة "قياسية" للقيام بالأشياء بتقليل الاحتكاك ، وسرعة تنفيذ كبيرة ، و "قابلية googleability" لأية مشكلات قد نواجهها. بالإضافة إلى ذلك ، فإن نظام هانامي البيئي ناضج وشائع ، ويتم صيانة المكتبة بعناية من قبل أعضاء محترمين في مجتمع روبي.

علاوة على ذلك ، تم بالفعل تنفيذ جزء كبير من النظام على جانب النظام الأساسي (على سبيل المثال ، نقطة نهاية GraphQL Entry Search وعملية CreateEntry) ، لذلك خططنا لنسخ الكثير من التعليمات البرمجية من النظام الأساسي إلى السجلات كما هي ، دون إجراء أي تغييرات. كان هذا أيضًا أحد الأسباب الرئيسية لعدم استخدامنا للإكسير ، لأن الإكسير لن يسمح بذلك.

قررنا عدم القيام بـ Rails لأنه بدا وكأنه مبالغة في مثل هذا المشروع الصغير ، لا سيما أشياء مثل ActiveSupport ، والتي لن توفر العديد من الفوائد الملموسة لاحتياجاتنا.

عندما تذهب الخطة جنوبا

على الرغم من أننا بذلنا قصارى جهدنا للالتزام بالخطة ، إلا أنها سرعان ما خرجت عن مسارها لعدد من الأسباب. أحدهما هو افتقارنا إلى الخبرة مع المكدس المختار ، متبوعًا بمشكلات حقيقية مع المكدس نفسه ، ثم كان هناك إعدادنا غير القياسي (قاعدتا بيانات). في النهاية ، قررنا التخلص من hanami-model ، ثم هانامي نفسه ، واستبداله بسيناترا.

اخترنا سيناترا لأنها مكتبة يتم صيانتها بنشاط تم إنشاؤها منذ 12 عامًا ، ولأنها واحدة من أكثر المكتبات شعبية ، كان لدى كل فرد في الفريق خبرة عملية واسعة بها.

التبعيات غير المتوافقة

بدأ استخراج Chronicles في يونيو 2019 ، وفي ذلك الوقت ، لم يكن Hanami متوافقًا مع أحدث إصدارات الأحجار الكريمة الجافة. أي أن أحدث إصدار من Hanami في ذلك الوقت (1.3.1) دعم فقط التحقق الجاف 0.12 ، وأردنا التحقق الجاف 1.0.0. خططنا لاستخدام العقود من التحقق الجاف التي تم تقديمها فقط في الإصدار 1.0.0.

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

التبعيات غير الضرورية

بالإضافة إلى ذلك ، تضمنت جوهرة هانامي العديد من التبعيات التي لم نكن نخطط لاستخدامها ، مثل hanami-cli و hanami-assets و hanami-mailer و hanami-view و hanami-controller . أيضًا ، بالنظر إلى الملف التمهيدي لـ hanami ، أصبح من الواضح أنه يدعم قاعدة بيانات واحدة فقط افتراضيًا. من ناحية أخرى ، فإن ROM.rb ، الذي يعتمد hanami-model ، يدعم تكوينات قواعد البيانات المتعددة خارج الصندوق.

بشكل عام ، بدا هانامي بشكل عام hanami-model بشكل خاص كمستوى غير ضروري من التجريد.

لذلك ، بعد 10 أيام من إنشاء أول علاقات عامة ذات مغزى لسجلات أخبار الأيام ، استبدلنا هانامي بسيناترا تمامًا. كان بإمكاننا استخدام الرف النقي أيضًا لأننا لسنا بحاجة إلى توجيه معقد (لدينا أربع نقاط نهاية "ثابتة" - نقطتا نهاية GraphQL ونقطة نهاية / ping وواجهة ويب sidekiq) ، لكننا قررنا عدم المبالغة في التعقيد. سيناترا يناسبنا على ما يرام. إذا كنت ترغب في معرفة المزيد ، تحقق من برنامجنا التعليمي Sinatra and Sequel.

سوء فهم المخطط الجاف والتحقق من الصحة الجافة

لقد استغرقنا بعض الوقت والكثير من المحاولة والخطأ لمعرفة كيفية "طهي" التحقق الجاف بشكل صحيح.

 params do required(:url).filled(:string) end params do required(:url).value(:string) end params do optional(:url).value(:string?) end params do optional(:url).filled(Types::String) end params do optional(:url).filled(Types::Coercible::String) end

في المقتطف أعلاه ، يتم تحديد معلمة url بعدة طرق مختلفة قليلاً. بعض التعريفات متكافئة ، والبعض الآخر لا معنى له. في البداية ، لم نتمكن حقًا من معرفة الفرق بين كل هذه التعريفات لأننا لم نفهمها تمامًا. نتيجة لذلك ، كانت النسخة الأولى من عقودنا فوضوية للغاية. بمرور الوقت ، تعلمنا كيفية قراءة وكتابة عقود DRY بشكل صحيح ، والآن تبدو متسقة وأنيقة - في الواقع ، ليست أنيقة فحسب ، فهي ليست أقل من الجمال. حتى أننا نتحقق من صحة تكوين التطبيق مع العقود.

مشاكل مع ROM.rb و Sequel

ROM.rb و Sequel يختلفان عن ActiveRecord ، فلا عجب. فشلت فكرتنا الأولية المتمثلة في أننا سنكون قادرين على نسخ ولصق معظم الكود من النظام الأساسي. تكمن المشكلة في أن جزء النظام الأساسي كان ثقيلًا جدًا للواقع المعزز ، لذلك كان لا بد من إعادة كتابة كل شيء تقريبًا في ROM / Sequel. تمكنا من نسخ أجزاء صغيرة فقط من التعليمات البرمجية التي كانت مستقلة عن إطار العمل. على طول الطريق ، واجهنا بعض المشكلات المحبطة وبعض الأخطاء.

التصفية حسب الاستعلام الفرعي

على سبيل المثال ، استغرق الأمر مني عدة ساعات لمعرفة كيفية إجراء استعلام فرعي في ROM.rb / Sequel. هذا شيء أود كتابته دون الاستيقاظ في ريلز: النطاق. scope.where(sequence_code: subquery ). في Sequel ، على الرغم من ذلك ، اتضح أنه ليس بهذه السهولة.

 def apply_subquery_filter(base_query, params) subquery = as_subquery(build_subquery(params)) base_query.where { Sequel.lit('sequence_code IN ?', subquery) } end # This is a fixed version of https://github.com/rom-rb/rom-sql/blob/6fa344d7022b5cc9ad8e0d026448a32ca5b37f12/lib/rom/sql/relation/reading.rb#L998 # The original version has `unorder` on the subquery. # The fix was merged: https://github.com/rom-rb/rom-sql/pull/342. def as_subquery(relation) attr = relation.schema.to_a[0] subquery = relation.schema.project(attr).call(relation).dataset ROM::SQL::Attribute[attr.type].meta(sql_expr: subquery) end

لذا فبدلاً من استخدام سطر واحد بسيط مثل base_query.where(sequence_code: bild_subquery(params)) ، يجب أن يكون لدينا عشرات الأسطر ذات التعليمات البرمجية غير التافهة ، وأجزاء SQL الأولية ، وتعليق متعدد الأسطر يشرح سبب هذه الحالة المؤسفة لـ منتفخ.

جمعيات مع مجالات الانضمام غير التافهة

علاقة entry (جدول performed_actions ) لها حقل id أساسي. ومع ذلك ، للانضمام إلى جداول *taggings ، فإنه يستخدم عمود رمز sequence_code . في ActiveRecord ، يتم التعبير عنه ببساطة إلى حد ما:

 class PerformedAction < ApplicationRecord has_many :feed_taggings, class_name: 'PerformedActionFeedTagging', foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code', end class PerformedActionFeedTagging < ApplicationRecord db_belongs_to :performed_action, foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code' end

من الممكن أن تكتب نفس الشيء في ذاكرة القراءة فقط.

 module Chronicles::Persistence::Relations::Entries < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_actions, as: :entries) do attribute :id, ROM::Types::Integer attribute :sequence_code, ::Types::UUID primary_key :id associations do has_many :access_taggings, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code end end end module Chronicles::Persistence::Relations::AccessTaggings < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_action_access_taggings, as: :access_taggings, infer: false) do attribute :performed_action_sequence_code, ::Types::UUID associations do belongs_to :entry, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code, null: false end end end

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

 [4] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings).limit(1).to_a E, [2019-09-05T15:54:16.706292 #20153] ERROR -- : PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform... ^ HINT: No operator matches the given name and argument types. You might need to add explicit type casts.: SELECT <..snip..> FROM "performed_actions" INNER JOIN "performed_action_access_taggings" ON ("performed_actions"."id" = "performed_action_access_taggings"."performed_action_sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform...

نحن محظوظون لأن نوعي المعرف ورمز sequence_code مختلفان ، لذا فإن PG يُلقي بخطأ في النوع. إذا كانت الأنواع هي نفسها ، فمن يعرف عدد الساعات التي سأقضيها في تصحيح هذا الخطأ.

لذا ، فإن entries.join(:access_taggings) لا تعمل. ماذا لو حددنا شرط الانضمام صراحة؟ كما هو الحال في entries.join(:access_taggings, performed_action_sequence_code: :sequence_code) ، كما تقترح الوثائق الرسمية.

 [8] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a E, [2019-09-05T16:02:16.952972 #20153] ERROR -- : PG::UndefinedTable: ERROR: relation "access_taggings" does not exist LINE 1: ...."updated_at" FROM "performed_actions" INNER JOIN "access_ta... ^: SELECT <snip> FROM "performed_actions" INNER JOIN "access_taggings" ON ("access_taggings"."performed_action_sequence_code" = "performed_actions"."sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedTable: ERROR: relation "access_taggings" does not exist

يعتقد الآن أن :access_taggings هو اسم جدول لسبب ما. حسنًا ، دعنا نستبدلها باسم الجدول الفعلي.

 [10] pry(main)> data = Chronicles::Persistence.relations[:platform][:entries].join(:performed_action_access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a => [#<Chronicles::Entities::Entry id=22 subject_g ... updated_at=2012-05-10 08:46:43 UTC>]

أخيرًا ، أعاد شيئًا ولم يفشل ، على الرغم من أنه انتهى بتجريد متسرب. يجب ألا يتسرب اسم الجدول إلى رمز التطبيق.

استيفاء معلمة SQL

هناك ميزة في البحث عن سجلات تتيح للمستخدمين البحث عن طريق الحمولة. يبدو الاستعلام على النحو التالي: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"} ، حيث يكون path دائمًا مصفوفة من السلاسل ، والقيمة هي أي قيمة JSON صالحة.

في ActiveRecord ، يبدو الأمر كما يلي:

 @scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)

في Sequel ، لم أتمكن من إقحام :path بشكل صحيح ، لذلك اضطررت إلى اللجوء إلى ذلك:

 base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))

لحسن الحظ ، تم التحقق من صحة path هنا بشكل صحيح بحيث يحتوي فقط على أحرف أبجدية رقمية ، لكن هذا الرمز لا يزال يبدو مضحكا.

السحر الصامت لمصنع ROM

استخدمنا جوهرة rom-factory لتبسيط إنشاء نماذجنا في الاختبارات. عدة مرات ، ومع ذلك ، لم يعمل الرمز كما هو متوقع. هل يمكنك تخمين ما هو الخطأ في هذا الاختبار؟

 action1 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'deleted'] action2 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'updated'] expect(action1.id).not_to eq(action2.id)

لا ، التوقع لا يفشل ، التوقع جيد.

المشكلة هي أن السطر الثاني فشل مع خطأ فريد في التحقق من صحة القيد. والسبب هو أن action ليس السمة التي يمتلكها نموذج Action . الاسم الحقيقي هو action_name ، لذا يجب أن تبدو الطريقة الصحيحة لإنشاء الإجراءات كما يلي:

 RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']

نظرًا لتجاهل السمة التي تم كتابتها بشكل خاطئ ، فإنها تعود إلى السمة الافتراضية المحددة في المصنع ( action_name { 'created' } ) ، ولدينا انتهاك فريد للقيد لأننا نحاول إنشاء إجراءين متطابقين. كان علينا التعامل مع هذه القضية عدة مرات ، والتي أثبتت أنها ضرائب.

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

بيئة العمل العامة

هذا يقول كل شيء:

 # ActiveRecord PerformedAction.count _# => 30232445_ # ROM EntryRepository.new.root.count _# => 30232445_

والفرق أكبر في الأمثلة الأكثر تعقيدًا.

الأجزاء الجيدة

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

اختبار السرعة

يستغرق تشغيل مجموعة الاختبار بالكامل محليًا من 5 إلى 10 ثوانٍ ، وطالما بالنسبة لـ RuboCop. وقت CI أطول بكثير (3-4 دقائق) ، لكن هذه مشكلة أقل لأننا نستطيع تشغيل كل شيء محليًا على أي حال ، وبفضل ذلك ، فإن أي فشل في CI يكون أقل احتمالًا.

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

نشر تايمز

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

أداء التطبيق

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

على الرغم من الحجم الضخم لسجل الإجراءات (30 مليون صف ، ومتزايد) ، فإن متوسط ​​وقت الاستجابة أقل من 100 مللي ثانية. ألق نظرة على هذا المخطط الجميل:

مخطط أداء التطبيق

في المتوسط ​​، يتم إنفاق 80-90٪ من وقت التطبيق في قاعدة البيانات. هذا ما يجب أن يبدو عليه مخطط الأداء المناسب.

لا يزال لدينا بعض الاستعلامات البطيئة التي قد تستغرق عشرات الثواني ، ولكن لدينا بالفعل خطة لكيفية التخلص منها ، مما يسمح للتطبيق المستخرج بأن يصبح أسرع.

هيكل

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

لم تعد هناك حاجة لاستدعاء .to_s.to_sym.to_i في كود التطبيق ، حيث يتم تنظيف جميع البيانات وطباعتها على حدود التطبيق. بمعنى أنه يجلب أنواعًا قوية من العقل إلى عالم روبي الديناميكي. لا أستطيع أن أوصي به بما فيه الكفاية.

الكلمات الأخيرة

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

على الرغم من أننا حاولنا اتخاذ قرارات حذرة ومحسوبة للغاية من البداية - اخترنا استخدام مكدس Hanami القياسي - كان علينا إعادة النظر في مجموعتنا على طول الطريق بسبب المتطلبات الفنية غير القياسية للمشروع. انتهى بنا الأمر مع Sinatra ومكدس قائم على DRY.

هل نختار Hanami مرة أخرى إذا كنا سنستخرج تطبيقًا جديدًا؟ ربما نعم. نحن الآن نعرف المزيد عن المكتبة وإيجابياتها وسلبياتها ، حتى نتمكن من اتخاذ قرارات مستنيرة منذ بداية أي مشروع جديد. ومع ذلك ، فإننا نفكر أيضًا بجدية في استخدام تطبيق عادي Sinatra / DRY.rb.

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