پیکربندی سیستم توزیع شده کامپایل شده است

من می خواهم یک مکانیسم جالب برای کار با پیکربندی یک سیستم توزیع شده را به شما بگویم. پیکربندی مستقیماً در یک زبان کامپایل شده (Scala) با استفاده از انواع امن نمایش داده می شود. این پست نمونه ای از چنین پیکربندی را ارائه می دهد و جنبه های مختلف پیاده سازی یک پیکربندی کامپایل شده در فرآیند کلی توسعه را مورد بحث قرار می دهد.

پیکربندی سیستم توزیع شده کامپایل شده است

(انگلیسی)

معرفی

ساختن یک سیستم توزیع شده قابل اعتماد به این معنی است که همه گره ها از پیکربندی صحیح و هماهنگ با سایر گره ها استفاده می کنند. فناوری‌های DevOps (terraform، ansible یا چیزی شبیه به آن) معمولاً برای تولید خودکار فایل‌های پیکربندی (اغلب برای هر گره خاص) استفاده می‌شوند. ما همچنین می‌خواهیم مطمئن باشیم که همه گره‌های ارتباطی از پروتکل‌های یکسان (از جمله نسخه مشابه) استفاده می‌کنند. در غیر این صورت، ناسازگاری در سیستم توزیع شده ما ایجاد می شود. در دنیای JVM، یکی از پیامدهای این الزام این است که نسخه یکسان کتابخانه حاوی پیام های پروتکل باید در همه جا استفاده شود.

در مورد آزمایش یک سیستم توزیع شده چطور؟ البته، قبل از اینکه به تست یکپارچه‌سازی برویم، فرض می‌کنیم که همه اجزا دارای تست واحد هستند. (برای اینکه بتوانیم نتایج آزمایش را به زمان اجرا برون یابی کنیم، باید مجموعه ای از کتابخانه های یکسان را در مرحله آزمایش و در زمان اجرا نیز ارائه کنیم.)

هنگام کار با تست‌های یکپارچه‌سازی، استفاده از یک مسیر کلاس در همه جا در همه گره‌ها آسان‌تر است. تنها کاری که باید انجام دهیم این است که اطمینان حاصل کنیم که از همان classpath در زمان اجرا استفاده می شود. (در حالی که اجرای گره‌های مختلف با کلاس‌های مختلف کاملاً امکان‌پذیر است، این امر به پیکربندی کلی و دشواری‌های آزمایش‌های استقرار و ادغام می‌افزاید.) برای اهداف این پست، فرض می‌کنیم که همه گره‌ها از یک مسیر کلاس استفاده خواهند کرد.

پیکربندی با برنامه تکامل می یابد. ما از نسخه ها برای شناسایی مراحل مختلف تکامل برنامه استفاده می کنیم. منطقی به نظر می رسد که نسخه های مختلف پیکربندی را نیز شناسایی کنید. و خود پیکربندی را در سیستم کنترل نسخه قرار دهید. اگر فقط یک پیکربندی در تولید وجود دارد، می‌توانیم به سادگی از شماره نسخه استفاده کنیم. اگر از نمونه های تولید زیادی استفاده کنیم، به چندین مورد نیاز خواهیم داشت
شاخه های پیکربندی و یک برچسب اضافی علاوه بر نسخه (به عنوان مثال، نام شاخه). به این ترتیب می توانیم پیکربندی دقیق را به وضوح شناسایی کنیم. هر شناسه پیکربندی منحصراً با ترکیب خاصی از گره های توزیع شده، پورت ها، منابع خارجی و نسخه های کتابخانه مطابقت دارد. برای اهداف این پست فرض می کنیم که فقط یک شاخه وجود دارد و می توانیم پیکربندی را به روش معمول با استفاده از سه عدد که با یک نقطه از هم جدا شده اند شناسایی کنیم (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 است.

پیکربندی سرویس با نام سرویس، پورت ها و وابستگی ها توصیف می شود. این عناصر را می توان در اسکالا به روش های مختلفی نشان داد (به عنوان مثال، HList-s، انواع داده های جبری). برای اهداف این پست، ما از الگوی کیک استفاده می کنیم و ماژول ها را با استفاده از آن نشان می دهیم trait'ov. (الگوی کیک عنصر مورد نیاز این رویکرد نیست. این فقط یک پیاده سازی ممکن است.)

وابستگی های بین سرویس ها را می توان به عنوان روش هایی که پورت ها را برمی گرداند نشان داد 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)
  }

برای ایجاد یک سرویس اکو، تنها چیزی که نیاز دارید یک شماره پورت و نشانه ای از پشتیبانی پورت از پروتکل اکو است. ممکن است پورت خاصی را مشخص نکنیم، زیرا... ویژگی ها به شما امکان می دهد روش ها را بدون پیاده سازی (روش های انتزاعی) اعلام کنید. در این حالت، هنگام ایجاد یک پیکربندی مشخص، کامپایلر از ما می خواهد که یک پیاده سازی از متد انتزاعی و ارائه یک شماره پورت را ارائه دهیم. از آنجایی که ما این روش را پیاده سازی کرده ایم، هنگام ایجاد یک پیکربندی خاص، ممکن است پورت دیگری را مشخص نکنیم. مقدار پیش فرض استفاده خواهد شد.

در پیکربندی کلاینت ما یک وابستگی به سرویس echo اعلام می کنیم:

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

وابستگی از همان نوع سرویس صادر شده است echoService. به طور خاص، در مشتری echo ما به همان پروتکل نیاز داریم. بنابراین، هنگام اتصال دو سرویس، می توانیم مطمئن باشیم که همه چیز به درستی کار می کند.

اجرای خدمات

یک تابع برای شروع و توقف سرویس مورد نیاز است. (قابلیت توقف سرویس برای آزمایش بسیار مهم است.) باز هم، چندین گزینه برای پیاده سازی چنین ویژگی وجود دارد (به عنوان مثال، می توانیم از کلاس های نوع بر اساس نوع پیکربندی استفاده کنیم). برای اهداف این پست از الگوی کیک استفاده خواهیم کرد. ما سرویس را با استفاده از یک کلاس نشان خواهیم داد 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
  }

اولین گره سرور را پیاده سازی می کند و فقط به پیکربندی سرور نیاز دارد. گره دوم کلاینت را پیاده سازی می کند و از بخش متفاوتی از پیکربندی استفاده می کند. همچنین هر دو گره نیاز به مدیریت مادام العمر دارند. گره سرور به طور نامحدود اجرا می شود تا زمانی که متوقف شود SIGTERMom، و گره مشتری پس از مدتی خاتمه می یابد. سانتی متر. برنامه لانچر.

فرآیند توسعه عمومی

بیایید ببینیم این رویکرد پیکربندی چگونه بر روند کلی توسعه تأثیر می گذارد.

پیکربندی به همراه بقیه کد کامپایل می شود و یک آرتیفکت (.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. DSL. 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. برای اهداف این پست، ما پیکربندی مجدد پویا دسته ای از گره های مشابه را در نظر نمی گیریم.

نتیجه

در این پست، ایده نمایش پیکربندی در کد منبع با استفاده از قابلیت های پیشرفته سیستم نوع اسکالا را بررسی کردیم. این رویکرد می تواند در برنامه های مختلف به عنوان جایگزینی برای روش های پیکربندی سنتی مبتنی بر فایل های xml یا متنی استفاده شود. حتی اگر مثال ما در Scala پیاده سازی شده است، همان ایده ها را می توان به سایر زبان های کامپایل شده (مانند Kotlin، C#، Swift، ...) منتقل کرد. می‌توانید این روش را در یکی از پروژه‌های زیر امتحان کنید، و اگر جواب نداد، به فایل متنی بروید و قسمت‌های از دست رفته را اضافه کنید.

به طور طبیعی، یک پیکربندی کامپایل شده به یک فرآیند توسعه با کیفیت بالا نیاز دارد. در عوض، کیفیت بالا و قابلیت اطمینان پیکربندی ها تضمین می شود.

رویکرد مورد نظر را می توان گسترش داد:

  1. می توانید از ماکروها برای انجام بررسی های زمان کامپایل استفاده کنید.
  2. شما می توانید یک DSL را برای ارائه پیکربندی به گونه ای پیاده سازی کنید که برای کاربران نهایی قابل دسترسی باشد.
  3. شما می توانید مدیریت منابع پویا را با تنظیم پیکربندی خودکار پیاده سازی کنید. برای مثال، تغییر تعداد گره ها در یک خوشه مستلزم آن است که (1) هر گره پیکربندی کمی متفاوت دریافت کند. (2) مدیر خوشه اطلاعاتی در مورد گره های جدید دریافت کرد.

تقدیر و تشکر

مایلم از آندری ساکسونوف، پاول پوپوف و آنتون نخایف به خاطر انتقاد سازنده آنها از پیش نویس مقاله تشکر کنم.

منبع: www.habr.com

اضافه کردن نظر