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

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

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

(нарусском)

บทนำ

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

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

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

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

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

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

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

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

ระบบแบบกระจายทั่วไปประกอบด้วยโหนดไม่กี่โหนด โหนดสามารถระบุได้โดยใช้บางประเภท:

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

หนึ่งในโปรโตคอลที่ใช้กันอย่างแพร่หลายที่สุดคือ 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)
  }

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

เราสามารถประกาศการพึ่งพาในการกำหนดค่าของไคลเอนต์บริการ echo:

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

การพึ่งพามีประเภทเดียวกันกับ echoService. โดยเฉพาะอย่างยิ่งมันต้องการโปรโตคอลเดียวกัน ดังนั้นเราจึงมั่นใจได้ว่าหากเราเชื่อมต่อการขึ้นต่อกันทั้งสองนี้เข้าด้วยกัน มันจะทำงานได้อย่างถูกต้อง

การดำเนินการบริการ

บริการจำเป็นต้องมีฟังก์ชันเพื่อเริ่มต้นและปิดระบบอย่างสง่างาม (ความสามารถในการปิดบริการเป็นสิ่งสำคัญสำหรับการทดสอบ) อีกครั้ง มีตัวเลือกบางประการในการระบุฟังก์ชันดังกล่าวสำหรับการกำหนดค่าที่กำหนด (เช่น เราสามารถใช้คลาสประเภทได้) สำหรับโพสต์นี้ เราจะใช้ Cake Pattern อีกครั้ง เราสามารถเป็นตัวแทนใช้บริการได้ 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 — wrapper ของฟังก์ชั่นที่มีผล (เกือบเป็น monad) (ในที่สุดเราอาจแทนที่มันด้วยอย่างอื่น)

การใช้อินเทอร์เฟซนี้ทำให้เราสามารถใช้บริการบางอย่างได้ ตัวอย่างเช่น บริการที่ไม่ทำอะไรเลย:

  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
}

โปรดทราบว่าในโหนดเราระบุประเภทการกำหนดค่าที่แน่นอนที่โหนดนี้ต้องการ คอมไพเลอร์จะไม่ยอมให้เราสร้างอ็อบเจ็กต์ (เค้ก) ด้วยประเภทที่ไม่เพียงพอ เนื่องจากลักษณะการบริการแต่ละอย่างจะประกาศข้อจำกัดใน 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"
  }

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

การใช้งานสองโหนด

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

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

กระบวนการพัฒนาโดยรวม

มาดูกันว่าแนวทางนี้เปลี่ยนวิธีที่เราทำงานกับการกำหนดค่าอย่างไร

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

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

ตั๋ว -> ประชาสัมพันธ์ -> การตรวจสอบ -> ผสาน -> บูรณาการอย่างต่อเนื่อง -> การใช้งานอย่างต่อเนื่อง

มีผลที่ตามมาของแนวทางดังต่อไปนี้:

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

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

รูปแบบต่างๆ

เรามาดูข้อดีข้อเสียของแนวทางที่นำเสนอเมื่อเปรียบเทียบกับเทคนิคการจัดการการกำหนดค่าอื่นๆ กัน

ก่อนอื่น เราจะแสดงรายการทางเลือกสองสามรายการสำหรับแง่มุมต่างๆ ของวิธีการจัดการกับการกำหนดค่าที่เสนอ:

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

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

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

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

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

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

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

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

ข้อเสียข้อดี

ในที่นี้เราอยากจะเน้นถึงข้อดีบางประการและอภิปรายข้อเสียบางประการของแนวทางที่นำเสนอ

ข้อดี

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

  1. การตรวจสอบการกำหนดค่าแบบคงที่ ซึ่งให้ความมั่นใจในระดับสูงว่าการกำหนดค่านั้นถูกต้องตามข้อจำกัดประเภท
  2. ภาษาการกำหนดค่าที่หลากหลาย โดยทั่วไปวิธีการกำหนดค่าอื่นๆ จะจำกัดอยู่ที่การทดแทนตัวแปรส่วนใหญ่
    การใช้ Scala สามารถใช้ฟีเจอร์ภาษาที่หลากหลายเพื่อทำให้การกำหนดค่าดีขึ้น ตัวอย่างเช่น เราสามารถใช้คุณลักษณะเพื่อให้เป็นค่าเริ่มต้น วัตถุเพื่อกำหนดขอบเขตที่แตกต่างกัน เราสามารถอ้างอิงถึงได้ valกำหนดเพียงครั้งเดียวในขอบเขตภายนอก (DRY) คุณสามารถใช้ลำดับตามตัวอักษรหรืออินสแตนซ์ของคลาสบางคลาสได้ (Seq, Mapฯลฯ )
  3. ดีเอสแอล. Scala มีการสนับสนุนที่ดีสำหรับผู้เขียน DSL เราสามารถใช้คุณสมบัติเหล่านี้เพื่อสร้างภาษาการกำหนดค่าที่สะดวกยิ่งขึ้นและเป็นมิตรกับผู้ใช้ เพื่อให้การกำหนดค่าขั้นสุดท้ายอย่างน้อยที่สุดสามารถอ่านได้โดยผู้ใช้โดเมน
  4. ความสมบูรณ์และการเชื่อมโยงกันระหว่างโหนด ข้อดีอย่างหนึ่งของการมีการกำหนดค่าสำหรับระบบแบบกระจายทั้งหมดไว้ในที่เดียวคือค่าทั้งหมดจะถูกกำหนดค่าอย่างเคร่งครัดเพียงครั้งเดียว จากนั้นจึงนำกลับมาใช้ใหม่ในทุกที่ที่เราต้องการ นอกจากนี้ ให้พิมพ์การประกาศพอร์ตที่ปลอดภัยเพื่อให้แน่ใจว่าในการกำหนดค่าที่ถูกต้องทั้งหมด โหนดของระบบจะพูดภาษาเดียวกัน มีการขึ้นต่อกันอย่างชัดเจนระหว่างโหนดซึ่งทำให้ยากต่อการลืมให้บริการบางอย่าง
  5. การเปลี่ยนแปลงคุณภาพสูง แนวทางโดยรวมในการส่งผ่านการเปลี่ยนแปลงการกำหนดค่าผ่านกระบวนการ PR ปกติจะกำหนดมาตรฐานคุณภาพระดับสูงในการกำหนดค่าด้วย
  6. การเปลี่ยนแปลงการกำหนดค่าพร้อมกัน เมื่อใดก็ตามที่เราทำการเปลี่ยนแปลงใดๆ ในการกำหนดค่าการปรับใช้อัตโนมัติ จะทำให้แน่ใจได้ว่าโหนดทั้งหมดได้รับการอัปเดต
  7. ลดความซับซ้อนของแอปพลิเคชัน แอปพลิเคชันไม่จำเป็นต้องแยกวิเคราะห์และตรวจสอบการกำหนดค่าและจัดการค่าการกำหนดค่าที่ไม่ถูกต้อง สิ่งนี้ทำให้แอปพลิเคชันโดยรวมง่ายขึ้น (ความซับซ้อนที่เพิ่มขึ้นบางอย่างอยู่ในการกำหนดค่าเอง แต่เป็นการแลกเปลี่ยนกับความปลอดภัยอย่างมีสติ) การกลับไปสู่การกำหนดค่าปกตินั้นค่อนข้างตรงไปตรงมา - เพียงเพิ่มส่วนที่ขาดหายไป การเริ่มต้นใช้งานการกำหนดค่าที่คอมไพล์แล้วง่ายกว่าและเลื่อนการใช้งานส่วนเพิ่มเติมออกไปในภายหลัง
  8. การกำหนดค่าตามเวอร์ชัน เนื่องจากการเปลี่ยนแปลงการกำหนดค่าเป็นไปตามกระบวนการพัฒนาเดียวกัน ด้วยเหตุนี้เราจึงได้รับอาร์ติแฟกต์ที่มีเวอร์ชันเฉพาะ ช่วยให้เราสามารถเปลี่ยนการกำหนดค่ากลับได้หากจำเป็น เรายังปรับใช้การกำหนดค่าที่เคยใช้เมื่อปีที่แล้วได้ และจะทำงานในลักษณะเดียวกันทุกประการ การกำหนดค่าที่เสถียรช่วยเพิ่มความสามารถในการคาดการณ์และความน่าเชื่อถือของระบบแบบกระจาย การกำหนดค่าได้รับการแก้ไข ณ เวลาคอมไพล์ และไม่สามารถแก้ไขระบบที่ใช้งานจริงได้อย่างง่ายดาย
  9. ความเป็นโมดูลาร์ กรอบงานที่นำเสนอเป็นแบบโมดูลาร์และสามารถรวมโมดูลต่างๆ ได้หลายวิธี
    รองรับการกำหนดค่าที่แตกต่างกัน (การตั้งค่า/เค้าโครง) โดยเฉพาะอย่างยิ่ง เป็นไปได้ที่จะมีเค้าโครงโหนดเดียวขนาดเล็กและการตั้งค่าหลายโหนดขนาดใหญ่ การมีรูปแบบการผลิตหลายรูปแบบก็สมเหตุสมผล
  10. การทดสอบ เพื่อวัตถุประสงค์ในการทดสอบเราอาจใช้บริการจำลองและใช้เป็นการพึ่งพาในลักษณะที่ปลอดภัย เค้าโครงการทดสอบที่แตกต่างกันสองสามแบบที่มีชิ้นส่วนต่างๆ แทนที่ด้วยแบบจำลองสามารถรักษาไว้ได้พร้อมๆ กัน
  11. การทดสอบบูรณาการ บางครั้งในระบบแบบกระจาย การทดสอบการรวมระบบทำได้ยาก ด้วยวิธีการที่อธิบายไว้เพื่อพิมพ์การกำหนดค่าที่ปลอดภัยของระบบแบบกระจายที่สมบูรณ์ เราสามารถรันส่วนที่กระจายทั้งหมดบนเซิร์ฟเวอร์เดียวในลักษณะที่ควบคุมได้ มันง่ายที่จะเลียนแบบสถานการณ์
    เมื่อบริการใดบริการหนึ่งไม่สามารถใช้งานได้

ข้อเสีย

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

  1. การกำหนดค่าแบบคงที่ อาจไม่เหมาะกับทุกการใช้งาน ในบางกรณี จำเป็นต้องแก้ไขการกำหนดค่าในการผลิตอย่างรวดเร็วโดยข้ามมาตรการด้านความปลอดภัยทั้งหมด วิธีนี้ทำให้ยากขึ้น จำเป็นต้องมีการคอมไพล์และการปรับใช้ใหม่หลังจากทำการเปลี่ยนแปลงการกำหนดค่า นี่เป็นทั้งคุณสมบัติและภาระ
  2. การสร้างการกำหนดค่า เมื่อการกำหนดค่าถูกสร้างขึ้นโดยเครื่องมืออัตโนมัติบางอย่าง วิธีการนี้จำเป็นต้องมีการคอมไพล์ในภายหลัง (ซึ่งอาจล้มเหลวในที่สุด) อาจต้องใช้ความพยายามเพิ่มเติมเพื่อรวมขั้นตอนเพิ่มเติมนี้เข้ากับระบบบิลด์
  3. เครื่องดนตรี มีเครื่องมือมากมายที่ใช้อยู่ในปัจจุบันซึ่งอาศัยการกำหนดค่าตามข้อความ บางส่วนของพวกเขา
    จะไม่สามารถใช้ได้เมื่อมีการคอมไพล์การกำหนดค่า
  4. จำเป็นต้องเปลี่ยนความคิด นักพัฒนาและ DevOps คุ้นเคยกับไฟล์การกำหนดค่าข้อความ แนวคิดในการคอมไพล์การกำหนดค่าอาจดูแปลกสำหรับพวกเขา
  5. ก่อนที่จะแนะนำการกำหนดค่าที่คอมไพล์ได้จำเป็นต้องมีกระบวนการพัฒนาซอฟต์แวร์คุณภาพสูง

ตัวอย่างที่นำไปใช้มีข้อจำกัดบางประการ:

  1. หากเราจัดเตรียมการกำหนดค่าเพิ่มเติมที่ไม่ต้องการโดยการใช้งานโหนด คอมไพเลอร์จะไม่ช่วยให้เราตรวจพบการใช้งานที่ขาดไป สิ่งนี้สามารถแก้ไขได้โดยใช้ HList หรือ ADT (คลาสเคส) สำหรับการกำหนดค่าโหนดแทนลักษณะและรูปแบบเค้ก
  2. เราต้องจัดเตรียมสำเร็จรูปบางส่วนในไฟล์ปรับแต่ง: (package, import, object ประกาศ;
    override defสำหรับพารามิเตอร์ที่มีค่าเริ่มต้น) ปัญหานี้อาจได้รับการแก้ไขบางส่วนโดยใช้ DSL
  3. ในโพสต์นี้ เราไม่ครอบคลุมถึงการกำหนดค่าใหม่แบบไดนามิกของกลุ่มของโหนดที่คล้ายกัน

สรุป

ในโพสต์นี้ เราได้พูดคุยถึงแนวคิดในการนำเสนอการกำหนดค่าโดยตรงในซอร์สโค้ดในลักษณะที่ปลอดภัย วิธีการนี้สามารถนำไปใช้ในแอปพลิเคชันจำนวนมากเพื่อแทนที่การกำหนดค่า xml และข้อความอื่นๆ แม้ว่าตัวอย่างของเราจะถูกนำมาใช้ใน Scala แล้ว แต่ก็สามารถแปลเป็นภาษาอื่นที่คอมไพล์ได้ (เช่น Kotlin, C#, Swift เป็นต้น) เราสามารถลองใช้แนวทางนี้ในโปรเจ็กต์ใหม่ได้ และในกรณีที่ไม่ลงตัว ให้เปลี่ยนไปใช้วิธีแบบเก่า

แน่นอนว่าการกำหนดค่าที่คอมไพล์ได้นั้นต้องใช้กระบวนการพัฒนาคุณภาพสูง ในทางกลับกัน สัญญาว่าจะมอบการกำหนดค่าที่แข็งแกร่งคุณภาพสูงเท่าเทียมกัน

แนวทางนี้สามารถขยายออกไปได้หลายวิธี:

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

ขอบคุณ

ฉันอยากจะกล่าวขอบคุณ Andrey Saksonov, Pavel Popov, Anton Nehaev ที่ให้ข้อเสนอแนะที่สร้างแรงบันดาลใจเกี่ยวกับร่างของโพสต์นี้ซึ่งช่วยให้ฉันทำให้ชัดเจนยิ่งขึ้น

ที่มา: will.com