ฉันอยากจะบอกคุณถึงกลไกที่น่าสนใจอย่างหนึ่งในการทำงานกับการกำหนดค่าระบบแบบกระจาย การกำหนดค่าจะแสดงโดยตรงในภาษาที่คอมไพล์ (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]]
ประเภทการกลั่น
ดูห้องสมุด
สำหรับโปรโตคอล 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]]
}
มีหลายวิธีในการใช้ฟังก์ชันนี้:
- หากเราทราบที่อยู่ก่อนที่จะปรับใช้ เราก็สามารถสร้างโค้ด Scala ได้
ที่อยู่ จากนั้นรันบิลด์ สิ่งนี้จะรวบรวมและรันการทดสอบ
ในกรณีนี้ ฟังก์ชันจะเป็นที่รู้จักแบบคงที่และสามารถแสดงเป็นโค้ดเป็นการแมปได้Map[NodeId, NodeAddress]
. - ในบางกรณี ที่อยู่จริงจะทราบหลังจากที่โหนดเริ่มทำงานแล้วเท่านั้น
ในกรณีนี้ เราสามารถใช้ “บริการค้นพบ” ที่ทำงานก่อนโหนดอื่นๆ และโหนดทั้งหมดจะลงทะเบียนกับบริการนี้และขอที่อยู่ของโหนดอื่นๆ - ถ้าเราปรับเปลี่ยนได้
/etc/hosts
จากนั้นคุณสามารถใช้ชื่อโฮสต์ที่กำหนดไว้ล่วงหน้าได้ (เช่นmy-project-main-node
иecho-backend
) และเพียงเชื่อมโยงชื่อเหล่านี้
ด้วยที่อยู่ IP ระหว่างการใช้งาน
ในโพสต์นี้ เราจะไม่พิจารณากรณีเหล่านี้โดยละเอียด สำหรับพวกเรา
ในตัวอย่างของเล่น โหนดทั้งหมดจะมีที่อยู่ IP เดียวกัน - 127.0.0.1
.
ต่อไป เราจะพิจารณาสองตัวเลือกสำหรับระบบแบบกระจาย:
- การวางบริการทั้งหมดไว้ในโหนดเดียว
- และโฮสต์บริการ 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 -> บทวิจารณ์ -> รวมเข้ากับสาขาที่เกี่ยวข้อง ->
บูรณาการ -> การใช้งาน
ผลที่ตามมาหลักของการนำการกำหนดค่าที่คอมไพล์ไปใช้คือ:
-
การกำหนดค่าจะสอดคล้องกันในทุกโหนดของระบบแบบกระจาย เนื่องจากโหนดทั้งหมดได้รับการกำหนดค่าเดียวกันจากแหล่งเดียว
-
การเปลี่ยนการกำหนดค่าในโหนดเดียวเท่านั้นเป็นปัญหา ดังนั้น "การกำหนดค่าที่ลอยไป" จึงไม่น่าเป็นไปได้
-
การเปลี่ยนแปลงการกำหนดค่าเล็กน้อยจะยากขึ้น
-
การเปลี่ยนแปลงการกำหนดค่าส่วนใหญ่จะเกิดขึ้นโดยเป็นส่วนหนึ่งของกระบวนการพัฒนาโดยรวม และจะต้องได้รับการตรวจสอบ
ฉันจำเป็นต้องมีพื้นที่เก็บข้อมูลแยกต่างหากเพื่อจัดเก็บการกำหนดค่าการใช้งานจริงหรือไม่ การกำหนดค่านี้อาจมีรหัสผ่านและข้อมูลที่ละเอียดอ่อนอื่น ๆ ที่เราต้องการจำกัดการเข้าถึง ด้วยเหตุนี้ จึงดูสมเหตุสมผลที่จะจัดเก็บการกำหนดค่าสุดท้ายไว้ในที่เก็บแยกต่างหาก คุณสามารถแบ่งการกำหนดค่าออกเป็นสองส่วน ส่วนแรกประกอบด้วยการตั้งค่าที่เข้าถึงได้แบบสาธารณะ และอีกส่วนประกอบด้วยการตั้งค่าที่จำกัด ซึ่งจะช่วยให้นักพัฒนาส่วนใหญ่สามารถเข้าถึงการตั้งค่าทั่วไปได้ การแยกนี้ทำได้ง่ายโดยใช้คุณลักษณะระดับกลางที่มีค่าเริ่มต้น
รูปแบบที่เป็นไปได้
ลองเปรียบเทียบการกำหนดค่าที่คอมไพล์แล้วกับทางเลือกทั่วไป:
- ไฟล์ข้อความบนเครื่องเป้าหมาย
- การจัดเก็บคีย์-ค่าแบบรวมศูนย์ (
etcd
/zookeeper
). - ประมวลผลส่วนประกอบที่สามารถกำหนดค่าใหม่/รีสตาร์ทโดยไม่ต้องรีสตาร์ทกระบวนการ
- การจัดเก็บการกำหนดค่านอกอาร์ติแฟกต์และการควบคุมเวอร์ชัน
ไฟล์ข้อความมีความยืดหยุ่นอย่างมากในแง่ของการเปลี่ยนแปลงเล็กๆ น้อยๆ ผู้ดูแลระบบสามารถเข้าสู่ระบบโหนดระยะไกล ทำการเปลี่ยนแปลงไฟล์ที่เหมาะสม และรีสตาร์ทบริการได้ อย่างไรก็ตาม สำหรับระบบขนาดใหญ่ ความยืดหยุ่นดังกล่าวอาจไม่เป็นที่ต้องการ การเปลี่ยนแปลงที่เกิดขึ้นไม่ทิ้งร่องรอยไว้ในระบบอื่น ไม่มีใครตรวจสอบการเปลี่ยนแปลง เป็นการยากที่จะตัดสินว่าใครเป็นผู้ทำการเปลี่ยนแปลงและด้วยเหตุผลอะไร การเปลี่ยนแปลงไม่ได้รับการทดสอบ หากระบบเป็นแบบกระจาย ผู้ดูแลระบบอาจลืมทำการเปลี่ยนแปลงที่เกี่ยวข้องบนโหนดอื่น
(ควรสังเกตด้วยว่าการใช้การกำหนดค่าที่คอมไพล์ไม่ได้ปิดความเป็นไปได้ของการใช้ไฟล์ข้อความในอนาคต มันจะเพียงพอที่จะเพิ่ม parser และ validator ที่สร้างประเภทเดียวกันกับเอาต์พุต Config
และคุณสามารถใช้ไฟล์ข้อความได้ ตามมาทันทีว่าความซับซ้อนของระบบที่มีการกำหนดค่าที่คอมไพล์นั้นค่อนข้างน้อยกว่าความซับซ้อนของระบบที่ใช้ไฟล์ข้อความเพราะ ไฟล์ข้อความต้องมีรหัสเพิ่มเติม)
ที่เก็บคีย์-ค่าแบบรวมศูนย์เป็นกลไกที่ดีสำหรับการกระจายพารามิเตอร์เมตาของแอปพลิเคชันแบบกระจาย เราจำเป็นต้องตัดสินใจว่าอะไรคือพารามิเตอร์การกำหนดค่า และอะไรเป็นเพียงข้อมูล ให้เรามีหน้าที่ C => A => B
และพารามิเตอร์ C
ไม่ค่อยมีการเปลี่ยนแปลงและข้อมูล A
- บ่อยครั้ง. ในกรณีนี้เราสามารถพูดได้ว่า C
- พารามิเตอร์การกำหนดค่าและ A
- ข้อมูล. ดูเหมือนว่าพารามิเตอร์การกำหนดค่าแตกต่างจากข้อมูล โดยโดยทั่วไปแล้วจะเปลี่ยนแปลงน้อยกว่าข้อมูล นอกจากนี้ ข้อมูลมักจะมาจากแหล่งหนึ่ง (จากผู้ใช้) และพารามิเตอร์การกำหนดค่าจากอีกแหล่งหนึ่ง (จากผู้ดูแลระบบ)
หากแทบไม่ต้องอัปเดตพารามิเตอร์โดยไม่ต้องรีสตาร์ทโปรแกรม สิ่งนี้มักจะนำไปสู่ความซับซ้อนของโปรแกรม เนื่องจากเราจะต้องส่งพารามิเตอร์ จัดเก็บ แยกวิเคราะห์ และตรวจสอบ และประมวลผลค่าที่ไม่ถูกต้อง ดังนั้นจากมุมมองของการลดความซับซ้อนของโปรแกรม จึงสมเหตุสมผลที่จะลดจำนวนพารามิเตอร์ที่สามารถเปลี่ยนแปลงได้ระหว่างการทำงานของโปรแกรม (หรือไม่รองรับพารามิเตอร์ดังกล่าวเลย)
สำหรับวัตถุประสงค์ของโพสต์นี้ เราจะแยกความแตกต่างระหว่างพารามิเตอร์คงที่และไดนามิก หากตรรกะของบริการจำเป็นต้องเปลี่ยนพารามิเตอร์ระหว่างการทำงานของโปรแกรม เราจะเรียกพารามิเตอร์ดังกล่าวเป็นไดนามิก มิฉะนั้น ตัวเลือกจะเป็นแบบคงที่และสามารถกำหนดค่าได้โดยใช้การกำหนดค่าที่คอมไพล์แล้ว สำหรับการกำหนดค่าใหม่แบบไดนามิก เราอาจจำเป็นต้องมีกลไกในการรีสตาร์ทบางส่วนของโปรแกรมด้วยพารามิเตอร์ใหม่ คล้ายกับวิธีการรีสตาร์ทกระบวนการของระบบปฏิบัติการ (ในความเห็นของเรา ขอแนะนำให้หลีกเลี่ยงการกำหนดค่าใหม่แบบเรียลไทม์ เนื่องจากจะทำให้ระบบมีความซับซ้อนมากขึ้น หากเป็นไปได้ ควรใช้ความสามารถมาตรฐานของระบบปฏิบัติการในการรีสตาร์ทกระบวนการ)
สิ่งสำคัญประการหนึ่งของการใช้การกำหนดค่าแบบคงที่ที่ทำให้ผู้คนพิจารณาการกำหนดค่าใหม่แบบไดนามิกคือเวลาที่ระบบใช้ในการรีบูตหลังจากการอัพเดตการกำหนดค่า (เวลาหยุดทำงาน) ที่จริงแล้วหากเราจำเป็นต้องเปลี่ยนแปลงการกำหนดค่าคงที่ เราจะต้องรีสตาร์ทระบบเพื่อให้ค่าใหม่มีผล ปัญหาการหยุดทำงานจะแตกต่างกันไปตามระดับความรุนแรงสำหรับระบบต่างๆ ในบางกรณี คุณสามารถกำหนดเวลาการรีบูตในเวลาที่มีโหลดน้อยที่สุดได้ หากคุณต้องการให้บริการอย่างต่อเนื่อง คุณสามารถดำเนินการได้
ให้เราพิจารณาปัญหาของการจัดเก็บการกำหนดค่าภายในหรือภายนอกสิ่งประดิษฐ์ หากเราจัดเก็บการกำหนดค่าไว้ภายในอาร์ติแฟกต์ อย่างน้อยเราก็มีโอกาสที่จะตรวจสอบความถูกต้องของการกำหนดค่าในระหว่างการประกอบอาร์ติแฟกต์ หากการกำหนดค่าอยู่นอกส่วนที่มีการควบคุม จะเป็นการยากที่จะติดตามว่าใครทำการเปลี่ยนแปลงไฟล์นี้และเพราะเหตุใด มันสำคัญแค่ไหน? ในความเห็นของเรา สำหรับระบบการผลิตหลายๆ ระบบ สิ่งสำคัญคือต้องมีโครงร่างที่เสถียรและมีคุณภาพสูง
เวอร์ชันของสิ่งประดิษฐ์ช่วยให้คุณสามารถระบุได้ว่าสร้างขึ้นเมื่อใด มีค่าใดบ้าง ฟังก์ชันใดบ้างที่เปิด/ปิดใช้งาน และใครเป็นผู้รับผิดชอบการเปลี่ยนแปลงใดๆ ในการกำหนดค่า แน่นอนว่า การจัดเก็บการกำหนดค่าภายในอาร์ติแฟกต์นั้นต้องใช้ความพยายาม ดังนั้นคุณจึงจำเป็นต้องตัดสินใจอย่างมีข้อมูล
ข้อดีและข้อเสีย
ฉันอยากจะพูดถึงข้อดีข้อเสียของเทคโนโลยีที่นำเสนอ
ข้อดี
ด้านล่างนี้คือรายการคุณลักษณะหลักของการกำหนดค่าระบบแบบกระจายที่คอมไพล์แล้ว:
- การตรวจสอบการกำหนดค่าแบบคงที่ ช่วยให้คุณมั่นใจได้ว่า
การกำหนดค่าถูกต้อง - ภาษาการกำหนดค่าที่หลากหลาย โดยปกติแล้ว วิธีการกำหนดค่าอื่นๆ จะถูกจำกัดไว้เพียงการทดแทนตัวแปรสตริงเป็นส่วนใหญ่ เมื่อใช้ Scala จะมีฟีเจอร์ภาษาที่หลากหลายเพื่อปรับปรุงการกำหนดค่าของคุณ เช่น เราสามารถใช้
คุณลักษณะสำหรับค่าเริ่มต้น โดยใช้อ็อบเจ็กต์เพื่อจัดกลุ่มพารามิเตอร์ เราสามารถอ้างถึง vals ที่ประกาศเพียงครั้งเดียว (DRY) ในขอบเขตที่ล้อมรอบ คุณสามารถยกตัวอย่างคลาสใดๆ ได้โดยตรงภายในการกำหนดค่า (Seq
,Map
, คลาสแบบกำหนดเอง) - ดีเอสแอล. Scala มีฟีเจอร์ภาษามากมายที่ทำให้การสร้าง DSL ง่ายขึ้น คุณสามารถใช้ประโยชน์จากคุณสมบัติเหล่านี้และใช้ภาษาการกำหนดค่าที่สะดวกยิ่งขึ้นสำหรับกลุ่มเป้าหมายของผู้ใช้ เพื่อให้ผู้เชี่ยวชาญโดเมนสามารถอ่านการกำหนดค่าได้เป็นอย่างน้อย ตัวอย่างเช่น ผู้เชี่ยวชาญสามารถมีส่วนร่วมในกระบวนการตรวจสอบการกำหนดค่าได้
- ความสมบูรณ์และการซิงโครไนซ์ระหว่างโหนด ข้อดีอย่างหนึ่งของการมีการกำหนดค่าของระบบแบบกระจายทั้งหมดที่ถูกจัดเก็บไว้ที่จุดเดียวคือค่าทั้งหมดจะถูกประกาศเพียงครั้งเดียว จากนั้นจึงนำกลับมาใช้ใหม่ในทุกที่ที่ต้องการ การใช้ประเภท Phantom เพื่อประกาศพอร์ตช่วยให้แน่ใจว่าโหนดใช้โปรโตคอลที่เข้ากันได้ในการกำหนดค่าระบบที่ถูกต้องทั้งหมด การมีการพึ่งพาบังคับอย่างชัดเจนระหว่างโหนดช่วยให้แน่ใจว่าบริการทั้งหมดเชื่อมต่อกัน
- การเปลี่ยนแปลงคุณภาพสูง การเปลี่ยนแปลงการกำหนดค่าโดยใช้กระบวนการพัฒนาทั่วไปทำให้สามารถบรรลุมาตรฐานคุณภาพสูงสำหรับการกำหนดค่าได้เช่นกัน
- อัพเดตการกำหนดค่าพร้อมกัน การปรับใช้ระบบอัตโนมัติหลังจากการเปลี่ยนแปลงการกำหนดค่าทำให้มั่นใจได้ว่าโหนดทั้งหมดได้รับการอัปเดต
- ลดความซับซ้อนของแอปพลิเคชัน แอปพลิเคชันไม่จำเป็นต้องแยกวิเคราะห์ ตรวจสอบการกำหนดค่า หรือจัดการค่าที่ไม่ถูกต้อง ซึ่งจะช่วยลดความซับซ้อนของแอปพลิเคชัน (ความซับซ้อนของการกำหนดค่าบางอย่างที่พบในตัวอย่างของเราไม่ใช่คุณลักษณะของการกำหนดค่าที่คอมไพล์แล้ว แต่เป็นเพียงการตัดสินใจอย่างมีสติซึ่งขับเคลื่อนโดยความปรารถนาที่จะให้ความปลอดภัยประเภทที่มากขึ้น) มันค่อนข้างง่ายที่จะกลับไปสู่การกำหนดค่าปกติ - เพียงใช้ส่วนที่ขาดหายไป ชิ้นส่วน ดังนั้น คุณสามารถเริ่มต้นด้วยการกำหนดค่าที่คอมไพล์แล้ว โดยเลื่อนการดำเนินการส่วนที่ไม่จำเป็นออกไปจนกว่าจะถึงเวลาที่จำเป็นจริงๆ
- การกำหนดค่าที่ได้รับการยืนยัน เนื่องจากการเปลี่ยนแปลงการกำหนดค่าเป็นไปตามชะตากรรมปกติของการเปลี่ยนแปลงอื่นๆ ผลลัพธ์ที่เราได้รับคืออาร์ติแฟกต์ที่มีเวอร์ชันเฉพาะ สิ่งนี้ช่วยให้เราสามารถกลับไปใช้การกำหนดค่าเวอร์ชันก่อนหน้าได้หากจำเป็น เรายังสามารถใช้การกำหนดค่าจากปีที่แล้วและระบบจะทำงานเหมือนเดิมทุกประการ การกำหนดค่าที่เสถียรช่วยเพิ่มความสามารถในการคาดการณ์และความน่าเชื่อถือของระบบแบบกระจาย เนื่องจากการกำหนดค่าได้รับการแก้ไขในขั้นตอนการคอมไพล์ จึงค่อนข้างยากที่จะปลอมแปลงในระหว่างการใช้งานจริง
- ความเป็นโมดูลาร์ กรอบงานที่นำเสนอเป็นแบบโมดูลาร์และสามารถรวมโมดูลต่างๆ เข้าด้วยกันได้หลายวิธีเพื่อสร้างระบบที่แตกต่างกัน โดยเฉพาะอย่างยิ่ง คุณสามารถกำหนดค่าระบบให้รันบนโหนดเดียวในรูปลักษณ์หนึ่ง และบนหลายโหนดในอีกรูปลักษณ์หนึ่ง คุณสามารถสร้างการกำหนดค่าต่างๆ สำหรับอินสแตนซ์ที่ใช้งานจริงของระบบได้
- การทดสอบ ด้วยการแทนที่บริการแต่ละรายการด้วยออบเจ็กต์จำลอง คุณจะได้รับระบบหลายเวอร์ชันที่สะดวกสำหรับการทดสอบ
- การทดสอบบูรณาการ การมีการกำหนดค่าเดียวสำหรับทั้งระบบแบบกระจายทำให้สามารถรันส่วนประกอบทั้งหมดในสภาพแวดล้อมที่มีการควบคุมโดยเป็นส่วนหนึ่งของการทดสอบการรวมระบบ มันง่ายที่จะจำลอง ตัวอย่างเช่น สถานการณ์ที่บางโหนดสามารถเข้าถึงได้
ข้อเสียและข้อจำกัด
การกำหนดค่าที่คอมไพล์แตกต่างจากวิธีการกำหนดค่าอื่นๆ และอาจไม่เหมาะกับบางแอปพลิเคชัน ด้านล่างนี้เป็นข้อเสียบางประการ:
- การกำหนดค่าแบบคงที่ บางครั้งคุณจำเป็นต้องแก้ไขการกำหนดค่าในการผลิตอย่างรวดเร็ว โดยข้ามกลไกการป้องกันทั้งหมด ด้วยวิธีนี้อาจทำได้ยากขึ้น อย่างน้อยที่สุดก็ยังจำเป็นต้องมีการคอมไพล์และการปรับใช้อัตโนมัติ นี่เป็นทั้งคุณลักษณะที่เป็นประโยชน์ของแนวทางนี้และเป็นข้อเสียในบางกรณี
- การสร้างการกำหนดค่า ในกรณีที่ไฟล์การกำหนดค่าถูกสร้างขึ้นโดยเครื่องมืออัตโนมัติ อาจต้องใช้ความพยายามเพิ่มเติมในการรวมสคริปต์การสร้าง
- เครื่องมือ. ปัจจุบันยูทิลิตี้และเทคนิคที่ออกแบบมาเพื่อทำงานกับการกำหนดค่าจะขึ้นอยู่กับไฟล์ข้อความ ยูทิลิตี้/เทคนิคบางอย่างอาจไม่พร้อมใช้งานในการกำหนดค่าที่คอมไพล์แล้ว
- จำเป็นต้องมีการเปลี่ยนแปลงทัศนคติ นักพัฒนาและ DevOps คุ้นเคยกับไฟล์ข้อความ แนวคิดในการรวบรวมการกำหนดค่าอาจค่อนข้างไม่คาดคิดและผิดปกติและทำให้เกิดการปฏิเสธ
- จำเป็นต้องมีกระบวนการพัฒนาคุณภาพสูง เพื่อที่จะใช้การกำหนดค่าที่คอมไพล์ได้อย่างสะดวกสบาย จำเป็นต้องมีกระบวนการอัตโนมัติเต็มรูปแบบของการสร้างและการปรับใช้แอปพลิเคชัน (CI/CD) มิฉะนั้นจะค่อนข้างไม่สะดวก
ให้เราอาศัยข้อ จำกัด หลายประการของตัวอย่างที่พิจารณาซึ่งไม่เกี่ยวข้องกับแนวคิดของการกำหนดค่าที่คอมไพล์:
- หากเราให้ข้อมูลการกำหนดค่าที่ไม่จำเป็นซึ่งโหนดไม่ได้ใช้ คอมไพเลอร์จะไม่ช่วยให้เราตรวจพบการใช้งานที่ขาดหายไป ปัญหานี้สามารถแก้ไขได้ด้วยการละทิ้งรูปแบบเค้กและใช้ประเภทที่เข้มงวดมากขึ้น เช่น
HList
หรือประเภทข้อมูลพีชคณิต (คลาสเคส) เพื่อแสดงการกำหนดค่า - มีบรรทัดในไฟล์การกำหนดค่าที่ไม่เกี่ยวข้องกับการกำหนดค่าเอง: (
package
,import
,การประกาศวัตถุ;override def
สำหรับพารามิเตอร์ที่มีค่าเริ่มต้น) สิ่งนี้สามารถหลีกเลี่ยงได้บางส่วนหากคุณใช้ DSL ของคุณเอง นอกจากนี้ การกำหนดค่าประเภทอื่นๆ (เช่น XML) ยังกำหนดข้อจำกัดบางประการเกี่ยวกับโครงสร้างไฟล์ด้วย - สำหรับวัตถุประสงค์ของโพสต์นี้ เราไม่ได้พิจารณาการกำหนดค่าใหม่แบบไดนามิกของคลัสเตอร์ของโหนดที่คล้ายกัน
ข้อสรุป
ในโพสต์นี้ เราได้สำรวจแนวคิดในการนำเสนอการกำหนดค่าในซอร์สโค้ดโดยใช้ความสามารถขั้นสูงของระบบประเภท Scala วิธีการนี้สามารถใช้ในแอปพลิเคชันต่างๆ เพื่อแทนที่วิธีการกำหนดค่าแบบเดิมโดยใช้ไฟล์ xml หรือข้อความ แม้ว่าตัวอย่างของเราจะถูกนำไปใช้ใน Scala แต่แนวคิดเดียวกันนี้สามารถถ่ายโอนไปยังภาษาที่คอมไพล์อื่น ๆ ได้ (เช่น Kotlin, C#, Swift, ... ) คุณสามารถลองใช้วิธีนี้ในโปรเจ็กต์ใดโปรเจ็กต์ต่อไปนี้ และหากไม่ได้ผล ให้ไปยังไฟล์ข้อความโดยเพิ่มส่วนที่ขาดหายไป
โดยปกติแล้ว การกำหนดค่าที่คอมไพล์แล้วต้องใช้กระบวนการพัฒนาคุณภาพสูง ในทางกลับกัน รับประกันคุณภาพและความน่าเชื่อถือของการกำหนดค่าสูง
แนวทางที่พิจารณาสามารถขยายได้:
- คุณสามารถใช้แมโครเพื่อทำการตรวจสอบเวลาคอมไพล์
- คุณสามารถใช้ DSL เพื่อนำเสนอการกำหนดค่าในลักษณะที่ผู้ใช้ปลายทางสามารถเข้าถึงได้
- คุณสามารถใช้การจัดการทรัพยากรแบบไดนามิกด้วยการปรับการกำหนดค่าอัตโนมัติ ตัวอย่างเช่น การเปลี่ยนจำนวนโหนดในคลัสเตอร์ต้องการให้ (1) แต่ละโหนดได้รับการกำหนดค่าที่แตกต่างกันเล็กน้อย; (2) ตัวจัดการคลัสเตอร์ได้รับข้อมูลเกี่ยวกับโหนดใหม่
บลาโกดาเรนนอสตี
ฉันขอขอบคุณ Andrei Saksonov, Pavel Popov และ Anton Nekhaev สำหรับการวิจารณ์อย่างสร้างสรรค์ต่อร่างบทความ
ที่มา: will.com