Compilable configuration ng isang distributed system

Sa post na ito gusto naming ibahagi ang isang kawili-wiling paraan ng pagharap sa pagsasaayos ng isang distributed system.
Direktang kinakatawan ang configuration sa wikang Scala sa isang uri na ligtas na paraan. Ang isang halimbawa ng pagpapatupad ay inilarawan sa mga detalye. Ang iba't ibang aspeto ng panukala ay tinatalakay, kabilang ang impluwensya sa pangkalahatang proseso ng pag-unlad.

Compilable configuration ng isang distributed system

(sa Russian)

pagpapakilala

Ang pagbuo ng matatag na distributed system ay nangangailangan ng paggamit ng tama at magkakaugnay na configuration sa lahat ng node. Ang isang tipikal na solusyon ay ang paggamit ng isang textual na paglalarawan ng deployment (terraform, ansible o isang bagay na magkatulad) at awtomatikong bumubuo ng mga configuration file (madalas β€” nakatuon para sa bawat node/role). Gusto rin naming gamitin ang parehong mga protocol ng parehong mga bersyon sa bawat pakikipag-usap na mga node (kung hindi, makakaranas kami ng mga isyu sa hindi pagkakatugma). Sa mundo ng JVM, nangangahulugan ito na ang library ng pagmemensahe ay dapat na may parehong bersyon sa lahat ng mga node sa pakikipag-usap.

Paano ang tungkol sa pagsubok sa system? Siyempre, dapat tayong magkaroon ng mga unit test para sa lahat ng mga bahagi bago dumating sa mga pagsubok sa pagsasama. Upang ma-extrapolate ang mga resulta ng pagsubok sa runtime, dapat nating tiyakin na ang mga bersyon ng lahat ng mga library ay pinananatiling magkapareho sa parehong runtime at testing environment.

Kapag nagpapatakbo ng mga pagsubok sa pagsasama, kadalasan ay mas madaling magkaroon ng parehong classpath sa lahat ng node. Kailangan lang nating tiyakin na ang parehong classpath ang ginagamit sa deployment. (Posibleng gumamit ng iba't ibang classpath sa iba't ibang node, ngunit mas mahirap i-represent ang configuration na ito at i-deploy ito nang tama.) Kaya para mapanatiling simple ang mga bagay ay isasaalang-alang lamang namin ang magkatulad na classpath sa lahat ng node.

Ang pagsasaayos ay may posibilidad na umunlad kasama ng software. Karaniwan kaming gumagamit ng mga bersyon upang makilala ang iba't-ibang
mga yugto ng ebolusyon ng software. Mukhang makatwirang saklawin ang configuration sa ilalim ng pamamahala ng bersyon at tukuyin ang iba't ibang mga configuration na may ilang mga label. Kung mayroon lamang isang configuration sa produksyon, maaari naming gamitin ang isang bersyon bilang isang identifier. Minsan maaari tayong magkaroon ng maraming kapaligiran sa produksyon. At para sa bawat kapaligiran ay maaaring kailanganin namin ang isang hiwalay na sangay ng pagsasaayos. Kaya ang mga configuration ay maaaring may label na sangay at bersyon upang natatanging makilala ang iba't ibang mga configuration. Ang bawat branch label at bersyon ay tumutugma sa iisang kumbinasyon ng mga distributed node, port, external resources, classpath na bersyon ng library sa bawat node. Dito, sasaklawin lang natin ang iisang sangay at tutukuyin ang mga configuration sa pamamagitan ng tatlong bahaging decimal na bersyon (1.2.3), sa parehong paraan tulad ng iba pang artifact.

Sa modernong kapaligiran, ang mga file ng pagsasaayos ay hindi na binago nang manu-mano. Karaniwan kaming bumubuo
config file sa oras ng pag-deploy at huwag na huwag silang hawakan pagkatapos. Kaya maaaring magtanong kung bakit gumagamit pa rin kami ng format ng teksto para sa mga file ng pagsasaayos? Ang isang mabubuhay na opsyon ay ilagay ang configuration sa loob ng compilation unit at makinabang mula sa compile-time na configuration validation.

Sa post na ito susuriin natin ang ideya ng pagpapanatili ng pagsasaayos sa pinagsama-samang artifact.

Compilable configuration

Sa seksyong ito tatalakayin natin ang isang halimbawa ng static na pagsasaayos. Dalawang simpleng serbisyo - serbisyo ng echo at ang kliyente ng serbisyo ng echo ay kino-configure at ipinapatupad. Pagkatapos, ang dalawang magkaibang mga distributed system na may parehong mga serbisyo ay na-instantiate. Ang isa ay para sa iisang node configuration at isa pa para sa dalawang node configuration.

Ang isang tipikal na ipinamamahaging sistema ay binubuo ng ilang mga node. Ang mga node ay maaaring makilala gamit ang ilang uri:

sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeId

o lamang

case class NodeId(hostName: String)

o kahit na

object Singleton
type NodeId = Singleton.type

Ang mga node na ito ay gumaganap ng iba't ibang tungkulin, nagpapatakbo ng ilang mga serbisyo at dapat na makipag-ugnayan sa iba pang mga node sa pamamagitan ng mga koneksyon sa TCP/HTTP.

Para sa koneksyon ng TCP, kailangan ng hindi bababa sa isang numero ng port. Nais din naming tiyakin na ang kliyente at server ay nagsasalita ng parehong protocol. Upang magmodelo ng koneksyon sa pagitan ng mga node, ideklara natin ang sumusunod na klase:

case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])

saan Port ay isang Int sa loob ng pinapayagang hanay:

type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]

Mga pinong uri

Tingnan pino aklatan. Sa madaling salita, pinapayagan nitong magdagdag ng mga limitasyon sa oras ng pag-compile sa iba pang mga uri. Sa kasong ito Int pinapayagan lamang na magkaroon ng 16-bit na mga halaga na maaaring kumatawan sa numero ng port. Walang kinakailangang gamitin ang library na ito para sa diskarte sa pagsasaayos na ito. Parang kasya lang.

Para sa HTTP (REST) ​​​​maaaring kailangan din namin ng landas ng serbisyo:

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

Uri ng multo

Upang matukoy ang protocol sa panahon ng compilation, ginagamit namin ang tampok na Scala ng pagdedeklara ng uri ng argumento Protocol na hindi ginagamit sa klase. Ito ay isang tinatawag na uri ng multo. Sa runtime bihira kaming nangangailangan ng isang halimbawa ng protocol identifier, kaya hindi namin ito iniimbak. Sa panahon ng compilation ang phantom type na ito ay nagbibigay ng karagdagang kaligtasan ng uri. Hindi kami makapasa sa port na may maling protocol.

Isa sa pinakamalawak na ginagamit na protocol ay ang REST API na may Json serialization:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

saan RequestMessage ay ang batayang uri ng mga mensahe na maaaring ipadala ng kliyente sa server at ResponseMessage ay ang mensahe ng tugon mula sa server. Siyempre, maaari kaming lumikha ng iba pang mga paglalarawan ng protocol na tumutukoy sa protocol ng komunikasyon na may nais na katumpakan.

Para sa mga layunin ng post na ito, gagamit kami ng mas simpleng bersyon ng protocol:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Sa protocol na ito, ang mensahe ng kahilingan ay idinagdag sa url at ibinalik ang mensahe ng tugon bilang plain string.

Ang isang configuration ng serbisyo ay maaaring ilarawan sa pamamagitan ng pangalan ng serbisyo, isang koleksyon ng mga port at ilang dependencies. Mayroong ilang mga posibleng paraan kung paano kinakatawan ang lahat ng mga elementong ito sa Scala (halimbawa, HList, algebraic na mga uri ng data). Para sa mga layunin ng post na ito gagamitin namin ang Pattern ng Cake at kinakatawan ang mga pinagsama-samang piraso (mga module) bilang mga katangian. (Ang Cake Pattern ay hindi kinakailangan para sa compilable na diskarte sa pagsasaayos na ito. Isa lamang itong posibleng pagpapatupad ng ideya.)

Ang mga dependency ay maaaring katawanin gamit ang Cake Pattern bilang mga endpoint ng iba pang mga node:

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

Ang serbisyo ng echo ay nangangailangan lamang ng isang port na na-configure. At ipinapahayag namin na ang port na ito ay sumusuporta sa echo protocol. Tandaan na hindi namin kailangang tukuyin ang isang partikular na port sa sandaling ito, dahil pinapayagan ng trait ang mga abstract na pamamaraan ng mga deklarasyon. Kung gumagamit kami ng mga abstract na pamamaraan, ang compiler ay mangangailangan ng pagpapatupad sa isang halimbawa ng pagsasaayos. Dito ibinigay namin ang pagpapatupad (8081) at ito ay gagamitin bilang default na halaga kung laktawan natin ito sa isang kongkretong pagsasaayos.

Maaari kaming magdeklara ng dependency sa configuration ng echo service client:

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

Ang dependency ay may parehong uri ng echoService. Sa partikular, hinihingi nito ang parehong protocol. Kaya naman, makatitiyak tayo na kung ikinonekta natin ang dalawang dependency na ito ay gagana sila nang tama.

Pagpapatupad ng mga serbisyo

Ang isang serbisyo ay nangangailangan ng isang function upang magsimula at maganda ang pagsasara. (Ang kakayahang i-shutdown ang isang serbisyo ay kritikal para sa pagsubok.) Muli, mayroong ilang mga opsyon sa pagtukoy ng ganoong function para sa isang partikular na config (halimbawa, maaari tayong gumamit ng mga uri ng klase). Para sa post na ito gagamitin namin muli ang Cake Pattern. Maaari kaming kumatawan sa isang serbisyo gamit ang cats.Resource na nagbibigay na ng bracketing at paglabas ng mapagkukunan. Upang makakuha ng resource dapat kaming magbigay ng configuration at ilang runtime context. Kaya ang pagsisimula ng serbisyo ay maaaring magmukhang:

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

saan

  • Config β€” uri ng configuration na kinakailangan ng service starter na ito
  • AddressResolver β€” isang runtime object na may kakayahang makakuha ng mga tunay na address ng iba pang mga node (patuloy na magbasa para sa mga detalye).

ang iba pang mga uri ay nanggaling cats:

  • F[_] β€” uri ng epekto (Sa pinakasimpleng kaso F[A] maaaring makatarungan () => A. Sa post na ito ay gagamitin natin cats.IO.)
  • Reader[A,B] β€” ay higit pa o mas kaunting kasingkahulugan para sa isang function A => B
  • cats.Resource β€” may mga paraan para makuha at palabasin
  • Timer β€” nagbibigay-daan sa pagtulog/pagsukat ng oras
  • ContextShift - analog ng ExecutionContext
  • Applicative β€” wrapper ng mga function na may bisa (halos monad) (maaaring palitan natin ito ng ibang bagay)

Gamit ang interface na ito maaari naming ipatupad ang ilang mga serbisyo. Halimbawa, isang serbisyo na walang ginagawa:

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

(Tingnan ang Source code para sa iba pang mga pagpapatupad ng mga serbisyo - echo serbisyo,
echo client at panghabambuhay na controllers.)

Ang isang node ay isang solong bagay na nagpapatakbo ng ilang mga serbisyo (pagsisimula ng isang hanay ng mga mapagkukunan ay pinagana ng Cake Pattern):

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

Tandaan na sa node ay tinukoy namin ang eksaktong uri ng configuration na kailangan ng node na ito. Hindi kami papayagan ng Compiler na bumuo ng object (Cake) na may hindi sapat na uri, dahil ang bawat katangian ng serbisyo ay nagdedeklara ng isang hadlang sa Config uri. Gayundin, hindi namin magagawang simulan ang node nang hindi nagbibigay ng kumpletong configuration.

Resolusyon ng node address

Upang makapagtatag ng isang koneksyon kailangan namin ng isang tunay na address ng host para sa bawat node. Maaari itong malaman sa ibang pagkakataon kaysa sa iba pang bahagi ng configuration. Samakatuwid, kailangan namin ng isang paraan upang magbigay ng pagmamapa sa pagitan ng node id at ito ay aktwal na address. Ang pagmamapa na ito ay isang function:

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

Mayroong ilang mga posibleng paraan upang ipatupad ang naturang function.

  1. Kung alam namin ang mga aktwal na address bago ang pag-deploy, sa panahon ng instantiation ng mga host ng node, maaari kaming bumuo ng Scala code kasama ang mga aktwal na address at patakbuhin ang build pagkatapos (na nagsasagawa ng mga pagsusuri sa oras ng pag-compile at pagkatapos ay nagpapatakbo ng integration test suite). Sa kasong ito, ang aming pag-andar ng pagmamapa ay kilala nang statically at maaaring gawing simple sa isang bagay tulad ng a Map[NodeId, NodeAddress].
  2. Minsan nakakakuha lang kami ng mga aktwal na address sa ibang pagkakataon kapag aktwal na nagsimula ang node, o wala kaming mga address ng mga node na hindi pa nasisimulan. Sa kasong ito, maaari tayong magkaroon ng serbisyo sa pagtuklas na sinimulan bago ang lahat ng iba pang mga node at maaaring i-advertise ng bawat node ang address nito sa serbisyong iyon at mag-subscribe sa mga dependency.
  3. Kung kaya nating baguhin /etc/hosts, maaari kaming gumamit ng mga paunang natukoy na pangalan ng host (tulad ng my-project-main-node at echo-backend) at iugnay lamang ang pangalang ito sa ip address sa oras ng pag-deploy.

Sa post na ito hindi namin sinasaklaw ang mga kasong ito sa higit pang mga detalye. Sa katunayan sa aming halimbawa ng laruan ang lahat ng mga node ay magkakaroon ng parehong IP address - 127.0.0.1.

Sa post na ito isasaalang-alang namin ang dalawang distributed system layout:

  1. Layout ng single node, kung saan inilalagay ang lahat ng serbisyo sa iisang node.
  2. Dalawang node layout, kung saan ang serbisyo at kliyente ay nasa magkaibang node.

Ang pagsasaayos para sa a iisang node ang layout ay ang mga sumusunod:

Pag-configure ng solong node

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

Dito kami lumikha ng isang configuration na nagpapalawak ng parehong server at client configuration. Nag-configure din kami ng isang lifecycle controller na karaniwang magwawakas sa kliyente at server pagkatapos lifetime lumipas ang pagitan.

Maaaring gamitin ang parehong hanay ng mga pagpapatupad at pagsasaayos ng serbisyo upang lumikha ng layout ng system na may dalawang magkahiwalay na node. Kailangan lang nating lumikha dalawang magkahiwalay na node config na may naaangkop na mga serbisyo:

Dalawang node configuration

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

Tingnan kung paano namin tinukoy ang dependency. Binanggit namin ang ibinigay na serbisyo ng ibang node bilang dependency ng kasalukuyang node. Sinusuri ang uri ng dependency dahil naglalaman ito ng phantom type na naglalarawan ng protocol. At sa runtime magkakaroon tayo ng tamang node id. Isa ito sa mahahalagang aspeto ng iminungkahing diskarte sa pagsasaayos. Nagbibigay ito sa amin ng kakayahang magtakda ng port nang isang beses lang at tiyaking tinutukoy namin ang tamang port.

Dalawang node na pagpapatupad

Para sa configuration na ito ginagamit namin ang eksaktong parehong mga pagpapatupad ng mga serbisyo. Walang pagbabago sa lahat. Gayunpaman, gumagawa kami ng dalawang magkaibang pagpapatupad ng node na naglalaman ng magkakaibang hanay ng mga serbisyo:

  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
  }

Ang unang node ay nagpapatupad ng server at kailangan lang nito ng server side config. Ang pangalawang node ay nagpapatupad ng kliyente at nangangailangan ng isa pang bahagi ng config. Ang parehong mga node ay nangangailangan ng ilang panghabambuhay na detalye. Para sa mga layunin ng post service node na ito ay magkakaroon ng walang katapusang panghabambuhay na maaaring wakasan gamit SIGTERM, habang ang echo client ay magwawakas pagkatapos ng na-configure na may hangganan na tagal. Tingnan ang panimulang aplikasyon para sa mga detalye.

Pangkalahatang proseso ng pag-unlad

Tingnan natin kung paano binabago ng diskarteng ito ang paraan ng pagtatrabaho namin sa configuration.

Ang pagsasaayos bilang code ay isasama at gagawa ng isang artifact. Mukhang makatwirang paghiwalayin ang artifact ng pagsasaayos mula sa iba pang mga artifact ng code. Kadalasan maaari tayong magkaroon ng maraming configuration sa parehong code base. At siyempre, maaari tayong magkaroon ng maraming bersyon ng iba't ibang sangay ng pagsasaayos. Sa isang configuration maaari kaming pumili ng mga partikular na bersyon ng mga library at ito ay mananatiling pare-pareho sa tuwing i-deploy namin ang configuration na ito.

Ang pagbabago ng configuration ay nagiging pagbabago ng code. Kaya dapat itong saklawin ng parehong proseso ng pagtiyak ng kalidad:

Ticket -> PR -> review -> merge -> tuluy-tuloy na pagsasama -> tuluy-tuloy na pag-deploy

Mayroong mga sumusunod na kahihinatnan ng diskarte:

  1. Ang pagsasaayos ay magkakaugnay para sa isang partikular na halimbawa ng system. Mukhang walang paraan upang magkaroon ng maling koneksyon sa pagitan ng mga node.
  2. Hindi madaling baguhin ang configuration sa isang node lamang. Mukhang hindi makatwiran na mag-log in at baguhin ang ilang mga text file. Kaya nagiging mas hindi posible ang configuration drift.
  3. Ang maliliit na pagbabago sa configuration ay hindi madaling gawin.
  4. Karamihan sa mga pagbabago sa configuration ay susunod sa parehong proseso ng pag-unlad, at ito ay magpapasa ng ilang pagsusuri.

Kailangan ba natin ng hiwalay na imbakan para sa pagsasaayos ng produksyon? Maaaring naglalaman ang configuration ng produksyon ng sensitibong impormasyon na gusto naming iwasang maabot ng maraming tao. Kaya't maaaring sulit na panatilihin ang isang hiwalay na repositoryo na may pinaghihigpitang pag-access na maglalaman ng configuration ng produksyon. Maaari naming hatiin ang configuration sa dalawang bahagi - isa na naglalaman ng pinakabukas na mga parameter ng produksyon at isa na naglalaman ng sikretong bahagi ng configuration. Ito ay magbibigay-daan sa pag-access sa karamihan ng mga developer sa karamihan ng mga parameter habang nililimitahan ang pag-access sa mga talagang sensitibong bagay. Madaling gawin ito gamit ang mga intermediate na katangian na may mga default na halaga ng parameter.

Pagkakaiba-iba

Tingnan natin ang mga kalamangan at kahinaan ng iminungkahing diskarte kumpara sa iba pang mga diskarte sa pamamahala ng configuration.

Una sa lahat, maglilista kami ng ilang alternatibo sa iba't ibang aspeto ng iminungkahing paraan ng pagharap sa pagsasaayos:

  1. Text file sa target na makina.
  2. Sentralisadong imbakan ng key-value (tulad ng etcd/zookeeper).
  3. Mga subprocess na bahagi na maaaring i-reconfigure/i-restart nang hindi na-restart ang proseso.
  4. Configuration sa labas ng artifact at version control.

Ang text file ay nagbibigay ng ilang flexibility sa mga tuntunin ng ad-hoc fixes. Ang administrator ng system ay maaaring mag-login sa target na node, gumawa ng pagbabago at simpleng i-restart ang serbisyo. Maaaring hindi ito maganda para sa mas malalaking system. Walang bakas na naiwan sa pagbabago. Ang pagbabago ay hindi sinusuri ng isa pang pares ng mga mata. Maaaring mahirap malaman kung ano ang naging sanhi ng pagbabago. Hindi pa ito nasubok. Mula sa pananaw ng distributed system, makalimutan lamang ng isang administrator na i-update ang configuration sa isa sa iba pang mga node.

(Btw, kung sa kalaunan ay magkakaroon ng pangangailangan upang simulan ang paggamit ng mga text config file, kailangan lang nating magdagdag ng parser + validator na maaaring makagawa ng pareho Config type at sapat na iyon para simulan ang paggamit ng mga text config. Ipinapakita rin nito na ang pagiging kumplikado ng pagsasaayos ng oras ng pag-compile ay medyo mas maliit kaysa sa pagiging kumplikado ng mga config na nakabatay sa teksto, dahil sa bersyon na nakabatay sa teksto kailangan namin ng ilang karagdagang code.)

Ang sentralisadong imbakan ng key-value ay isang mahusay na mekanismo para sa pamamahagi ng mga parameter ng meta ng application. Dito kailangan nating isipin kung ano ang itinuturing nating mga halaga ng pagsasaayos at kung ano ang data lamang. Nabigyan ng function C => A => B karaniwan naming tinatawag na bihirang pagbabago ng mga halaga C "configuration", habang madalas na binago ang data A - input lang ng data. Dapat ibigay ang configuration sa function nang mas maaga kaysa sa data A. Dahil sa ideyang ito, masasabi nating inaasahang dalas ng mga pagbabago kung ano ang maaaring gamitin upang makilala ang data ng configuration mula sa data lamang. Karaniwan ding nagmumula ang data sa isang source (user) at ang configuration ay mula sa ibang source (admin). Ang pagharap sa mga parameter na maaaring baguhin pagkatapos ng proseso ng pagsisimula ay humahantong sa pagtaas ng pagiging kumplikado ng application. Para sa mga naturang parameter kailangan nating pangasiwaan ang kanilang mekanismo ng paghahatid, pag-parse at pagpapatunay, paghawak ng mga maling halaga. Samakatuwid, upang mabawasan ang pagiging kumplikado ng programa, mas mabuting bawasan namin ang bilang ng mga parameter na maaaring magbago sa runtime (o kahit na alisin ang mga ito nang buo).

Mula sa pananaw ng post na ito dapat tayong gumawa ng pagkakaiba sa pagitan ng static at dynamic na mga parameter. Kung ang lohika ng serbisyo ay nangangailangan ng bihirang pagbabago ng ilang mga parameter sa runtime, maaari naming tawagan ang mga ito na mga dynamic na parameter. Kung hindi, ang mga ito ay static at maaaring i-configure gamit ang iminungkahing diskarte. Para sa dynamic na muling pagsasaayos, maaaring kailanganin ang iba pang mga diskarte. Halimbawa, maaaring i-restart ang mga bahagi ng system gamit ang mga bagong parameter ng configuration sa katulad na paraan sa pag-restart ng magkakahiwalay na proseso ng isang distributed system.
(Ang aking mapagpakumbabang opinyon ay upang maiwasan ang muling pagsasaayos ng runtime dahil pinatataas nito ang pagiging kumplikado ng system.
Maaaring mas diretso na umasa lang sa suporta ng OS sa mga proseso ng pag-restart. Gayunpaman, maaaring hindi ito palaging posible.)

Isang mahalagang aspeto ng paggamit ng static na configuration na kung minsan ay ginagawang isaalang-alang ng mga tao ang dynamic na configuration (nang walang iba pang dahilan) ay ang downtime ng serbisyo sa panahon ng pag-update ng configuration. Sa katunayan, kung kailangan nating gumawa ng mga pagbabago sa static na pagsasaayos, kailangan nating i-restart ang system upang ang mga bagong halaga ay maging epektibo. Ang mga kinakailangan para sa downtime ay nag-iiba para sa iba't ibang mga system, kaya maaaring hindi ito kritikal. Kung ito ay kritikal, kailangan nating magplano nang maaga para sa anumang pag-restart ng system. Halimbawa, maaari nating ipatupad Nauubos ang koneksyon ng AWS ELB. Sa sitwasyong ito sa tuwing kailangan naming i-restart ang system, magsisimula kami ng bagong instance ng system nang magkatulad, pagkatapos ay ilipat ang ELB dito, habang hinahayaan ang lumang system na kumpletuhin ang pagseserbisyo sa mga kasalukuyang koneksyon.

Paano ang tungkol sa pagpapanatiling configuration sa loob ng versioned artifact o sa labas? Ang pagpapanatiling configuration sa loob ng isang artifact ay nangangahulugan sa karamihan ng mga kaso na ang configuration na ito ay pumasa sa parehong proseso ng pagtiyak ng kalidad tulad ng iba pang mga artifact. Kaya't maaaring makasigurado na ang pagsasaayos ay may magandang kalidad at mapagkakatiwalaan. Sa kabilang banda, ang pagsasaayos sa isang hiwalay na file ay nangangahulugan na walang mga bakas kung sino at bakit gumawa ng mga pagbabago sa file na iyon. Mahalaga ba ito? Naniniwala kami na para sa karamihan ng mga sistema ng produksyon, mas mahusay na magkaroon ng matatag at mataas na kalidad na configuration.

Ang bersyon ng artifact ay nagbibigay-daan upang malaman kung kailan ito nilikha, kung anong mga halaga ang nilalaman nito, kung anong mga tampok ang pinagana/hindi pinagana, kung sino ang may pananagutan sa paggawa ng bawat pagbabago sa pagsasaayos. Maaaring mangailangan ito ng ilang pagsisikap upang mapanatili ang configuration sa loob ng isang artifact at isa itong pagpipiliang disenyo na gagawin.

Mga kalamangan at kahinaan

Dito nais naming i-highlight ang ilang mga pakinabang at pag-usapan ang ilang mga disadvantage ng iminungkahing diskarte.

Bentahe

Mga tampok ng compilable na configuration ng isang kumpletong distributed system:

  1. Static check ng configuration. Nagbibigay ito ng mataas na antas ng kumpiyansa, na ang pagsasaayos ay tama dahil sa mga uri ng hadlang.
  2. Mayaman na wika ng pagsasaayos. Karaniwan ang iba pang mga diskarte sa pagsasaayos ay limitado sa karamihan sa mga variable na pagpapalit.
    Ang paggamit ng Scala ay maaaring gumamit ng malawak na hanay ng mga feature ng wika upang gawing mas mahusay ang configuration. Halimbawa, maaari naming gamitin ang mga katangian upang magbigay ng mga default na halaga, mga bagay upang itakda ang iba't ibang saklaw, maaari naming sumangguni sa vals isang beses lang tinukoy sa panlabas na saklaw (DRY). Posibleng gumamit ng mga literal na pagkakasunud-sunod, o mga pagkakataon ng ilang partikular na klase (Seq, Map, Atbp).
  3. DSL. Ang Scala ay may disenteng suporta para sa mga manunulat ng DSL. Maaaring gamitin ng isa ang mga feature na ito upang magtatag ng configuration language na mas maginhawa at end-user friendly, upang ang panghuling configuration ay mababasa man lang ng mga user ng domain.
  4. Integridad at pagkakaugnay-ugnay sa mga node. Ang isa sa mga pakinabang ng pagkakaroon ng pagsasaayos para sa buong ipinamahagi na sistema sa isang lugar ay ang lahat ng mga halaga ay tinukoy nang mahigpit nang isang beses at pagkatapos ay muling ginagamit sa lahat ng mga lugar kung saan kailangan natin ang mga ito. I-type din ang mga deklarasyon ng ligtas na port na tiyakin na sa lahat ng posibleng tamang configuration ang mga node ng system ay magsasalita ng parehong wika. May mga tahasang dependency sa pagitan ng mga node na nagpapahirap na makalimutan ang pagbibigay ng ilang mga serbisyo.
  5. Mataas na kalidad ng mga pagbabago. Ang pangkalahatang paraan ng pagpasa ng mga pagbabago sa pagsasaayos sa pamamagitan ng normal na proseso ng PR ay nagtatatag ng mataas na pamantayan ng kalidad din sa pagsasaayos.
  6. Sabay-sabay na pagbabago sa configuration. Sa tuwing gumawa kami ng anumang mga pagbabago sa awtomatikong pag-deploy ng configuration, tinitiyak na ang lahat ng mga node ay ina-update.
  7. Pagpapasimple ng aplikasyon. Hindi kailangang i-parse at i-validate ng application ang configuration at pangasiwaan ang mga maling value ng configuration. Pinapasimple nito ang pangkalahatang aplikasyon. (Ang ilang pagtaas ng pagiging kumplikado ay nasa mismong configuration, ngunit ito ay isang mulat na trade-off patungo sa kaligtasan.) Medyo diretsong bumalik sa ordinaryong configuration β€” idagdag lang ang mga nawawalang piraso. Mas madaling magsimula sa pinagsama-samang pagsasaayos at ipagpaliban ang pagpapatupad ng mga karagdagang piraso sa ibang pagkakataon.
  8. Bersyon na configuration. Dahil sa katotohanan na ang mga pagbabago sa pagsasaayos ay sumusunod sa parehong proseso ng pag-unlad, bilang isang resulta nakakakuha kami ng isang artifact na may natatanging bersyon. Nagbibigay-daan ito sa amin na ibalik ang configuration kung kinakailangan. Maaari pa nga kaming mag-deploy ng configuration na ginamit noong isang taon at gagana ito sa parehong paraan. Pinapabuti ng matatag na configuration ang predictability at reliability ng distributed system. Ang configuration ay naayos sa oras ng pag-compile at hindi madaling pakialaman sa isang production system.
  9. Modularity. Ang iminungkahing balangkas ay modular at ang mga module ay maaaring pagsamahin sa iba't ibang paraan upang
    suportahan ang iba't ibang mga pagsasaayos (mga setup/layout). Sa partikular, posibleng magkaroon ng small scale single node layout at large scale multi node setting. Makatwirang magkaroon ng maraming layout ng produksyon.
  10. Pagsubok. Para sa mga layunin ng pagsubok, maaaring magpatupad ang isang mock service at gamitin ito bilang dependency sa isang uri ng ligtas na paraan. Ang ilang magkakaibang mga layout ng pagsubok na may iba't ibang bahagi na pinalitan ng mga pangungutya ay maaaring mapanatili nang sabay-sabay.
  11. Pagsubok sa pagsasama. Minsan sa mga distributed system mahirap magpatakbo ng mga integration test. Gamit ang inilarawang diskarte para mag-type ng ligtas na configuration ng kumpletong distributed system, maaari naming patakbuhin ang lahat ng mga distributed parts sa isang server sa isang nakokontrol na paraan. Madaling tularan ang sitwasyon
    kapag ang isa sa mga serbisyo ay naging hindi magagamit.

Mga Disbentaha

Ang pinagsama-samang diskarte sa pagsasaayos ay iba sa "normal" na pagsasaayos at maaaring hindi ito angkop sa lahat ng pangangailangan. Narito ang ilan sa mga disadvantage ng pinagsama-samang config:

  1. Static na pagsasaayos. Maaaring hindi ito angkop para sa lahat ng application. Sa ilang mga kaso mayroong pangangailangan ng mabilis na pag-aayos ng pagsasaayos sa produksyon na lampasan ang lahat ng mga hakbang sa kaligtasan. Ang diskarte na ito ay ginagawang mas mahirap. Ang compilation at redeployment ay kinakailangan pagkatapos gumawa ng anumang pagbabago sa configuration. Ito ang parehong tampok at ang pasanin.
  2. Pagbuo ng configuration. Kapag ang config ay nabuo sa pamamagitan ng ilang automation tool ang diskarte na ito ay nangangailangan ng kasunod na compilation (na maaaring mabigo naman). Maaaring mangailangan ng karagdagang pagsisikap upang maisama ang karagdagang hakbang na ito sa build system.
  3. Mga instrumento. Maraming mga tool na ginagamit ngayon na umaasa sa mga text-based na config. Iba sa kanila
    ay hindi mailalapat kapag na-compile ang configuration.
  4. Kailangan ng pagbabago sa mindset. Pamilyar ang mga developer at DevOps sa mga text configuration file. Ang ideya ng pag-compile ng pagsasaayos ay maaaring mukhang kakaiba sa kanila.
  5. Bago ipakilala ang compilable na configuration, kinakailangan ang isang mataas na kalidad na proseso ng pagbuo ng software.

Mayroong ilang mga limitasyon ng ipinatupad na halimbawa:

  1. Kung magbibigay kami ng karagdagang config na hindi hinihingi ng pagpapatupad ng node, hindi kami tutulungan ng compiler na matukoy ang absent na pagpapatupad. Ito ay maaaring matugunan sa pamamagitan ng paggamit HList o mga ADT (mga klase ng kaso) para sa configuration ng node sa halip na mga katangian at Pattern ng Cake.
  2. Kailangan naming magbigay ng ilang boilerplate sa config file: (package, import, object mga deklarasyon;
    override defpara sa mga parameter na may mga default na halaga). Maaaring bahagyang matugunan ito gamit ang isang DSL.
  3. Sa post na ito hindi namin sinasaklaw ang dynamic na muling pagsasaayos ng mga kumpol ng mga katulad na node.

Konklusyon

Sa post na ito napag-usapan namin ang ideya ng direktang kumakatawan sa pagsasaayos sa source code sa isang uri ng ligtas na paraan. Ang diskarte ay maaaring gamitin sa maraming mga application bilang isang kapalit sa xml- at iba pang text-based na mga config. Sa kabila na ang aming halimbawa ay ipinatupad sa Scala, maaari rin itong isalin sa iba pang mga compilable na wika (tulad ng Kotlin, C#, Swift, atbp.). Maaaring subukan ng isa ang diskarteng ito sa isang bagong proyekto at, kung sakaling hindi ito magkasya, lumipat sa lumang paraan.

Siyempre, ang compilable na pagsasaayos ay nangangailangan ng mataas na kalidad na proseso ng pag-unlad. Bilang kapalit, nangangako itong magbibigay ng parehong mataas na kalidad na matatag na pagsasaayos.

Ang pamamaraang ito ay maaaring mapalawak sa iba't ibang paraan:

  1. Maaaring gumamit ang isa ng mga macro upang magsagawa ng pagpapatunay ng pagsasaayos at mabigo sa oras ng pag-compile kung sakaling magkaroon ng anumang pagkabigo sa mga hadlang sa lohika ng negosyo.
  2. Maaaring ipatupad ang isang DSL upang kumatawan sa pagsasaayos sa isang domain-user-friendly na paraan.
  3. Dynamic na resource management na may mga awtomatikong pagsasaayos ng configuration. Halimbawa, kapag inayos namin ang bilang ng mga cluster node na maaaring gusto naming (1) ang mga node ay makakuha ng bahagyang binagong configuration; (2) cluster manager para makatanggap ng mga bagong node info.

salamat

Gusto kong magpasalamat kay Andrey Saksonov, Pavel Popov, Anton Nehaev sa pagbibigay ng inspirational na feedback sa draft ng post na ito na nakatulong sa akin na gawing mas malinaw.

Pinagmulan: www.habr.com