Meie ettevõte on spetsialiseerunud ERP-klassi tarkvaralahenduste arendamisele, milles lõviosa hõivavad tohutu äriloogika ja töövooga a la EDMS tehingusüsteemid. Meie toodete kaasaegsed versioonid põhinevad JavaEE tehnoloogiatel, kuid katsetame aktiivselt ka mikroteenustega. Selliste lahenduste üks probleemsemaid valdkondi on erinevate külgnevate domeenidega seotud alamsüsteemide integreerimine. Integratsiooniülesanded on meile alati tohutut peavalu valmistanud, olenemata kasutatavatest arhitektuuristiilidest, tehnoloogiapakkidest ja raamistikest, kuid viimasel ajal on selliste probleemide lahendamisel edusamme tehtud.
Teie tähelepanu alla juhitud artiklis räägin MTÜ Krista kogemustest ja arhitektuursetest uuringutest selleks ettenähtud piirkonnas. Vaatleme ka näidet integratsiooniprobleemi lihtsast lahendusest rakenduste arendaja vaatevinklist ja uurime, mis selle lihtsuse taga on.
Vastutusest loobumine
Artiklis kirjeldatud arhitektuursed ja tehnilised lahendused olen pakutud isiklikule kogemusele tuginedes konkreetsete ülesannete kontekstis. Need lahendused ei pretendeeri universaalsusele ega pruugi olla optimaalsed teistes kasutustingimustes.
Mis BPM-il sellega pistmist on?
Sellele küsimusele vastamiseks peame veidi süvenema meie lahenduste rakendusprobleemide eripäradesse. Äriloogika põhiosa meie tüüpilises tehingusüsteemis on andmete sisestamine andmebaasi kasutajaliideste kaudu, nende andmete käsitsi ja automaatne kontrollimine, mingi töövoo läbimine, teise süsteemi / analüütilisse andmebaasi / arhiivi avaldamine, aruannete genereerimine. Seega on süsteemi põhifunktsioon klientide jaoks nende sisemiste äriprotsesside automatiseerimine.
Mugavuse huvides kasutame mõistet "dokument" suhtluses mingi andmekogumi abstraktsioonina, mida ühendab ühine võti, millele saab "kinnitada" konkreetse töövoo.
Aga kuidas on lood integratsiooniloogikaga? Lõppkokkuvõttes genereerib integreerimise ülesande süsteemi arhitektuur, mis "saagitakse" osadeks MITTE kliendi soovil, vaid täiesti erinevate tegurite mõjul:
Conway seaduse mõjul;
varem muude toodete jaoks välja töötatud alamsüsteemide taaskasutamise tulemusena;
nagu arhitekt otsustas, lähtudes mittefunktsionaalsetest nõuetest.
On suur kiusatus eraldada integratsiooniloogika põhitöövoo äriloogikast, et mitte reostada äriloogikat integratsiooniartefaktidega ja säästa rakenduste arendajat süsteemi arhitektuurilise maastiku iseärasustesse süvenemisest. Sellel lähenemisviisil on mitmeid eeliseid, kuid praktika näitab selle ebaefektiivsust:
integratsiooniprobleemide lahendamine libiseb reeglina alla kõige lihtsamate valikuteni sünkroonkõnede näol, kuna laienduspunktid on põhitöövoo realiseerimisel piiratud (sünkroonse integreerimise puudustest lähemalt allpool);
integratsiooni artefaktid tungivad endiselt peamisse äriloogikasse, kui on vaja tagasisidet teisest alamsüsteemist;
rakenduse arendaja ignoreerib integratsiooni ja võib selle töövoogu muutes kergesti katkestada;
süsteem lakkab olemast ühtne tervik kasutaja seisukohalt, alamsüsteemidevahelised "õmblused" muutuvad märgatavaks, ilmnevad üleliigsed kasutajatoimingud, mis algatavad andmete ülekandmise ühest alamsüsteemist teise.
Teine lähenemisviis on käsitleda integratsiooni interaktsioone põhilise äriloogika ja töövoo lahutamatu osana. Selleks, et rakenduste arendajate oskuste nõuded ei tõuseks hüppeliselt, tuleks uute integreerimisinteraktsioonide loomine toimuda lihtsalt ja loomulikult ning lahenduse valikul on minimaalsed võimalused. See on keerulisem, kui pealtnäha paistab: tööriist peab olema piisavalt võimas, et pakkuda kasutajale selle kasutamiseks vajalikke erinevaid võimalusi ja samal ajal mitte lasta endale jalga lasta. On palju küsimusi, millele insener peab integratsiooniülesannete kontekstis vastama, kuid millele rakenduste arendaja ei peaks oma igapäevatöös mõtlema: tehingupiirid, järjepidevus, atomaalsus, turvalisus, skaleerimine, koormuse ja ressursside jaotus, marsruutimine, jaotus, levitamise ja vahetamise kontekstid jne. Rakenduste arendajatele on vaja pakkuda üsna lihtsaid otsustusmalle, milles on vastused kõikidele sellistele küsimustele juba peidus. Need mustrid peaksid olema piisavalt turvalised: äriloogika muutub väga sageli, mis suurendab vigade sissetoomise riski, vigade maksumus peaks jääma üsna madalale tasemele.
Aga ikkagi, mis BPM-il sellega pistmist on? Töövoo rakendamiseks on palju võimalusi ...
Tõepoolest, meie lahendustes on väga populaarne veel üks äriprotsesside teostus - oleku ülemineku diagrammi deklaratiivse seadistamise ja äriloogikaga töötlejate ühendamise kaudu üleminekutega. Samal ajal on olek, mis määrab "dokumendi" hetkepositsiooni äriprotsessis, "dokumendi" enda atribuudiks.
Nii näeb protsess välja projekti alguses
Sellise teostuse populaarsus on tingitud lineaarsete äriprotsesside loomise suhtelisest lihtsusest ja kiirusest. Kuna aga tarkvarasüsteemid muutuvad keerukamaks, kasvab ja muutub keerukamaks äriprotsessi automatiseeritud osa. Vaja on lagundamist, protsesside osade taaskasutamist, samuti protsesside harutamist, et iga haru toimuks paralleelselt. Sellistel tingimustel muutub tööriist ebamugavaks ja oleku ülemineku diagramm kaotab oma teabesisu (integratsiooni interaktsioonid ei kajastu diagrammil üldse).
Selline näeb protsess välja pärast mitut nõuete selgitamist
Väljapääs sellest olukorrast oli mootori integreerimine jBPM mõnesse kõige keerukamate äriprotsessidega toodetesse. Lühiajalises perspektiivis oli sellel lahendusel mõningane edu: sai võimalikuks keerukate äriprotsesside juurutamine, säilitades märkmetes üsna informatiivse ja ajakohase diagrammi. BPMN2.
Väike osa keerulisest äriprotsessist
Pikemas perspektiivis ei vastanud lahendus ootustele: visuaalsete tööriistade abil äriprotsesside loomise suur töömahukus ei võimaldanud saavutada vastuvõetavaid tootlikkuse näitajaid ning tööriist ise muutus arendajate seas üheks ebameeldivamaks. Samuti esitati kaebusi mootori sisemise struktuuri kohta, mis tõi kaasa paljude “plaastrite” ja “karkude” ilmumise.
jBPM-i kasutamise peamine positiivne aspekt oli äriprotsessi eksemplari jaoks oma püsiva oleku eeliste ja kahjude mõistmine. Samuti nägime võimalust kasutada protsessipõhist lähenemist keerukate integratsiooniprotokollide rakendamiseks erinevate rakenduste vahel, kasutades signaalide ja sõnumite kaudu asünkroonset interaktsiooni. Püsiva seisundi olemasolu mängib selles otsustavat rolli.
Ülaltoodu põhjal võime järeldada: BPM-stiilis protsessikäsitlus võimaldab lahendada väga erinevaid ülesandeid üha keerukamate äriprotsesside automatiseerimiseks, integreerimistegevused nendesse protsessidesse harmooniliselt sobitada ning säilitada võimaluse realiseeritud protsessi visuaalselt sobivas notatsioonis kuvada.
Sünkroonkõnede kui integratsioonimustri miinused
Sünkroonne integreerimine viitab lihtsaimale blokeerivale kõnele. Üks alamsüsteem toimib serveri poolena ja paljastab API soovitud meetodiga. Teine alamsüsteem toimib kliendi poolena ja helistab õigel ajal tulemuse ootusega. Olenevalt süsteemi arhitektuurist saab kliendi- ja serveripoolt majutada kas samas rakenduses ja protsessis või erinevates. Teisel juhul peate rakendama RPC-d ja pakkuma parameetrite ja kõne tulemuse järjestamist.
Sellisel integreerimismustril on üsna suur hulk puudusi, kuid praktikas kasutatakse seda oma lihtsuse tõttu väga laialdaselt. Teostamise kiirus köidab ja paneb seda ikka ja jälle rakendama "põlevate" tähtaegade tingimustes, kirjutades lahenduse tehnilisse võlga. Kuid juhtub ka, et kogenematud arendajad kasutavad seda alateadlikult, lihtsalt ei mõista negatiivseid tagajärgi.
Lisaks alamsüsteemide ühenduvuse kõige ilmsemale suurenemisele on vähem ilmseid probleeme "levitavate" ja "venitavate" tehingutega. Tõepoolest, kui äriloogika teeb mingeid muudatusi, on tehingud hädavajalikud ja tehingud omakorda lukustavad teatud rakendusressursid, mida need muudatused mõjutavad. See tähendab, et kuni üks alamsüsteem ei oota teiselt vastust, ei saa see tehingut lõpule viia ega lukke vabastada. See suurendab märkimisväärselt mitmesuguste tagajärgede riski:
süsteemi reageerimisvõime kaob, kasutajad ootavad päringutele vastuseid kaua;
server lõpetab üldiselt kasutajate päringutele vastamise ületäitunud lõimekogumi tõttu: enamik lõimedest "seisab" tehingu poolt hõivatud ressursi lukus;
hakkavad tekkima ummikseisud: nende tekkimise tõenäosus sõltub tugevalt tehingute kestusest, äriloogika ja tehinguga seotud lukkude mahust;
ilmuvad tehingu ajalõpu aegumise vead;
server "kukkub" OutOfMemoryle, kui ülesanne nõuab suurte andmemahtude töötlemist ja muutmist ning sünkroonsete integratsioonide olemasolu muudab töötlemise "kergemateks" tehinguteks jagamise väga keeruliseks.
Arhitektuurilisest vaatenurgast viib blokeerimiskõnede kasutamine integreerimise ajal üksikute alamsüsteemide kvaliteedikontrolli kadumiseni: ühe allsüsteemi kvaliteedieesmärke on võimatu tagada eraldi teise allsüsteemi kvaliteedieesmärkidest. Kui alamsüsteeme arendavad erinevad meeskonnad, on see suur probleem.
Asi läheb veelgi huvitavamaks, kui integreeritavad alamsüsteemid on erinevates rakendustes ja mõlemal poolel on vaja teha sünkroonseid muudatusi. Kuidas muuta need muudatused tehinguliseks?
Kui muudatusi tehakse eraldi tehingutes, siis tuleb tagada robustne erandite käsitlemine ja kompenseerimine ning see välistab täielikult sünkroonsete integratsioonide peamise eelise - lihtsuse.
Meenuvad ka hajutatud tehingud, kuid me ei kasuta neid oma lahendustes: usaldusväärsust on raske tagada.
"Saaga" kui lahendus tehingute probleemile
Mikroteenuste populaarsuse kasvuga suureneb nõudlus Saaga muster.
See muster lahendab suurepäraselt ülaltoodud pikkade tehingute probleemid ja avardab ka võimalusi süsteemi oleku juhtimiseks äriloogika poolelt: ebaõnnestunud tehingu järgselt makstav hüvitis ei pruugi süsteemi algolekusse tagasi viia, vaid pakkuda alternatiivi. andmetöötluse marsruut. Samuti võimaldab see mitte korrata edukalt lõpetatud andmetöötlusetappe, kui proovite protsessi "hea" lõpuni viia.
Huvitav on see, et monoliitsetes süsteemides on see muster asjakohane ka lõdvalt seotud alamsüsteemide integreerimisel ning pikad tehingud ja vastavad ressursilukud põhjustavad negatiivseid mõjusid.
Mis puutub meie BPM-stiilis äriprotsessidesse, siis on Sagade juurutamine väga lihtne: Sagade üksikuid samme saab määrata tegevusteks äriprotsessi sees ning äriprotsessi püsiv olek määrab muuhulgas , Saagade sisemine olek. See tähendab, et me ei vaja täiendavat koordineerimismehhanismi. Kõik, mida vajate, on sõnumivahendaja, millel on transpordiks "vähemalt ühekordne" garantii.
Kuid sellisel lahendusel on ka oma "hind":
äriloogika muutub keerulisemaks: tuleb välja töötada hüvitis;
tuleb loobuda täielikust järjepidevusest, mis võib olla eriti tundlik monoliitsete süsteemide puhul;
arhitektuur muutub veidi keerulisemaks, tekib täiendav vajadus sõnumivahendaja järele;
vaja on täiendavaid jälgimis- ja haldustööriistu (kuigi üldiselt on see isegi hea: süsteemiteenuse kvaliteet tõuseb).
Monoliitsete süsteemide puhul pole "Sags" kasutamise õigustus nii ilmne. Mikroteenuste ja muude SOA-de puhul, kus suure tõenäosusega on maakler juba olemas ja projekti alguses ohverdati täielik järjepidevus, võivad selle mustri kasutamise eelised oluliselt üles kaaluda puudused, eriti kui rakenduses on mugav API äriloogika tase.
Äriloogika kapseldamine mikroteenustesse
Kui hakkasime mikroteenustega katsetama, tekkis mõistlik küsimus: kuhu panna domeeni äriloogika seoses domeeniandmete püsivust tagava teenusega?
Erinevate BPMS-ide arhitektuuri vaadates võib tunduda mõistlik eraldada äriloogika püsivusest: luua platvormist ja domeenist sõltumatute mikroteenuste kiht, mis moodustavad domeeni äriloogika täitmiseks keskkonna ja konteineri, ning korraldada domeeni andmete püsivus eraldiseisvana. kiht väga lihtsaid ja kergeid mikroteenuseid. Äriprotsessid korraldavad sel juhul püsivuskihi teenuseid.
Sellel lähenemisel on väga suur pluss: platvormi funktsionaalsust saad suurendada nii palju kui soovid ja sellest “paksub” vaid vastav platvormi mikroteenuste kiht. Mis tahes domeeni äriprotsessid saavad kohe võimaluse kasutada platvormi uut funktsionaalsust kohe pärast selle värskendamist.
Üksikasjalikum uuring näitas selle lähenemisviisi olulisi puudusi:
platvormteenus, mis rakendab korraga paljude domeenide äriloogikat, kannab endas suuri riske ühe tõrkepunktina. Äriloogika sagedased muudatused suurendavad vigade ohtu, mis põhjustavad kogu süsteemi tõrkeid;
jõudlusprobleemid: äriloogika töötab oma andmetega kitsa ja aeglase liidese kaudu:
andmed järjestatakse ja pumbatakse uuesti läbi võrgupinu;
domeeniteenus tagastab sageli rohkem andmeid, kui äriloogika töötlemiseks nõuab, kuna teenuse välise API tasemel on ebapiisavad päringu parameetrite määramise võimalused;
mitmed sõltumatud äriloogika osad võivad samu andmeid korduvalt töötlemiseks uuesti taotleda (saate seda probleemi leevendada, lisades andmeid vahemällu salvestavaid seansi ube, kuid see muudab arhitektuuri veelgi keerulisemaks ning tekitab andmete värskuse ja vahemälu kehtetuks tunnistamise probleeme);
tehinguga seotud probleemid:
platvormiteenuse salvestatud püsiva olekuga äriprotsessid ei ole domeeniandmetega kooskõlas ja selle probleemi lahendamiseks pole lihtsaid viise;
domeeniandmete lukustuse teisaldamine tehingust välja: kui domeeni äriloogikas on vaja teha muudatusi, tuleb pärast esmast tegelike andmete õigsuse kontrollimist välistada töödeldavate andmete konkurentsimuutuse võimalus. Andmete väline blokeerimine võib aidata probleemi lahendada, kuid selline lahendus toob endaga kaasa lisariske ja vähendab süsteemi üldist töökindlust;
täiendavad komplikatsioonid värskendamisel: mõnel juhul peate uuendama püsivusteenust ja äriloogikat sünkroonselt või ranges järjekorras.
Lõpuks pidin minema tagasi põhitõdede juurde: kapseldama domeeni andmed ja domeeni äriloogika ühte mikroteenusesse. Selline lähenemine lihtsustab mikroteenuse kui süsteemi lahutamatu komponendi tajumist ega tekita ülaltoodud probleeme. See pole ka tasuta:
API standardimine on vajalik interaktsiooniks äriloogikaga (eelkõige kasutajate tegevuste pakkumiseks äriprotsesside osana) ja API platvormiteenustega; API muudatustele, edasi- ja tagasiühilduvusele on vaja rohkem tähelepanu pöörata;
iga sellise mikroteenuse osana on vaja lisada täiendavaid käitusaegseid teeke, et tagada äriloogika toimimine, ja see toob kaasa uued nõuded sellistele teekidele: kergus ja minimaalne transitiivne sõltuvus;
äriloogika arendajad peavad jälgima teegi versioone: kui mikroteenust pole pikka aega lõpetatud, sisaldab see tõenäoliselt teekide aegunud versioone. See võib olla ootamatu takistus uue funktsiooni lisamisel ja võib nõuda sellise teenuse vana äriloogika üleviimist teekide uutele versioonidele, kui versioonide vahel toimusid kokkusobimatud muudatused.
Sellises arhitektuuris on ka platvormiteenuste kiht, kuid see kiht ei moodusta enam konteinerit domeeni äriloogika täitmiseks, vaid ainult selle keskkonda, pakkudes "platvormi" abifunktsioone. Sellist kihti pole vaja mitte ainult domeeni mikroteenuste kerguse säilitamiseks, vaid ka halduse tsentraliseerimiseks.
Näiteks kasutajate tegevused äriprotsessides genereerivad ülesandeid. Ülesannetega töötades peab kasutaja aga nägema ülesandeid kõigist domeenidest üldnimekirjas, mis tähendab, et seal peab olema vastav, domeeni äriloogikast puhastatud ülesannete registreerimisplatvormi teenus. Äriloogika kapseldamine selles kontekstis on üsna problemaatiline ja see on veel üks selle arhitektuuri kompromiss.
Äriprotsesside integreerimine rakenduste arendaja pilgu läbi
Nagu eespool juba mainitud, peab rakenduste arendaja olema abstraheeritud mitme rakenduse koostoime rakendamise tehnilistest ja insenertehnilistest omadustest, et saaks loota heale arendustootlikkusele.
Proovime lahendada üsna keerulise integreerimisprobleemi, mis on spetsiaalselt artikli jaoks leiutatud. See on "mängu" ülesanne, mis hõlmab kolme rakendust, kus igaüks neist määratleb mõne domeeninime: "app1", "app2", "app3".
Iga rakenduse sees käivitatakse äriprotsessid, mis hakkavad integratsioonisiini kaudu "palli mängima". Kirjad nimega "Pall" toimivad pallina.
Mängu reeglid:
esimene mängija on algataja. Ta kutsub teisi mängijaid mängu, alustab mängu ja võib selle igal ajal lõpetada;
teised mängijad deklareerivad oma osalemist mängus, "tutvuvad" üksteise ja esimese mängijaga;
pärast palli saamist valib mängija teise osaleva mängija ja söödab palli talle. Loendatakse läbimiste koguarv;
igal mängijal on "energia", mis väheneb selle mängija iga palli sööduga. Kui energia saab otsa, eemaldatakse mängija mängust, teatades oma pensionile jäämisest;
kui mängija jäetakse üksi, teatab ta kohe oma lahkumisest;
kui kõik mängijad langevad välja, kuulutab esimene mängija mängu lõpu. Kui ta lahkus mängust varem, siis tuleb mängu lõpuleviimiseks jälgida.
Selle probleemi lahendamiseks kasutan meie äriprotsesside jaoks mõeldud DSL-i, mis võimaldab kirjeldada Kotlini loogikat kompaktselt ja minimaalselt.
Rakenduses app1 töötab esimese mängija (ta on ka mängu algataja) äriprotsess:
klassi InitialPlayer
import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.constraint.UniqueConstraints
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.dsl.taskOperation
import ru.krista.bpm.runtime.instance.MessageSendInstance
data class PlayerInfo(val name: String, val domain: String, val id: String)
class PlayersList : ArrayList<PlayerInfo>()
// Это класс экземпляра процесса: инкапсулирует его внутреннее состояние
class InitialPlayer : ProcessImpl<InitialPlayer>(initialPlayerModel) {
var playerName: String by persistent("Player1")
var energy: Int by persistent(30)
var players: PlayersList by persistent(PlayersList())
var shotCounter: Int = 0
}
// Это декларация модели процесса: создается один раз, используется всеми
// экземплярами процесса соответствующего класса
val initialPlayerModel = processModel<InitialPlayer>(name = "InitialPlayer",
version = 1) {
// По правилам, первый игрок является инициатором игры и должен быть единственным
uniqueConstraint = UniqueConstraints.singleton
// Объявляем активности, из которых состоит бизнес-процесс
val sendNewGameSignal = signal<String>("NewGame")
val sendStopGameSignal = signal<String>("StopGame")
val startTask = humanTask("Start") {
taskOperation {
processCondition { players.size > 0 }
confirmation { "Подключилось ${players.size} игроков. Начинаем?" }
}
}
val stopTask = humanTask("Stop") {
taskOperation {}
}
val waitPlayerJoin = signalWait<String>("PlayerJoin") { signal ->
players.add(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
println("... join player ${signal.data} ...")
}
val waitPlayerOut = signalWait<String>("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
println("... player ${signal.data} is out ...")
}
val sendPlayerOut = signal<String>("PlayerOut") {
signalData = { playerName }
}
val sendHandshake = messageSend<String>("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val throwStartBall = messageSend<Int>("Ball") {
messageData = { 1 }
activation = { selectNextPlayer() }
}
val throwBall = messageSend<Int>("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData<Int>("Ball") {
shotCounter = it
}
// Теперь конструируем граф процесса из объявленных активностей
startFrom(sendNewGameSignal)
.fork("mainFork") {
next(startTask)
next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
next(waitPlayerOut)
.branch("checkPlayers") {
ifTrue { players.isEmpty() }
.next(sendStopGameSignal)
.terminate()
ifElse().next(waitPlayerOut)
}
}
startTask.fork("afterStart") {
next(throwStartBall)
.branch("mainLoop") {
ifTrue { energy < 5 }.next(sendPlayerOut).next(waitBall)
ifElse().next(waitBall).next(throwBall).loop()
}
next(stopTask).next(sendStopGameSignal)
}
// Навешаем на активности дополнительные обработчики для логирования
sendNewGameSignal.onExit { println("Let's play!") }
sendStopGameSignal.onExit { println("Stop!") }
sendPlayerOut.onExit { println("$playerName: I'm out!") }
}
private fun MessageSendInstance<InitialPlayer, Int>.selectNextPlayer() {
val player = process.players.random()
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Step ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}
Lisaks äriloogika täitmisele võib ülaltoodud kood luua äriprotsessi objektmudeli, mida saab diagrammina visualiseerida. Me ei ole visualiseerijat veel juurutanud, seega pidime natuke aega kulutama joonistamisele (siinkohal lihtsustasin veidi BPMN-i tähistust väravate kasutamise kohta, et parandada diagrammi kooskõla ülaltoodud koodiga):
app2 sisaldab teise mängija äriprotsessi:
klassi RandomPlayer
import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.instance.MessageSendInstance
data class PlayerInfo(val name: String, val domain: String, val id: String)
class PlayersList: ArrayList<PlayerInfo>()
class RandomPlayer : ProcessImpl<RandomPlayer>(randomPlayerModel) {
var playerName: String by input(persistent = true,
defaultValue = "RandomPlayer")
var energy: Int by input(persistent = true, defaultValue = 30)
var players: PlayersList by persistent(PlayersList())
var allPlayersOut: Boolean by persistent(false)
var shotCounter: Int = 0
val selfPlayer: PlayerInfo
get() = PlayerInfo(playerName, env.eventDispatcher.domainName, id)
}
val randomPlayerModel = processModel<RandomPlayer>(name = "RandomPlayer",
version = 1) {
val waitNewGameSignal = signalWait<String>("NewGame")
val waitStopGameSignal = signalWait<String>("StopGame")
val sendPlayerJoin = signal<String>("PlayerJoin") {
signalData = { playerName }
}
val sendPlayerOut = signal<String>("PlayerOut") {
signalData = { playerName }
}
val waitPlayerJoin = signalWaitCustom<String>("PlayerJoin") {
eventCondition = { signal ->
signal.sender.processInstanceId != process.id
&& !process.players.any { signal.sender.processInstanceId == it.id}
}
handler = { signal ->
players.add(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
}
}
val waitPlayerOut = signalWait<String>("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
allPlayersOut = players.isEmpty()
}
val sendHandshake = messageSend<String>("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val receiveHandshake = messageWait<String>("Handshake") { message ->
if (!players.any { message.sender.processInstanceId == it.id}) {
players.add(PlayerInfo(
message.data!!,
message.sender.domain,
message.sender.processInstanceId))
}
}
val throwBall = messageSend<Int>("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData<Int>("Ball") {
shotCounter = it
}
startFrom(waitNewGameSignal)
.fork("mainFork") {
next(sendPlayerJoin)
.branch("mainLoop") {
ifTrue { energy < 5 || allPlayersOut }
.next(sendPlayerOut)
.next(waitBall)
ifElse()
.next(waitBall)
.next(throwBall)
.loop()
}
next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
next(waitPlayerOut).next(waitPlayerOut)
next(receiveHandshake).next(receiveHandshake)
next(waitStopGameSignal).terminate()
}
sendPlayerJoin.onExit { println("$playerName: I'm here!") }
sendPlayerOut.onExit { println("$playerName: I'm out!") }
}
private fun MessageSendInstance<RandomPlayer, Int>.selectNextPlayer() {
val player = if (process.players.isNotEmpty())
process.players.random()
else
process.selfPlayer
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Step ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}
Diagramm:
Rakenduses app3 muudame mängija pisut teistsuguse käitumisega: selle asemel, et juhuslikult järgmist mängijat valida, tegutseb ta ring-robin algoritmi järgi:
klassi RoundRobinPlayer
import ru.krista.bpm.ProcessInstance
import ru.krista.bpm.runtime.ProcessImpl
import ru.krista.bpm.runtime.dsl.processModel
import ru.krista.bpm.runtime.instance.MessageSendInstance
data class PlayerInfo(val name: String, val domain: String, val id: String)
class PlayersList: ArrayList<PlayerInfo>()
class RoundRobinPlayer : ProcessImpl<RoundRobinPlayer>(roundRobinPlayerModel) {
var playerName: String by input(persistent = true,
defaultValue = "RoundRobinPlayer")
var energy: Int by input(persistent = true, defaultValue = 30)
var players: PlayersList by persistent(PlayersList())
var nextPlayerIndex: Int by persistent(-1)
var allPlayersOut: Boolean by persistent(false)
var shotCounter: Int = 0
val selfPlayer: PlayerInfo
get() = PlayerInfo(playerName, env.eventDispatcher.domainName, id)
}
val roundRobinPlayerModel = processModel<RoundRobinPlayer>(
name = "RoundRobinPlayer",
version = 1) {
val waitNewGameSignal = signalWait<String>("NewGame")
val waitStopGameSignal = signalWait<String>("StopGame")
val sendPlayerJoin = signal<String>("PlayerJoin") {
signalData = { playerName }
}
val sendPlayerOut = signal<String>("PlayerOut") {
signalData = { playerName }
}
val waitPlayerJoin = signalWaitCustom<String>("PlayerJoin") {
eventCondition = { signal ->
signal.sender.processInstanceId != process.id
&& !process.players.any { signal.sender.processInstanceId == it.id}
}
handler = { signal ->
players.add(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
}
}
val waitPlayerOut = signalWait<String>("PlayerOut") { signal ->
players.remove(PlayerInfo(
signal.data!!,
signal.sender.domain,
signal.sender.processInstanceId))
allPlayersOut = players.isEmpty()
}
val sendHandshake = messageSend<String>("Handshake") {
messageData = { playerName }
activation = {
receiverDomain = process.players.last().domain
receiverProcessInstanceId = process.players.last().id
}
}
val receiveHandshake = messageWait<String>("Handshake") { message ->
if (!players.any { message.sender.processInstanceId == it.id}) {
players.add(PlayerInfo(
message.data!!,
message.sender.domain,
message.sender.processInstanceId))
}
}
val throwBall = messageSend<Int>("Ball") {
messageData = { shotCounter + 1 }
activation = { selectNextPlayer() }
onEntry { energy -= 1 }
}
val waitBall = messageWaitData<Int>("Ball") {
shotCounter = it
}
startFrom(waitNewGameSignal)
.fork("mainFork") {
next(sendPlayerJoin)
.branch("mainLoop") {
ifTrue { energy < 5 || allPlayersOut }
.next(sendPlayerOut)
.next(waitBall)
ifElse()
.next(waitBall)
.next(throwBall)
.loop()
}
next(waitPlayerJoin).next(sendHandshake).next(waitPlayerJoin)
next(waitPlayerOut).next(waitPlayerOut)
next(receiveHandshake).next(receiveHandshake)
next(waitStopGameSignal).terminate()
}
sendPlayerJoin.onExit { println("$playerName: I'm here!") }
sendPlayerOut.onExit { println("$playerName: I'm out!") }
}
private fun MessageSendInstance<RoundRobinPlayer, Int>.selectNextPlayer() {
var idx = process.nextPlayerIndex + 1
if (idx >= process.players.size) {
idx = 0
}
process.nextPlayerIndex = idx
val player = if (process.players.isNotEmpty())
process.players[idx]
else
process.selfPlayer
receiverDomain = player.domain
receiverProcessInstanceId = player.id
println("Step ${process.shotCounter + 1}: " +
"${process.playerName} >>> ${player.name}")
}
Vastasel juhul ei erine mängija käitumine eelmisest, seega diagramm ei muutu.
Nüüd vajame selle kõige käivitamiseks testi. Annan ainult testi enda koodi, et artiklit mitte risustada (tegelikult kasutasin teiste äriprotsesside integreerimise testimiseks varem loodud testkeskkonda):
testGame()
@Test
public void testGame() throws InterruptedException {
String pl2 = startProcess(app2, "RandomPlayer", playerParams("Player2", 20));
String pl3 = startProcess(app2, "RandomPlayer", playerParams("Player3", 40));
String pl4 = startProcess(app3, "RoundRobinPlayer", playerParams("Player4", 25));
String pl5 = startProcess(app3, "RoundRobinPlayer", playerParams("Player5", 35));
String pl1 = startProcess(app1, "InitialPlayer");
// Теперь нужно немного подождать, пока игроки "познакомятся" друг с другом.
// Ждать через sleep - плохое решение, зато самое простое.
// Не делайте так в серьезных тестах!
Thread.sleep(1000);
// Запускаем игру, закрывая пользовательскую активность
assertTrue(closeTask(app1, pl1, "Start"));
app1.getWaiting().waitProcessFinished(pl1);
app2.getWaiting().waitProcessFinished(pl2);
app2.getWaiting().waitProcessFinished(pl3);
app3.getWaiting().waitProcessFinished(pl4);
app3.getWaiting().waitProcessFinished(pl5);
}
private Map<String, Object> playerParams(String name, int energy) {
Map<String, Object> params = new HashMap<>();
params.put("playerName", name);
params.put("energy", energy);
return params;
}
Sellest kõigest saab teha mitmeid olulisi järeldusi:
vajalike tööriistade olemasolul saavad rakenduste arendajad luua rakenduste vahel integratsiooniinteraktsioone ilma äriloogikast lahti murdmata;
inseneripädevusi nõudva integreerimisülesande keerukus (keerukus) võib peituda raamistiku sees, kui see on algselt raamistiku arhitektuuris sätestatud. Ülesande raskusastet (raskusastet) ei saa varjata, seega näeb keerulise ülesande lahendus koodis välja vastavalt;
lõimumisloogika väljatöötamisel tuleb arvestada lõpuks kõigi lõimumisel osalejate olekumuutuse järjepidevuse ja lineariseeritavuse puudumisega. See sunnib meid loogikat keerulisemaks muutma, et muuta see väliste sündmuste toimumise järjekorra suhtes tundetuks. Meie näites on mängija sunnitud mängust osa võtma pärast seda, kui ta on teatanud oma mängust lahkumisest: teised mängijad jätkavad talle palli söötmist, kuni teave tema väljumise kohta jõuab ja kõik osalejad seda töötlevad. Selline loogika ei tulene mängureeglitest ja on kompromisslahendus valitud arhitektuuri raames.
Järgmisena räägime meie lahenduse erinevatest nüanssidest, kompromissidest ja muudest punktidest.
Kõik kirjad ühes järjekorras
Kõik integreeritud rakendused töötavad ühe integratsioonisiiniga, mis on esindatud välise maaklerina, ühe BPMQueue'iga sõnumite jaoks ja ühe BPMTopici teemaga signaalide (sündmuste) jaoks. Kõigi sõnumite ühe järjekorra kaudu saatmine on iseenesest kompromiss. Äriloogika tasandil saate nüüd kasutusele võtta nii palju uut tüüpi sõnumeid, kui soovite, ilma süsteemi struktuuri muutmata. See on märkimisväärne lihtsustus, kuid sellega kaasnevad teatud riskid, mis meie tüüpiliste ülesannete kontekstis ei tundunud meile nii olulised.
Siin on aga üks nüanss: iga rakendus filtreerib oma domeeni nime järgi sissepääsu järjekorras olevast "oma" sõnumid. Samuti saab signaalides määrata domeeni, kui on vaja piirata signaali “ulatust” ühe rakendusega. See peaks suurendama siini ribalaiust, kuid äriloogika peab nüüd toimima domeeninimedega: sõnumite adresseerimisel kohustuslik, signaalide puhul soovitav.
Integratsioonisiini töökindluse tagamine
Usaldusväärsus koosneb mitmest asjast:
Valitud sõnumivahendaja on arhitektuuri kriitiline komponent ja üksainus tõrkepunkt: see peab olema piisavalt tõrketaluv. Peaksite kasutama ainult ajaproovitud rakendusi, millel on hea tugi ja suur kogukond;
on vaja tagada sõnumivahendaja kõrge kättesaadavus, mille jaoks tuleb see integreeritud rakendustest füüsiliselt eraldada (rakendusliku äriloogikaga rakenduste kõrget kättesaadavust on palju keerulisem ja kulukam pakkuda);
maakler on kohustatud andma "vähemalt ühekordse" tarnetagatise. See on kohustuslik nõue integreerimissiini usaldusväärseks tööks. "Täpselt üks kord" tasemel garantiisid pole vaja: äriprotsessid ei ole enamasti tundlikud sõnumite või sündmuste korduva saabumise suhtes ning eriülesannetes, kus see on oluline, on lihtsam lisada äriloogikasse täiendavaid kontrolle kui pidevalt kasutada. pigem "kallid" " garantiid;
sõnumite ja signaalide saatmine peab olema kaasatud ühisesse tehingusse äriprotsesside ja domeeniandmete oleku muutumisega. Eelistatud variant oleks kasutada mustrit Tehingu väljundkaust, kuid see nõuab täiendavat tabelit andmebaasis ja releed. JEE rakendustes saab seda lihtsustada kohaliku JTA halduri abil, kuid ühendus valitud maakleriga peab töötama režiimis XA;
äriprotsessi oleku muutmise tehinguga peavad töötama ka sissetulevate sõnumite ja sündmuste käitlejad: kui selline tehing tagasi keeratakse, siis tuleb tühistada ka teate vastuvõtmine;
sõnumid, mida ei saanud vigade tõttu kohale toimetada, tuleks hoida eraldi poes D.L.Q. (Surnud kirjade järjekord). Selleks lõime eraldi platvormi mikroteenuse, mis salvestab sellised sõnumid oma salvestusruumi, indekseerib need atribuutide järgi (kiireks rühmitamiseks ja otsimiseks) ning avab API vaatamiseks, sihtaadressile uuesti saatmiseks ja sõnumite kustutamiseks. Süsteemiadministraatorid saavad selle teenusega töötada oma veebiliidese kaudu;
maakleri seadetes tuleb reguleerida tarnekorduste arvu ja tarnetevahelisi viivitusi, et vähendada tõenäosust, et teated jõuavad DLQ-sse (optimaalseid parameetreid on peaaegu võimatu välja arvutada, kuid saate tegutseda empiiriliselt ja neid reguleerida operatsioon);
DLQ-poodi tuleks pidevalt jälgida ja seiresüsteem peaks teavitama süsteemiadministraatoreid, et nad saaksid edastamata sõnumite ilmnemisel võimalikult kiiresti reageerida. See vähendab tõrke või äriloogika vea "kahjustustsooni";
integratsioonisiin peab olema tundetu rakenduste ajutise puudumise suhtes: teematellimused peavad olema vastupidavad ja rakenduse domeeninimi peab olema kordumatu, et keegi teine ei üritaks rakenduse puudumisel selle sõnumit järjekorrast töödelda.
Äriloogika lõime ohutuse tagamine
Äriprotsessi sama eksemplar võib korraga vastu võtta mitu sõnumit ja sündmust, mille töötlemine algab paralleelselt. Samas peaks rakenduste arendaja jaoks kõik olema lihtne ja lõimekindel.
Protsessi äriloogika töötleb iga välist sündmust, mis seda äriprotsessi mõjutab, eraldi. Need sündmused võivad olla:
äriprotsessi eksemplari käivitamine;
äriprotsessis toimuva tegevusega seotud kasutaja toiming;
sõnumi või signaali vastuvõtmine, millele äriprotsessi eksemplar on tellitud;
äriprotsessi eksemplari seatud taimeri aegumine;
juhttoimingud API kaudu (nt protsessi katkestamine).
Iga selline sündmus võib muuta äriprotsessi eksemplari olekut: mõned tegevused võivad lõppeda ja teised alata, püsivate omaduste väärtused võivad muutuda. Mis tahes tegevuse sulgemine võib kaasa tuua ühe või mitme järgmise tegevuse aktiveerimise. Need omakorda võivad lõpetada teiste sündmuste ootamise või kui nad täiendavaid andmeid ei vaja, saavad nad sooritada sama tehingu. Enne tehingu sulgemist salvestatakse äriprotsessi uus olek andmebaasi, kus see jääb ootama järgmist välist sündmust.
Relatsiooniandmebaasi salvestatud püsivad äriprotsesside andmed on väga mugav töötlemise sünkroonimispunkt, kui kasutate valikut SELECT FOR UPDATE. Kui ühel tehingul õnnestus saada andmebaasist äriprotsessi olek selle muutmiseks, siis ükski teine paralleelne tehing ei saa teise muudatuse jaoks sama seisundit ja pärast esimese tehingu sooritamist on teine. garanteeritud juba muutunud oleku saamine.
Kasutades DBMS-i poolel pessimistlikke lukke, täidame kõik vajalikud nõuded ACID, ja säilitavad ka võimaluse skaleerida rakendust äriloogikaga, suurendades töötavate eksemplaride arvu.
Pessimistlikud lukud ähvardavad meid aga ummikseisudega, mis tähendab, et SELECT FOR UPDATE peaks siiski piirduma mõne mõistliku ajalõpuga juhuks, kui mõnel äriloogikas silmatorkaval juhul ummikusse sattuda.
Teine probleem on äriprotsessi alguse sünkroonimine. Kuigi äriprotsessi eksemplari pole, pole ka andmebaasis olekut, seega kirjeldatud meetod ei tööta. Kui soovite tagada äriprotsessi eksemplari unikaalsust konkreetses ulatuses, on teil vaja protsessiklassi ja vastava ulatusega seotud mingit sünkroonimisobjekti. Selle probleemi lahendamiseks kasutame teistsugust lukustusmehhanismi, mis võimaldab meil välisteenuse kaudu lukustada URI-vormingus võtmega määratud suvalise ressursi.
Meie näidetes sisaldab InitialPlayeri äriprotsess deklaratsiooni
uniqueConstraint = UniqueConstraints.singleton
Seetõttu sisaldab logi teateid vastava võtme luku võtmise ja vabastamise kohta. Teiste äriprotsesside jaoks selliseid sõnumeid pole: unikaalne piirang pole määratud.
Püsiva olekuga seotud äriprotsesside probleemid
Vahel püsiv seisund mitte ainult ei aita, vaid ka tõesti takistab arengut.
Probleemid algavad siis, kui on vaja teha muudatusi äriloogikas ja/või äriprotsessi mudelis. Ükski selline muudatus ei sobi kokku äriprotsesside vana olekuga. Kui andmebaasis on palju "reaalajas" eksemplare, võib kokkusobimatute muudatuste tegemine põhjustada palju probleeme, millega jBPM-i kasutamisel sageli kokku puutusime.
Olenevalt muutuse sügavusest saate tegutseda kahel viisil.
looge uus äriprotsessi tüüp, et mitte teha kokkusobimatuid muudatusi vanasse, ja kasutage seda uute eksemplaride käivitamisel vana asemel. Vanad eksemplarid jätkavad tööd "vanal viisil";
Esimene viis on lihtsam, kuid sellel on oma piirangud ja puudused, näiteks:
äriloogika dubleerimine paljudes äriprotsessimudelites, äriloogika mahu suurenemine;
sageli on vaja kohest üleminekut uuele äriloogikale (peaaegu alati integreerimisülesannete osas);
arendaja ei tea, mis hetkel on võimalik aegunud mudeleid kustutada.
Praktikas kasutame mõlemat lähenemisviisi, kuid oleme oma elu lihtsustamiseks teinud mitmeid otsuseid:
andmebaasis salvestatakse äriprotsessi püsiv olek kergesti loetaval ja hõlpsasti töödeldaval kujul: JSON-vormingus stringis. See võimaldab teil migreeruda nii rakenduse sees kui ka väljaspool. Äärmuslikel juhtudel saate seda ka käepidemetega näpistada (eriti kasulik arenduses silumise ajal);
integratsiooni äriloogika ei kasuta äriprotsesside nimetusi, nii et igal ajal on võimalik ühe osaleva protsessi juurutamine asendada uuega, uue nimega (näiteks "InitialPlayerV2"). Sidumine toimub sõnumite ja signaalide nimede kaudu;
protsessimudelil on versiooninumber, mida me suurendame, kui teeme selles mudelis mitteühilduvaid muudatusi, ja see number salvestatakse koos protsessi eksemplari olekuga;
protsessi püsiv olek loetakse esmalt baasist mugavasse objektmudelisse, millega saab migratsiooniprotseduur töötada, kui mudeli versiooninumber on muutunud;
migratsiooniprotseduur asetatakse äriloogika kõrvale ja seda nimetatakse iga äriprotsessi eksemplari jaoks "laisaks" selle andmebaasist taastamise ajal;
kui on vaja kiiresti ja sünkroonselt migreerida kõigi protsessieksemplaride olekut, kasutatakse klassikalisemaid andmebaasi migratsiooni lahendusi, kuid seal tuleb töötada JSON-iga.
Kas ma vajan äriprotsesside jaoks teist raamistikku?
Artiklis kirjeldatud lahendused võimaldasid meil oluliselt lihtsustada oma elu, laiendada rakenduste arenduse tasemel lahendatavate probleemide ringi ja muuta äriloogika mikroteenusteks eraldamise ideed atraktiivsemaks. Selleks on tehtud palju tööd, loodud väga “kerge” äriprotsesside raamistik, samuti teenusekomponendid tuvastatud probleemide lahendamiseks paljude rakenduslike ülesannete kontekstis. Meil on soov neid tulemusi jagada, tuua ühiskomponentide arendus vaba litsentsi alla avatud juurdepääsuks. See nõuab pingutust ja aega. Selliste lahenduste nõudluse mõistmine võiks olla meile lisastiimuliks. Kavandatavas artiklis pööratakse väga vähe tähelepanu raamistiku enda võimalustele, kuid mõned neist on esitatud näidete põhjal nähtavad. Kui me siiski oma raamistiku avaldame, pühendatakse sellele eraldi artikkel. Seniks oleme tänulikud, kui jätate veidi tagasisidet, vastates küsimusele:
Küsitluses saavad osaleda ainult registreerunud kasutajad. Logi sissepalun.
Kas ma vajan äriprotsesside jaoks teist raamistikku?
18,8%Jah, ma olen midagi sellist juba pikka aega otsinud.
12,5%huvitav on teie rakenduse kohta lisateavet, see võib olla kasulik2
6,2%kasutame ühte olemasolevatest raamistikest, kuid mõtleme selle väljavahetamisele1
18,8%kasutame üht olemasolevatest raamistikest, kõik sobib3
18,8%toimetulek ilma raamistikuta3
25,0%kirjuta oma 4
16 kasutajat hääletas. 7 kasutajat jäi erapooletuks.