تكوين النظام الموزع المترجمة

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

تكوين النظام الموزع المترجمة

(English)

مقدمة

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

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

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

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

في البيئات الحديثة، نادرًا ما يتم إنشاء ملفات التكوين يدويًا. في كثير من الأحيان يتم إنشاؤها أثناء النشر ولم يعد يتم لمسها (وبالتالي لا تكسر أي شيء). يطرح سؤال طبيعي: لماذا لا نزال نستخدم تنسيق النص لتخزين التكوين؟ يبدو أن البديل القابل للتطبيق هو القدرة على استخدام التعليمات البرمجية العادية للتكوين والاستفادة من اختبارات وقت الترجمة.

في هذا المنشور سوف نستكشف فكرة تمثيل التكوين داخل قطعة أثرية مجمعة.

التكوين المترجمة

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

عادةً ما يحتوي النظام الموزع على عدة عقد. يمكنك تحديد العقد باستخدام قيم من نوع ما NodeId:

sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeId

أو

case class NodeId(hostName: String)

أو حتى

object Singleton
type NodeId = Singleton.type

تؤدي العقد أدوارًا مختلفة، فهي تقوم بتشغيل الخدمات ويمكن إنشاء اتصالات TCP/HTTP فيما بينها.

لوصف اتصال TCP نحتاج على الأقل إلى رقم منفذ. نود أيضًا أن نعكس البروتوكول المدعوم على هذا المنفذ للتأكد من أن كلاً من العميل والخادم يستخدمان نفس البروتوكول. سنصف الاتصال باستخدام الفئة التالية:

case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])

حيث Port - مجرد عدد صحيح Int تشير إلى نطاق القيم المقبولة:

type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]

أنواع مكررة

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

بالنسبة لبروتوكولات HTTP (REST)، بالإضافة إلى رقم المنفذ، قد نحتاج أيضًا إلى المسار إلى الخدمة:

type UrlPathPrefix = Refined[String, MatchesRegex[W.`"[a-zA-Z_0-9/]*"`.T]]
case class PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)

أنواع الوهمية

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

أحد البروتوكولات الشائعة هو REST API مع تسلسل Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

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

ولأغراض هذه المقالة، سوف نستخدم نسخة مبسطة من البروتوكول:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

الطلب هنا عبارة عن سلسلة ملحقة بعنوان URL والاستجابة هي السلسلة التي تم إرجاعها في نص استجابة HTTP.

يتم وصف تكوين الخدمة من خلال اسم الخدمة والمنافذ والتبعيات. يمكن تمثيل هذه العناصر في Scala بعدة طرق (على سبيل المثال، HList-s، أنواع البيانات الجبرية). لأغراض هذا المنشور، سوف نستخدم نمط الكعكة ونمثل الوحدات المستخدمة trait'ع. (لا يعد نموذج الكعكة عنصرًا مطلوبًا في هذا النهج. إنه ببساطة أحد التطبيقات الممكنة.)

يمكن تمثيل التبعيات بين الخدمات كأساليب تقوم بإرجاع المنافذ EndPointالعقد الأخرى:

  type EchoProtocol[A] = SimpleHttpGetRest[A, A]

  trait EchoConfig[A] extends ServiceConfig {
    def portNumber: PortNumber = 8081
    def echoPort: PortWithPrefix[EchoProtocol[A]] = PortWithPrefix[EchoProtocol[A]](portNumber, "echo")
    def echoService: HttpSimpleGetEndPoint[NodeId, EchoProtocol[A]] = providedSimpleService(echoPort)
  }

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

في تكوين العميل نعلن الاعتماد على خدمة الصدى:

  trait EchoClientConfig[A] {
    def testMessage: String = "test"
    def pollInterval: FiniteDuration
    def echoServiceDependency: HttpSimpleGetEndPoint[_, EchoProtocol[A]]
  }

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

تنفيذ الخدمات

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

  type ResourceReader[F[_], Config, A] = Reader[Config, Resource[F, A]]

  trait ServiceImpl[F[_]] {
    type Config
    def resource(
      implicit
      resolver: AddressResolver[F],
      timer: Timer[F],
      contextShift: ContextShift[F],
      ec: ExecutionContext,
      applicative: Applicative[F]
    ): ResourceReader[F, Config, Unit]
  }

حيث

  • Config — نوع التكوين لهذه الخدمة
  • AddressResolver - كائن وقت التشغيل الذي يسمح لك بمعرفة عناوين العقد الأخرى (انظر أدناه)

وأنواع أخرى من المكتبة cats:

  • F[_] - نوع التأثير (في أبسط الحالات F[A] يمكن أن تكون مجرد وظيفة () => A. في هذا المنصب سوف نستخدم cats.IO.)
  • Reader[A,B] - أكثر أو أقل مرادفا للوظيفة A => B
  • cats.Resource - مورد يمكن الحصول عليه وتحريره
  • Timer - مؤقت (يسمح لك بالنوم لفترة من الوقت وقياس الفترات الزمنية)
  • ContextShift - التناظرية ExecutionContext
  • Applicative — فئة نوع التأثير التي تسمح لك بدمج التأثيرات الفردية (تقريبًا أحادية). في التطبيقات الأكثر تعقيدًا يبدو من الأفضل استخدامه Monad/ConcurrentEffect.

باستخدام توقيع الوظيفة هذا يمكننا تنفيذ العديد من الخدمات. على سبيل المثال، خدمة لا تفعل شيئًا:

  trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
    type Config <: Any
    def resource(...): ResourceReader[F, Config, Unit] =
      Reader(_ => Resource.pure[F, Unit](()))
  }

(سم. شفرة المصدر، حيث يتم تنفيذ الخدمات الأخرى - خدمة الصدى, عميل الصدى
и وحدات تحكم مدى الحياة.)

العقدة هي كائن يمكنه تشغيل العديد من الخدمات (يتم ضمان إطلاق سلسلة من الموارد من خلال نمط الكعكة):

object SingleNodeImpl extends ZeroServiceImpl[IO]
  with EchoServiceService
  with EchoClientService
  with FiniteDurationLifecycleServiceImpl
{
  type Config = EchoConfig[String] with EchoClientConfig[String] with FiniteDurationLifecycleConfig
}

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

حل اسم المضيف

للاتصال بمضيف بعيد، نحتاج إلى عنوان IP حقيقي. من الممكن أن يصبح العنوان معروفًا في وقت لاحق عن بقية التكوين. لذلك نحن بحاجة إلى وظيفة تقوم بتعيين معرف العقدة إلى عنوان:

case class NodeAddress[NodeId](host: Uri.Host)
trait AddressResolver[F[_]] {
  def resolve[NodeId](nodeId: NodeId): F[NodeAddress[NodeId]]
}

هناك عدة طرق لتنفيذ هذه الوظيفة:

  1. إذا أصبحت العناوين معروفة لنا قبل النشر، فيمكننا إنشاء كود Scala باستخدام
    العناوين ثم قم بتشغيل الإنشاء. سيؤدي هذا إلى تجميع الاختبارات وتشغيلها.
    في هذه الحالة، ستكون الوظيفة معروفة بشكل ثابت ويمكن تمثيلها في التعليمات البرمجية كمخطط Map[NodeId, NodeAddress].
  2. في بعض الحالات، لا يُعرف العنوان الفعلي إلا بعد بدء العقدة.
    في هذه الحالة، يمكننا تنفيذ "خدمة الاكتشاف" التي يتم تشغيلها قبل العقد الأخرى وستسجل جميع العقد في هذه الخدمة وتطلب عناوين العقد الأخرى.
  3. إذا كنا نستطيع تعديل /etc/hosts، فيمكنك استخدام أسماء المضيفين المحددة مسبقًا (مثل my-project-main-node и echo-backend) وببساطة ربط هذه الأسماء
    مع عناوين IP أثناء النشر.

في هذا المقال لن نتناول هذه الحالات بمزيد من التفصيل. لنا
في مثال لعبة، جميع العقد سيكون لها نفس عنوان IP - 127.0.0.1.

بعد ذلك، سننظر في خيارين للنظام الموزع:

  1. وضع جميع الخدمات في عقدة واحدة.
  2. واستضافة خدمة الصدى وعميل الصدى على عقد مختلفة.

التكوين ل عقدة واحدة:

تكوين عقدة واحدة

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton // identifier of the single node 
  // configuration of server
  type NodeId = Singleton.type
  def nodeId = Singleton

  /** Type safe service port specification. */
  override def portNumber: PortNumber = 8088

  // configuration of client

  /** We'll use the service provided by the same host. */
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  // lifecycle controller configuration
  def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 requests, not 9.
}

ينفذ الكائن تكوين كل من العميل والخادم. يتم أيضًا استخدام تكوين مدة البقاء بحيث يتم ذلك بعد الفاصل الزمني lifetime إنهاء البرنامج. (يعمل Ctrl-C أيضًا ويحرر جميع الموارد بشكل صحيح.)

يمكن استخدام نفس مجموعة سمات التكوين والتنفيذ لإنشاء نظام يتكون من عقدتين منفصلتين:

تكوين عقدتين

  object NodeServerConfig extends EchoConfig[String] with SigTermLifecycleConfig
  {
    type NodeId = NodeIdImpl

    def nodeId = NodeServer

    override def portNumber: PortNumber = 8080
  }

  object NodeClientConfig extends EchoClientConfig[String] with FiniteDurationLifecycleConfig
  {
    // NB! dependency specification
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 request, not 9.

    def testMessage: String = "dolly"
  }

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

تنفيذ عقدتين للنظام

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

  object TwoJvmNodeServerImpl extends ZeroServiceImpl[IO] with EchoServiceService with SigIntLifecycleServiceImpl {
    type Config = EchoConfig[String] with SigTermLifecycleConfig
  }

  object TwoJvmNodeClientImpl extends ZeroServiceImpl[IO] with EchoClientService with FiniteDurationLifecycleServiceImpl {
    type Config = EchoClientConfig[String] with FiniteDurationLifecycleConfig
  }

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

عملية التطوير العامة

دعونا نرى كيف يؤثر نهج التكوين هذا على عملية التطوير الشاملة.

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

يتحول أي تغيير في التكوين إلى تغيير في التعليمات البرمجية. وبالتالي كل
سيتم تغطية التغيير من خلال عملية ضمان الجودة العادية:

تذكرة في متعقب الأخطاء -> العلاقات العامة -> المراجعة -> الدمج مع الفروع ذات الصلة ->
التكامل -> النشر

النتائج الرئيسية لتنفيذ التكوين المترجم هي:

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

  2. من الصعب تغيير التكوين في واحدة فقط من العقد. ولذلك، فإن "الانحراف في التكوين" غير مرجح.

  3. يصبح من الصعب إجراء تغييرات صغيرة على التكوين.

  4. ستحدث معظم تغييرات التكوين كجزء من عملية التطوير الشاملة وستخضع للمراجعة.

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

الاختلافات الممكنة

دعونا نحاول مقارنة التكوين المترجم مع بعض البدائل الشائعة:

  1. ملف نصي على الجهاز الهدف.
  2. مخزن القيمة الرئيسية المركزي (etcd/zookeeper).
  3. مكونات العملية التي يمكن إعادة تكوينها/إعادة تشغيلها دون إعادة تشغيل العملية.
  4. تخزين التكوين خارج التحكم في العناصر والإصدارات.

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

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

يعد مخزن القيمة الرئيسية المركزي آلية جيدة لتوزيع معلمات التعريف للتطبيق الموزع. نحن بحاجة إلى تحديد ما هي معلمات التكوين وما هي البيانات فقط. دعونا يكون لدينا وظيفة C => A => B، والمعلمات C نادرا ما يتغير، والبيانات A - غالباً. في هذه الحالة يمكننا أن نقول ذلك C - معلمات التكوين، و A - بيانات. يبدو أن معلمات التكوين تختلف عن البيانات حيث أنها تتغير عمومًا بشكل أقل تكرارًا من البيانات. كما تأتي البيانات عادةً من مصدر واحد (من المستخدم)، ومعلمات التكوين من مصدر آخر (من مسؤول النظام).

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

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

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

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

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

إيجابيات وسلبيات

أود أن أتناول إيجابيات وسلبيات التكنولوجيا المقترحة.

المزايا

فيما يلي قائمة بالميزات الرئيسية لتكوين النظام الموزع المترجم:

  1. فحص التكوين الثابت. يتيح لك التأكد من ذلك
    التكوين صحيح.
  2. لغة التكوين الغنية. عادةً ما تقتصر طرق التكوين الأخرى على استبدال متغير السلسلة على الأكثر. عند استخدام Scala، تتوفر مجموعة واسعة من ميزات اللغة لتحسين التكوين الخاص بك. على سبيل المثال يمكننا استخدام
    سمات القيم الافتراضية، باستخدام الكائنات لتجميع المعلمات، يمكننا الرجوع إلى القيم المعلنة مرة واحدة فقط (DRY) في النطاق المحيط. يمكنك إنشاء مثيل لأي فئات مباشرة داخل التكوين (Seq, Map، فئات مخصصة).
  3. دي اس ال. يحتوي Scala على عدد من ميزات اللغة التي تسهل إنشاء خط DSL. من الممكن الاستفادة من هذه الميزات وتنفيذ لغة تكوين أكثر ملاءمة للمجموعة المستهدفة من المستخدمين، بحيث يكون التكوين قابلاً للقراءة على الأقل من قبل خبراء المجال. يمكن للمتخصصين، على سبيل المثال، المشاركة في عملية مراجعة التكوين.
  4. النزاهة والتزامن بين العقد. إحدى مزايا تخزين تكوين النظام الموزع بالكامل في نقطة واحدة هي أنه يتم الإعلان عن جميع القيم مرة واحدة بالضبط ثم إعادة استخدامها حيثما تكون هناك حاجة إليها. إن استخدام الأنواع الوهمية للإعلان عن المنافذ يضمن أن العقد تستخدم بروتوكولات متوافقة في جميع تكوينات النظام الصحيحة. وجود تبعيات إلزامية صريحة بين العقد يضمن أن جميع الخدمات متصلة.
  5. تغييرات عالية الجودة. إن إجراء تغييرات على التكوين باستخدام عملية تطوير مشتركة يجعل من الممكن تحقيق معايير الجودة العالية للتكوين أيضًا.
  6. تحديث التكوين المتزامن. يضمن النشر التلقائي للنظام بعد تغييرات التكوين تحديث جميع العقد.
  7. تبسيط التطبيق. لا يحتاج التطبيق إلى التحليل أو التحقق من التكوين أو التعامل مع القيم غير الصحيحة. وهذا يقلل من تعقيد التطبيق. (بعض تعقيدات التكوين التي تمت ملاحظتها في مثالنا ليست سمة من سمات التكوين المترجم، ولكنها مجرد قرار واعٍ مدفوع بالرغبة في توفير قدر أكبر من الأمان للكتابة.) من السهل جدًا العودة إلى التكوين المعتاد - ما عليك سوى تنفيذ ما هو مفقود القطع. لذلك، يمكنك، على سبيل المثال، البدء بتكوين مجمع، وتأجيل تنفيذ الأجزاء غير الضرورية حتى الوقت الذي تكون فيه هناك حاجة إليه بالفعل.
  8. التكوين المتحقق. نظرًا لأن تغييرات التكوين تتبع المصير المعتاد لأي تغييرات أخرى، فإن الناتج الذي نحصل عليه هو قطعة أثرية بإصدار فريد. وهذا يسمح لنا، على سبيل المثال، بالعودة إلى الإصدار السابق من التكوين إذا لزم الأمر. يمكننا أيضًا استخدام التكوين الذي تم إصداره قبل عام وسيعمل النظام بنفس الطريقة تمامًا. يعمل التكوين المستقر على تحسين القدرة على التنبؤ وموثوقية النظام الموزع. نظرًا لأن التكوين تم إصلاحه في مرحلة التجميع، فمن الصعب جدًا تزييفه في الإنتاج.
  9. نمطية. الإطار المقترح عبارة عن وحدات ويمكن دمج الوحدات بطرق مختلفة لإنشاء أنظمة مختلفة. على وجه الخصوص، يمكنك تكوين النظام ليتم تشغيله على عقدة واحدة في أحد النماذج، وعلى عقد متعددة في نموذج آخر. يمكنك إنشاء العديد من التكوينات لمثيلات الإنتاج الخاصة بالنظام.
  10. اختبارات. من خلال استبدال الخدمات الفردية بكائنات وهمية، يمكنك الحصول على إصدارات متعددة من النظام ملائمة للاختبار.
  11. اختبار التكامل. إن وجود تكوين واحد للنظام الموزع بأكمله يجعل من الممكن تشغيل جميع المكونات في بيئة خاضعة للرقابة كجزء من اختبار التكامل. من السهل محاكاة، على سبيل المثال، الموقف الذي يصبح فيه الوصول إلى بعض العقد ممكنًا.

العيوب والقيود

يختلف التكوين المترجم عن أساليب التكوين الأخرى وقد لا يكون مناسبًا لبعض التطبيقات. وفيما يلي بعض العيوب:

  1. التكوين الثابت. في بعض الأحيان تحتاج إلى تصحيح التكوين بسرعة في الإنتاج، وتجاوز جميع آليات الحماية. مع هذا النهج يمكن أن يكون أكثر صعوبة. على أقل تقدير، ستظل هناك حاجة إلى التجميع والنشر التلقائي. وهذه ميزة مفيدة لهذا النهج وعيوب في بعض الحالات.
  2. جيل التكوين في حالة إنشاء ملف التكوين بواسطة أداة تلقائية، قد تكون هناك حاجة إلى جهود إضافية لدمج البرنامج النصي للإنشاء.
  3. أدوات. حاليًا، تعتمد الأدوات المساعدة والتقنيات المصممة للعمل مع التكوين على الملفات النصية. لن تكون كل هذه الأدوات المساعدة/التقنيات متاحة في تكوين مجمع.
  4. مطلوب تغيير في المواقف. لقد اعتاد المطورون وDevOps على الملفات النصية. قد تكون فكرة تجميع التكوين غير متوقعة وغير عادية إلى حد ما وتتسبب في الرفض.
  5. مطلوب عملية تطوير عالية الجودة. من أجل استخدام التكوين المترجم بشكل مريح، من الضروري الأتمتة الكاملة لعملية إنشاء التطبيق ونشره (CI/CD). وإلا فإنه سيكون غير مريح تماما.

دعونا نتناول أيضًا عددًا من قيود المثال المدروس والتي لا تتعلق بفكرة التكوين المترجم:

  1. إذا قدمنا ​​معلومات التكوين غير الضرورية التي لا تستخدمها العقدة، فلن يساعدنا المترجم في اكتشاف التنفيذ المفقود. يمكن حل هذه المشكلة بالتخلي عن نمط الكيك واستخدام أنواع أكثر صلابة، على سبيل المثال، HList أو أنواع البيانات الجبرية (فئات الحالة) لتمثيل التكوين.
  2. توجد أسطر في ملف التكوين لا تتعلق بالتكوين نفسه: (package, importإعلانات الكائنات؛ override defللمعلمات التي لها قيم افتراضية). يمكن تجنب ذلك جزئيًا إذا قمت بتطبيق DSL الخاص بك. بالإضافة إلى ذلك، تفرض أنواع أخرى من التكوين (على سبيل المثال، XML) قيودًا معينة على بنية الملف.
  3. ولأغراض هذه المقالة، نحن لا نفكر في إعادة التكوين الديناميكي لمجموعة من العقد المماثلة.

اختتام

في هذا المنشور، استكشفنا فكرة تمثيل التكوين في الكود المصدري باستخدام الإمكانات المتقدمة لنظام نوع Scala. يمكن استخدام هذا الأسلوب في العديد من التطبيقات كبديل لطرق التكوين التقليدية المعتمدة على ملفات XML أو الملفات النصية. على الرغم من أن مثالنا تم تنفيذه في Scala، إلا أنه يمكن نقل نفس الأفكار إلى لغات مجمعة أخرى (مثل Kotlin، C#، Swift، ...). يمكنك تجربة هذا الأسلوب في أحد المشاريع التالية، وإذا لم ينجح، فانتقل إلى الملف النصي، مع إضافة الأجزاء المفقودة.

وبطبيعة الحال، يتطلب التكوين المترجم عملية تطوير عالية الجودة. وفي المقابل، يتم ضمان الجودة العالية والموثوقية للتكوينات.

يمكن توسيع النهج المدروس:

  1. يمكنك استخدام وحدات الماكرو لإجراء اختبارات وقت الترجمة.
  2. يمكنك تنفيذ DSL لتقديم التكوين بطريقة يمكن للمستخدمين النهائيين الوصول إليها.
  3. يمكنك تنفيذ إدارة الموارد الديناميكية من خلال ضبط التكوين التلقائي. على سبيل المثال، يتطلب تغيير عدد العقد في المجموعة أن (1) تتلقى كل عقدة تكوينًا مختلفًا قليلاً؛ (2) تلقى مدير المجموعة معلومات حول العقد الجديدة.

شكر وتقدير

أود أن أشكر أندريه ساكسونوف، وبافل بوبوف، وأنطون نيخيف على انتقاداتهم البناءة لمشروع المادة.

المصدر: www.habr.com

إضافة تعليق