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

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

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

(на русском)

المُقدّمة

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

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

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

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

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

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

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

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

يتكون النظام الموزع النموذجي من عدد قليل من العقد. يمكن تحديد العقد باستخدام نوع ما:

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]]

أنواع مكررة

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

بالنسبة لـ HTTP (REST) ​​قد نحتاج أيضًا إلى مسار الخدمة:

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

النوع الوهمي

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

أحد البروتوكولات الأكثر استخدامًا هو REST API مع تسلسل Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

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

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

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

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

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

يمكن تمثيل التبعيات باستخدام نمط الكعكة كنقاط نهاية للعقد الأخرى:

  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)
  }

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

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

  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 - غلاف الوظائف سارية المفعول (موناد تقريبًا) (قد نستبدله في النهاية بشيء آخر)

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

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

(انظر شفرة المصدر لتطبيقات الخدمات الأخرى - خدمة الصدى,
عميل الصدى و وحدات تحكم مدى الحياة.)

العقدة هي كائن واحد يقوم بتشغيل عدد قليل من الخدمات (يتم تمكين بدء سلسلة من الموارد بواسطة Cake Pattern):

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

لاحظ أننا في العقدة نحدد نوع التكوين الدقيق الذي تحتاجه هذه العقدة. لن يسمح لنا المترجم ببناء كائن (كعكة) بنوع غير كافٍ، لأن كل سمة خدمة تعلن قيدًا على Config يكتب. كما أننا لن نتمكن من بدء العقدة دون توفير التكوين الكامل.

دقة عنوان العقدة

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

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

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

  1. إذا كنا نعرف العناوين الفعلية قبل النشر، أثناء إنشاء مثيل لمضيفي العقدة، فيمكننا إنشاء كود Scala بالعناوين الفعلية وتشغيل الإنشاء بعد ذلك (الذي يقوم بإجراء فحوصات وقت الترجمة ثم تشغيل مجموعة اختبار التكامل). في هذه الحالة، تكون وظيفة رسم الخرائط لدينا معروفة بشكل ثابت ويمكن تبسيطها إلى شيء مثل a 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 يمر الفاصل.

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

تكوين عقدتين

  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، بينما سيتم إنهاء عميل الصدى بعد المدة المحددة التي تم تكوينها. انظر تطبيق بداية للتفاصيل.

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

دعونا نرى كيف يغير هذا النهج الطريقة التي نعمل بها مع التكوين.

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

يصبح تغيير التكوين تغييرًا في التعليمات البرمجية. لذلك يجب أن تتم تغطيتها بنفس عملية ضمان الجودة:

تذكرة -> العلاقات العامة -> المراجعة -> الدمج -> التكامل المستمر -> النشر المستمر

هناك النتائج التالية لهذا النهج:

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

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

المتغيرات

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

أولاً، سنقوم بإدراج بعض البدائل للجوانب المختلفة للطريقة المقترحة للتعامل مع التكوين:

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

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

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

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

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

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

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

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

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

ونود هنا تسليط الضوء على بعض المزايا ومناقشة بعض عيوب النهج المقترح.

المزايا

ميزات التكوين القابل للتجميع لنظام موزع كامل:

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

عيوب

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

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

هناك بعض القيود على المثال المطبق:

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

وفي الختام

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

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

ويمكن توسيع هذا النهج بطرق مختلفة:

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

شكر

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

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