การกำหนดค่าระบบแบบกระจายที่คอมไพล์

ฉันอยากจะบอกคุณถึงกลไกที่น่าสนใจอย่างหนึ่งในการทำงานกับการกำหนดค่าระบบแบบกระจาย การกำหนดค่าจะแสดงโดยตรงในภาษาที่คอมไพล์ (Scala) โดยใช้ประเภทที่ปลอดภัย โพสต์นี้ให้ตัวอย่างของการกำหนดค่าดังกล่าว และอภิปรายแง่มุมต่างๆ ของการนำการกำหนดค่าที่คอมไพล์ไปใช้ในกระบวนการพัฒนาโดยรวม

การกำหนดค่าระบบแบบกระจายที่คอมไพล์

(อังกฤษ)

การแนะนำ

การสร้างระบบแบบกระจายที่เชื่อถือได้หมายความว่าโหนดทั้งหมดใช้การกำหนดค่าที่ถูกต้อง และซิงโครไนซ์กับโหนดอื่นๆ เทคโนโลยี DevOps (terraform, ansible หรืออะไรทำนองนั้น) มักจะใช้เพื่อสร้างไฟล์การกำหนดค่าโดยอัตโนมัติ (มักจะเฉพาะสำหรับแต่ละโหนด) เรายังต้องการให้แน่ใจว่าโหนดการสื่อสารทั้งหมดใช้โปรโตคอลที่เหมือนกัน (รวมถึงเวอร์ชันเดียวกันด้วย) มิฉะนั้น ความไม่เข้ากันจะถูกสร้างขึ้นในระบบแบบกระจายของเรา ในโลกของ JVM ผลที่ตามมาประการหนึ่งของข้อกำหนดนี้คือเวอร์ชันเดียวกันของไลบรารีที่มีข้อความโปรโตคอลจะต้องถูกใช้ทุกที่

แล้วการทดสอบระบบแบบกระจายล่ะ? แน่นอนว่า เราถือว่าส่วนประกอบทั้งหมดมีการทดสอบหน่วยก่อนที่เราจะไปสู่การทดสอบการรวมระบบ (เพื่อให้เราสามารถคาดการณ์ผลการทดสอบกับรันไทม์ได้ เราต้องจัดเตรียมชุดไลบรารีที่เหมือนกันในขั้นตอนการทดสอบและขณะรันไทม์ด้วย)

เมื่อทำงานกับการทดสอบการรวม มักจะง่ายกว่าที่จะใช้ classpath เดียวกันทุกที่บนทุกโหนด สิ่งที่เราต้องทำคือตรวจสอบให้แน่ใจว่ามีการใช้ classpath เดียวกันที่รันไทม์ (แม้ว่าจะเป็นไปได้โดยสิ้นเชิงในการรันโหนดที่แตกต่างกันด้วยคลาสพาธที่แตกต่างกัน สิ่งนี้จะเพิ่มความซับซ้อนให้กับการกำหนดค่าโดยรวม และความยุ่งยากในการทดสอบการใช้งานและการรวมระบบ) สำหรับจุดประสงค์ของโพสต์นี้ เรากำลังถือว่าโหนดทั้งหมดจะใช้คลาสพาธเดียวกัน

การกำหนดค่าจะพัฒนาขึ้นพร้อมกับแอปพลิเคชัน เราใช้เวอร์ชันต่างๆ เพื่อระบุขั้นตอนต่างๆ ของการพัฒนาโปรแกรม ดูเหมือนสมเหตุสมผลที่จะระบุเวอร์ชันต่างๆ ของการกำหนดค่าด้วย และวางการกำหนดค่าไว้ในระบบควบคุมเวอร์ชัน หากมีการกำหนดค่าเดียวในการผลิต เราก็สามารถใช้หมายเลขเวอร์ชันได้ หากเราใช้อินสแตนซ์การผลิตจำนวนมาก เราก็จะต้องมีหลายอินสแตนซ์
สาขาการกำหนดค่าและป้ายกำกับเพิ่มเติมนอกเหนือจากเวอร์ชัน (เช่น ชื่อสาขา) วิธีนี้ทำให้เราสามารถระบุการกำหนดค่าที่แน่นอนได้อย่างชัดเจน ตัวระบุการกำหนดค่าแต่ละตัวจะสอดคล้องกับชุดค่าผสมเฉพาะของโหนดแบบกระจาย พอร์ต ทรัพยากรภายนอก และเวอร์ชันไลบรารี สำหรับวัตถุประสงค์ของโพสต์นี้ เราจะถือว่ามีเพียงสาขาเดียวเท่านั้น และเราสามารถระบุการกำหนดค่าได้ตามปกติโดยใช้ตัวเลขสามตัวคั่นด้วยจุด (1.2.3)

ในสภาพแวดล้อมสมัยใหม่ ไฟล์การกำหนดค่ามักไม่ค่อยถูกสร้างขึ้นด้วยตนเอง บ่อยครั้งที่พวกมันถูกสร้างขึ้นในระหว่างการปรับใช้และไม่มีการแตะต้องอีกต่อไป (เช่นนั้น อย่าทำลายอะไรเลย). คำถามทั่วไปเกิดขึ้น: เหตุใดเราจึงยังใช้รูปแบบข้อความเพื่อจัดเก็บการกำหนดค่า ทางเลือกอื่นที่ดูเหมือนว่าจะเป็นไปได้คือความสามารถในการใช้โค้ดปกติสำหรับการกำหนดค่าและรับประโยชน์จากการตรวจสอบเวลาคอมไพล์

ในโพสต์นี้ เราจะสำรวจแนวคิดในการนำเสนอการกำหนดค่าภายในสิ่งประดิษฐ์ที่คอมไพล์แล้ว

การกำหนดค่าที่คอมไพล์แล้ว

ส่วนนี้แสดงตัวอย่างของการกำหนดค่าที่คอมไพล์แบบคงที่ มีการนำบริการง่ายๆ สองอย่างไปใช้ - บริการ echo และไคลเอนต์บริการ echo จากบริการทั้งสองนี้ จะมีการรวบรวมตัวเลือกระบบทั้งสองไว้ด้วยกัน ในตัวเลือกหนึ่ง บริการทั้งสองจะอยู่บนโหนดเดียวกันในตัวเลือกอื่น - บนโหนดที่ต่างกัน

โดยปกติแล้วระบบแบบกระจายจะประกอบด้วยหลายโหนด คุณสามารถระบุโหนดได้โดยใช้ค่าบางประเภท 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)
  }

หากต้องการสร้างบริการ Echo สิ่งที่คุณต้องมีคือหมายเลขพอร์ตและการบ่งชี้ว่าพอร์ตรองรับโปรโตคอล Echo เราอาจไม่ได้ระบุพอร์ตเฉพาะเนื่องจาก... ลักษณะช่วยให้คุณสามารถประกาศวิธีการโดยไม่ต้องนำไปใช้ (วิธีนามธรรม) ในกรณีนี้ เมื่อสร้างการกำหนดค่าที่เป็นรูปธรรม คอมไพเลอร์จะต้องให้เราจัดเตรียมการใช้งานวิธีนามธรรมและระบุหมายเลขพอร์ต เนื่องจากเราได้นำวิธีการนี้ไปใช้ เมื่อสร้างการกำหนดค่าเฉพาะ เราจึงอาจไม่ระบุพอร์ตอื่น ค่าเริ่มต้นจะถูกใช้

ในการกำหนดค่าไคลเอนต์เราประกาศการพึ่งพาบริการ 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) ในแอปพลิเคชันที่ซับซ้อนมากขึ้น ดูเหมือนว่าจะใช้งานได้ดีกว่า 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 จริง เป็นไปได้ว่าที่อยู่จะเป็นที่รู้จักช้ากว่าการกำหนดค่าที่เหลือ ดังนั้นเราจึงต้องการฟังก์ชันที่แมป ID โหนดกับที่อยู่:

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. และโฮสต์บริการ echo และ echo client บนโหนดต่างๆ

การกำหนดค่าสำหรับ หนึ่งโหนด:

การกำหนดค่าโหนดเดียว

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

วัตถุใช้การกำหนดค่าของทั้งไคลเอนต์และเซิร์ฟเวอร์ นอกจากนี้ยังใช้การกำหนดค่า time-to-live ดังนั้นหลังจากช่วงเวลาดังกล่าว 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"
  }

สำคัญ! สังเกตว่าบริการเชื่อมโยงกันอย่างไร เราระบุบริการที่ดำเนินการโดยโหนดหนึ่งเป็นการใช้วิธีการพึ่งพาของโหนดอื่น ประเภทการพึ่งพาจะถูกตรวจสอบโดยคอมไพเลอร์เพราะว่า มีประเภทโปรโตคอล เมื่อรัน การขึ้นต่อกันจะมี ID โหนดเป้าหมายที่ถูกต้อง ด้วยรูปแบบนี้ เราจึงระบุหมายเลขพอร์ตได้เพียงครั้งเดียว และรับประกันว่าจะอ้างอิงถึงพอร์ตที่ถูกต้องเสมอ

การดำเนินการของสองโหนดระบบ

สำหรับการกำหนดค่านี้ เราจะใช้บริการแบบเดียวกันโดยไม่มีการเปลี่ยนแปลง ข้อแตกต่างเพียงอย่างเดียวคือตอนนี้เรามีออบเจ็กต์สองรายการที่ใช้ชุดบริการที่แตกต่างกัน:

  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) จะถูกสร้างขึ้น ดูเหมือนว่าจะสมเหตุสมผลที่จะวางการกำหนดค่าไว้ในอาร์ติแฟกต์ที่แยกจากกัน เนื่องจากเราสามารถมีการกำหนดค่าได้หลายแบบโดยใช้รหัสเดียวกัน อีกครั้ง คุณสามารถสร้างอาร์ติแฟกต์ที่สอดคล้องกับสาขาการกำหนดค่าต่างๆ ได้ การพึ่งพาไลบรารีเวอร์ชันเฉพาะจะถูกบันทึกพร้อมกับการกำหนดค่า และเวอร์ชันเหล่านี้จะถูกบันทึกไว้ตลอดไปทุกครั้งที่เราตัดสินใจปรับใช้การกำหนดค่าเวอร์ชันนั้น

การเปลี่ยนแปลงการกำหนดค่าใด ๆ จะกลายเป็นการเปลี่ยนแปลงรหัส ดังนั้นแต่ละคน
การเปลี่ยนแปลงจะครอบคลุมโดยกระบวนการประกันคุณภาพตามปกติ:

ตั๋วในตัวติดตามข้อผิดพลาด -> PR -> บทวิจารณ์ -> รวมเข้ากับสาขาที่เกี่ยวข้อง ->
บูรณาการ -> การใช้งาน

ผลที่ตามมาหลักของการนำการกำหนดค่าที่คอมไพล์ไปใช้คือ:

  1. การกำหนดค่าจะสอดคล้องกันในทุกโหนดของระบบแบบกระจาย เนื่องจากโหนดทั้งหมดได้รับการกำหนดค่าเดียวกันจากแหล่งเดียว

  2. การเปลี่ยนการกำหนดค่าในโหนดเดียวเท่านั้นเป็นปัญหา ดังนั้น "การกำหนดค่าที่ลอยไป" จึงไม่น่าเป็นไปได้

  3. การเปลี่ยนแปลงการกำหนดค่าเล็กน้อยจะยากขึ้น

  4. การเปลี่ยนแปลงการกำหนดค่าส่วนใหญ่จะเกิดขึ้นโดยเป็นส่วนหนึ่งของกระบวนการพัฒนาโดยรวม และจะต้องได้รับการตรวจสอบ

ฉันจำเป็นต้องมีพื้นที่เก็บข้อมูลแยกต่างหากเพื่อจัดเก็บการกำหนดค่าการใช้งานจริงหรือไม่ การกำหนดค่านี้อาจมีรหัสผ่านและข้อมูลที่ละเอียดอ่อนอื่น ๆ ที่เราต้องการจำกัดการเข้าถึง ด้วยเหตุนี้ จึงดูสมเหตุสมผลที่จะจัดเก็บการกำหนดค่าสุดท้ายไว้ในที่เก็บแยกต่างหาก คุณสามารถแบ่งการกำหนดค่าออกเป็นสองส่วน ส่วนแรกประกอบด้วยการตั้งค่าที่เข้าถึงได้แบบสาธารณะ และอีกส่วนประกอบด้วยการตั้งค่าที่จำกัด ซึ่งจะช่วยให้นักพัฒนาส่วนใหญ่สามารถเข้าถึงการตั้งค่าทั่วไปได้ การแยกนี้ทำได้ง่ายโดยใช้คุณลักษณะระดับกลางที่มีค่าเริ่มต้น

รูปแบบที่เป็นไปได้

ลองเปรียบเทียบการกำหนดค่าที่คอมไพล์แล้วกับทางเลือกทั่วไป:

  1. ไฟล์ข้อความบนเครื่องเป้าหมาย
  2. การจัดเก็บคีย์-ค่าแบบรวมศูนย์ (etcd/zookeeper).
  3. ประมวลผลส่วนประกอบที่สามารถกำหนดค่าใหม่/รีสตาร์ทโดยไม่ต้องรีสตาร์ทกระบวนการ
  4. การจัดเก็บการกำหนดค่านอกอาร์ติแฟกต์และการควบคุมเวอร์ชัน

ไฟล์ข้อความมีความยืดหยุ่นอย่างมากในแง่ของการเปลี่ยนแปลงเล็กๆ น้อยๆ ผู้ดูแลระบบสามารถเข้าสู่ระบบโหนดระยะไกล ทำการเปลี่ยนแปลงไฟล์ที่เหมาะสม และรีสตาร์ทบริการได้ อย่างไรก็ตาม สำหรับระบบขนาดใหญ่ ความยืดหยุ่นดังกล่าวอาจไม่เป็นที่ต้องการ การเปลี่ยนแปลงที่เกิดขึ้นไม่ทิ้งร่องรอยไว้ในระบบอื่น ไม่มีใครตรวจสอบการเปลี่ยนแปลง เป็นการยากที่จะตัดสินว่าใครเป็นผู้ทำการเปลี่ยนแปลงและด้วยเหตุผลอะไร การเปลี่ยนแปลงไม่ได้รับการทดสอบ หากระบบเป็นแบบกระจาย ผู้ดูแลระบบอาจลืมทำการเปลี่ยนแปลงที่เกี่ยวข้องบนโหนดอื่น

(ควรสังเกตด้วยว่าการใช้การกำหนดค่าที่คอมไพล์ไม่ได้ปิดความเป็นไปได้ของการใช้ไฟล์ข้อความในอนาคต มันจะเพียงพอที่จะเพิ่ม parser และ validator ที่สร้างประเภทเดียวกันกับเอาต์พุต Configและคุณสามารถใช้ไฟล์ข้อความได้ ตามมาทันทีว่าความซับซ้อนของระบบที่มีการกำหนดค่าที่คอมไพล์นั้นค่อนข้างน้อยกว่าความซับซ้อนของระบบที่ใช้ไฟล์ข้อความเพราะ ไฟล์ข้อความต้องมีรหัสเพิ่มเติม)

ที่เก็บคีย์-ค่าแบบรวมศูนย์เป็นกลไกที่ดีสำหรับการกระจายพารามิเตอร์เมตาของแอปพลิเคชันแบบกระจาย เราจำเป็นต้องตัดสินใจว่าอะไรคือพารามิเตอร์การกำหนดค่า และอะไรเป็นเพียงข้อมูล ให้เรามีหน้าที่ C => A => Bและพารามิเตอร์ C ไม่ค่อยมีการเปลี่ยนแปลงและข้อมูล A - บ่อยครั้ง. ในกรณีนี้เราสามารถพูดได้ว่า C - พารามิเตอร์การกำหนดค่าและ A - ข้อมูล. ดูเหมือนว่าพารามิเตอร์การกำหนดค่าแตกต่างจากข้อมูล โดยโดยทั่วไปแล้วจะเปลี่ยนแปลงน้อยกว่าข้อมูล นอกจากนี้ ข้อมูลมักจะมาจากแหล่งหนึ่ง (จากผู้ใช้) และพารามิเตอร์การกำหนดค่าจากอีกแหล่งหนึ่ง (จากผู้ดูแลระบบ)

หากแทบไม่ต้องอัปเดตพารามิเตอร์โดยไม่ต้องรีสตาร์ทโปรแกรม สิ่งนี้มักจะนำไปสู่ความซับซ้อนของโปรแกรม เนื่องจากเราจะต้องส่งพารามิเตอร์ จัดเก็บ แยกวิเคราะห์ และตรวจสอบ และประมวลผลค่าที่ไม่ถูกต้อง ดังนั้นจากมุมมองของการลดความซับซ้อนของโปรแกรม จึงสมเหตุสมผลที่จะลดจำนวนพารามิเตอร์ที่สามารถเปลี่ยนแปลงได้ระหว่างการทำงานของโปรแกรม (หรือไม่รองรับพารามิเตอร์ดังกล่าวเลย)

สำหรับวัตถุประสงค์ของโพสต์นี้ เราจะแยกความแตกต่างระหว่างพารามิเตอร์คงที่และไดนามิก หากตรรกะของบริการจำเป็นต้องเปลี่ยนพารามิเตอร์ระหว่างการทำงานของโปรแกรม เราจะเรียกพารามิเตอร์ดังกล่าวเป็นไดนามิก มิฉะนั้น ตัวเลือกจะเป็นแบบคงที่และสามารถกำหนดค่าได้โดยใช้การกำหนดค่าที่คอมไพล์แล้ว สำหรับการกำหนดค่าใหม่แบบไดนามิก เราอาจจำเป็นต้องมีกลไกในการรีสตาร์ทบางส่วนของโปรแกรมด้วยพารามิเตอร์ใหม่ คล้ายกับวิธีการรีสตาร์ทกระบวนการของระบบปฏิบัติการ (ในความเห็นของเรา ขอแนะนำให้หลีกเลี่ยงการกำหนดค่าใหม่แบบเรียลไทม์ เนื่องจากจะทำให้ระบบมีความซับซ้อนมากขึ้น หากเป็นไปได้ ควรใช้ความสามารถมาตรฐานของระบบปฏิบัติการในการรีสตาร์ทกระบวนการ)

สิ่งสำคัญประการหนึ่งของการใช้การกำหนดค่าแบบคงที่ที่ทำให้ผู้คนพิจารณาการกำหนดค่าใหม่แบบไดนามิกคือเวลาที่ระบบใช้ในการรีบูตหลังจากการอัพเดตการกำหนดค่า (เวลาหยุดทำงาน) ที่จริงแล้วหากเราจำเป็นต้องเปลี่ยนแปลงการกำหนดค่าคงที่ เราจะต้องรีสตาร์ทระบบเพื่อให้ค่าใหม่มีผล ปัญหาการหยุดทำงานจะแตกต่างกันไปตามระดับความรุนแรงสำหรับระบบต่างๆ ในบางกรณี คุณสามารถกำหนดเวลาการรีบูตในเวลาที่มีโหลดน้อยที่สุดได้ หากคุณต้องการให้บริการอย่างต่อเนื่อง คุณสามารถดำเนินการได้ การเชื่อมต่อ AWS ELB หมดลง. ในเวลาเดียวกัน เมื่อเราต้องการรีบูตระบบ เราจะเปิดใช้อินสแตนซ์แบบขนานของระบบนี้ สลับบาลานเซอร์ไปที่อินสแตนซ์ และรอให้การเชื่อมต่อเก่าเสร็จสมบูรณ์ หลังจากที่การเชื่อมต่อเก่าทั้งหมดสิ้นสุดลง เราจะปิดอินสแตนซ์เก่าของระบบ

ให้เราพิจารณาปัญหาของการจัดเก็บการกำหนดค่าภายในหรือภายนอกสิ่งประดิษฐ์ หากเราจัดเก็บการกำหนดค่าไว้ภายในอาร์ติแฟกต์ อย่างน้อยเราก็มีโอกาสที่จะตรวจสอบความถูกต้องของการกำหนดค่าในระหว่างการประกอบอาร์ติแฟกต์ หากการกำหนดค่าอยู่นอกส่วนที่มีการควบคุม จะเป็นการยากที่จะติดตามว่าใครทำการเปลี่ยนแปลงไฟล์นี้และเพราะเหตุใด มันสำคัญแค่ไหน? ในความเห็นของเรา สำหรับระบบการผลิตหลายๆ ระบบ สิ่งสำคัญคือต้องมีโครงร่างที่เสถียรและมีคุณภาพสูง

เวอร์ชันของสิ่งประดิษฐ์ช่วยให้คุณสามารถระบุได้ว่าสร้างขึ้นเมื่อใด มีค่าใดบ้าง ฟังก์ชันใดบ้างที่เปิด/ปิดใช้งาน และใครเป็นผู้รับผิดชอบการเปลี่ยนแปลงใดๆ ในการกำหนดค่า แน่นอนว่า การจัดเก็บการกำหนดค่าภายในอาร์ติแฟกต์นั้นต้องใช้ความพยายาม ดังนั้นคุณจึงจำเป็นต้องตัดสินใจอย่างมีข้อมูล

ข้อดีและข้อเสีย

ฉันอยากจะพูดถึงข้อดีข้อเสียของเทคโนโลยีที่นำเสนอ

ข้อดี

ด้านล่างนี้คือรายการคุณลักษณะหลักของการกำหนดค่าระบบแบบกระจายที่คอมไพล์แล้ว:

  1. การตรวจสอบการกำหนดค่าแบบคงที่ ช่วยให้คุณมั่นใจได้ว่า
    การกำหนดค่าถูกต้อง
  2. ภาษาการกำหนดค่าที่หลากหลาย โดยปกติแล้ว วิธีการกำหนดค่าอื่นๆ จะถูกจำกัดไว้เพียงการทดแทนตัวแปรสตริงเป็นส่วนใหญ่ เมื่อใช้ Scala จะมีฟีเจอร์ภาษาที่หลากหลายเพื่อปรับปรุงการกำหนดค่าของคุณ เช่น เราสามารถใช้
    คุณลักษณะสำหรับค่าเริ่มต้น โดยใช้อ็อบเจ็กต์เพื่อจัดกลุ่มพารามิเตอร์ เราสามารถอ้างถึง vals ที่ประกาศเพียงครั้งเดียว (DRY) ในขอบเขตที่ล้อมรอบ คุณสามารถยกตัวอย่างคลาสใดๆ ได้โดยตรงภายในการกำหนดค่า (Seq, Map, คลาสแบบกำหนดเอง)
  3. ดีเอสแอล. Scala มีฟีเจอร์ภาษามากมายที่ทำให้การสร้าง DSL ง่ายขึ้น คุณสามารถใช้ประโยชน์จากคุณสมบัติเหล่านี้และใช้ภาษาการกำหนดค่าที่สะดวกยิ่งขึ้นสำหรับกลุ่มเป้าหมายของผู้ใช้ เพื่อให้ผู้เชี่ยวชาญโดเมนสามารถอ่านการกำหนดค่าได้เป็นอย่างน้อย ตัวอย่างเช่น ผู้เชี่ยวชาญสามารถมีส่วนร่วมในกระบวนการตรวจสอบการกำหนดค่าได้
  4. ความสมบูรณ์และการซิงโครไนซ์ระหว่างโหนด ข้อดีอย่างหนึ่งของการมีการกำหนดค่าของระบบแบบกระจายทั้งหมดที่ถูกจัดเก็บไว้ที่จุดเดียวคือค่าทั้งหมดจะถูกประกาศเพียงครั้งเดียว จากนั้นจึงนำกลับมาใช้ใหม่ในทุกที่ที่ต้องการ การใช้ประเภท Phantom เพื่อประกาศพอร์ตช่วยให้แน่ใจว่าโหนดใช้โปรโตคอลที่เข้ากันได้ในการกำหนดค่าระบบที่ถูกต้องทั้งหมด การมีการพึ่งพาบังคับอย่างชัดเจนระหว่างโหนดช่วยให้แน่ใจว่าบริการทั้งหมดเชื่อมต่อกัน
  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) ตัวจัดการคลัสเตอร์ได้รับข้อมูลเกี่ยวกับโหนดใหม่

บลาโกดาเรนนอสตี

ฉันขอขอบคุณ Andrei Saksonov, Pavel Popov และ Anton Nekhaev สำหรับการวิจารณ์อย่างสร้างสรรค์ต่อร่างบทความ

ที่มา: will.com

เพิ่มความคิดเห็น