Käytännön tilakoneita
Tilakoneet ovat yliopiston ratkaisu merkkijonojen käsittelyyn. Onko niistä jotain hyötyä myös oikeassa elämässä? Jos olet Helsingin Yliopistolla tietojenkäsittelytieteen laitoksella eksynyt “Laskennan mallit” -kurssille, tilakoneet aiheuttavat todennäköisesti joko pelkoa tai ihailua. “Lama” aiheuttaa monella inhoreaktioita, mutta kurssin sisältö on oikeasti hyödyllistä muuhunkin kuin tentin läpäisyyn.
Kahvilla käyvä ohjelmistokehittäjä
Mikä tilakone? Tilakoneet ovat prosesseja, jotka lukevat syötettä ja jokaisen syötemerkin kohdalla siirtyvät johonkin toiseen tilaan. Yleisin esimerkki, jota tilakoneiden hyödyistä käytetään, ovat säännölliset lausekkeet eli regexit. Niiden käsittely on tilakoneiden avulla tehokasta ja kohtalaisen yksinkertaista. Ei kuitenkaan kannata jäädä ajattelemaan, että merkki kerrallaan luettava teksti on ainoa asia, mitä tilakoneet pystyvät käsittelemään.
Kun ymmärtää, että tilasiirtymä voi olla yksinkertaisen merkin sijaan mitä tahansa, tilakoneet muuttuvatkin äkkiä paljon tehokkaammiksi työkaluiksi järjestelmien toiminnan mallintamisessa. Esimerkiksi ohjelmoijan päivärytmiä voidaan mallintaa tilakoneella. Kello kahden kahvitaukoa lukuunottamatta esimerkin koodari nakuttaa koodia näppis sauhuten, kunnes ominaisuus saadaan vietyä tuotantoon.
Tässä tilakoneessa on neljä tilaa; “Aktiivista koodausta”, “Kahvitauko”, “Deploy tuotantoon” sekä “Feature valmis”. Aktiivisesta koodauksesta voi siirtyä joko kahvitauolle jos järjestelmään tulee syöte “Kello 14:00”, tai sitten odottamaan deployn etenemistä testien mennessä läpi.
Kohti oikeaa elämää
Tilakoneet soveltuvat erityisen hyvin prosesseihin, jotka voivat olla useassa tilassa, joista on rajoitettu määrä siirtymiä toisiin tiloihin. Seuraavassa oikeamman maailman esimerkissä loppukäyttäjä tekee tilauksen järjestelmään, jossa asiakaspalvelu ensin vahvistaa tilauksen, ja joidenkin tuotteiden kohdalla vaatii asiakkaalta henkilöllisyyden vahvistamista. Jokainen tilaus pitää maksaa. Tilakoneen siirtymillä voidaan hallita monimutkaisempia rakenteita. Kuvasta näkyy jokaisen solmun kohdalta, mihin tiloihin solmusta voidaan siirtyä.
Tilakoneita voi myös eventtien sijaan mallintaa vaihtoehtoisina polkuina choose-your-adventure -tyyliin. Haluatko työnteon sijaan miettiä, miten saadaan Jira-tiketti oikeaan tilaan? Jira-workflowit ovat tällaisia tilakoneita, joissa tiketin siirtymiä on rajattu. Hyödyllisyydestä voidaan keskustella erikseen.
Kasa if-lauseita
Jos äskeisen tilausprosessin muuntaisi suoraviivaiseksi JavaScriptiksi ilman tilakonetta, seurauksena olisi varmaankin jotain seuraavan kaltaista:
function requiresPassport(order) {
return order.passport;
}
function orderAcceptable(order) {
return !!order;
}
const defaultState = {
orderDetails: false,
orderConfirmed: false,
orderPaid: false,
passport: false,
orderPosted: false,
};
function OrderStatus() {
const [state, setState] = useState(defaultState);
if (!state.orderDetails) {
return (
<button
onClick={() => {
const orderDetails = { passport: true };
if (requiresPassport(orderDetails)) {
setState({
...state,
orderDetails,
orderConfirmed: "requiresPassport",
});
} else if (orderAcceptable(orderDetails)) {
setState({ ...state, orderDetails, orderConfirmed: "yes" });
}
}}
>
Send order
</button>
);
} else if (state.orderConfirmed === "requiresPassport" && !state.passport && !state.orderPaid) {
return (
<div>
<button onClick={() => setState({ ...state, passport: true })}>Send passport</button>
<button onClick={() => setState({ ...state, orderPaid: true })}>Send payment</button>
</div>
);
} else if (state.orderConfirmed === "yes" && !state.orderPaid) {
return <button onClick={() => setState({ ...state, orderPaid: true })}>Send payment</button>;
} else if (!state.orderPosted) {
return <button onClick={() => setState({ orderPosted: true })}>Post order</button>;
} else {
return "Done!";
}
}
Koodissa on sekoitettu logiikkaa ja käyttöliittymää, mikä tekee komponentista vaikealukuisen. Koodiin on myös helppo piilottaa bugeja – yllä olevassa koodissa on (ainakin) yksi. Yksi mahdollinen ratkaisu olisi piilottaa logiikka esimerkiksi Reduxin avulla. Hyvänä puolena koodi on kuitenkin kohtalaisen yksinkertaista.
Koodi jättää myös tilaan liittyviä kysymyksiä auki. Mitä esimerkiksi
tarkoittaa, jos tilaus on jo lähtenyt postiin
(eli state.orderPosted === true
), mutta tilausta ei ole maksettu
(state.orderPaid !== true
)?
Tilakoneen rakentaminen
Miltä sitten näyttäisi vastaava tilakoneena? Kokeillaan xstate
-nimisellä
JavaScript/TypeScript-kirjastolla. Aloitetaan kirjoittamalla ylös tilat ja
tilasiirtymät. Yllä olevassa kaaviossa näkyy seuraavat tilat:
Begin
WaitingForConfirmation
WaitingForPassportAndPayment
WaitingForPassport
WaitingForPayment
WaitingForMail
Done
Aloitetaan yksinkertaisista tiloista, joissa vain odotetaan jotain tapahtumia
ja siirrytään seuraavaan tilaan. Näissä esimerkiksi WaitingForPassport
-tila
odottaa ReceivePassport
-tapahtumaa, jolloin siirrytään tilaan
WaitingForMail
.
import { createMachine, interpret, assign } from "xstate";
const WaitingForPassportAndPayment = {
on: {
ReceivePassport: "WaitingForPayment",
ReceivePayment: "WaitingForPassport",
},
};
const WaitingForPassport = {
on: { ReceivePassport: "WaitingForMail" },
};
const WaitingForPayment = {
on: { ReceivePayment: "WaitingForMail" },
};
const WaitingForMail = {
on: { MailOrder: "Done" },
};
const Done = { type: "final" };
Monimutkaisempia tilasiirtymiä on WaitingForConfirmation
- ja
Begin
-tiloissa. Ensimmäisessä pitää tehdä ehdollisia siirtymiä; riippuen
siitä, vaatiiko tilaus passikuvaa, siirrytään joko
WaitingForPassportAndPayment
- tai WaitingForPayment
-tilaan. Tilasiirtymien
cond
-avaimessa oleva funktio ottaa parametrina laitteen kontekstin, jossa
pitäisi olla käytettävissä order
-kenttä tilauksen tietoja varten.
const WaitingForConfirmation = {
always: [
{
target: "WaitingForPassportAndPayment",
cond: ({ order }) => requiresPassport(order),
},
{
target: "WaitingForPayment",
cond: ({ order }) => orderAcceptable(order),
},
{ target: "Begin" },
],
};
Begin
-tilassa talletetaan tilakoneen kontekstiin ReceiveOrder
-tapahtuman
mukana tuleva tilaus.
const Begin = {
context: {},
on: {
ReceiveOrder: {
target: "WaitingForConfirmation",
actions: assign({
order: (_context, ev) => ev.order,
}),
},
},
};
Nyt kaikki tilat on luotu, joten voimme muodostaa näistä tilakoneen.
const orderMachine = createMachine({
id: "Order handler",
initial: "Begin",
context: {},
states: {
Begin,
WaitingForConfirmation,
WaitingForPassportAndPayment,
WaitingForPassport,
WaitingForPayment,
WaitingForMail,
Done,
},
});
Voimme käyttää tilakonetta esimerkiksi Reactin kanssa. Koodissa on nyt erotettu logiikka ja käyttöliittymä omiksi paloikseen – muuten koodi näyttää pitkälti samalta kuin alkuperäinen toteutus.
import { useMachine } from "@xstate/react";
function OrderStatus() {
const [state, send] = useMachine(orderMachine);
switch (state.value) {
case "Begin":
return <button onClick={() => send({ type: "ReceiveOrder", order: { passport: true } })}>Send order</button>;
case "WaitingForConfirmation":
return "Order needs confirmation from customer service, please wait";
case "WaitingForPassportAndPayment":
return (
<div>
<button onClick={() => send("ReceivePassport")}>Send passport</button>
<button onClick={() => send("ReceivePayment")}>Send payment</button>
</div>
);
case "WaitingForPassport":
return <button onClick={() => send("ReceivePassport")}>Send passport</button>;
case "WaitingForPayment":
return <button onClick={() => send("ReceivePayment")}>Send payment</button>;
case "WaitingForMail":
return <button onClick={() => send("MailOrder")}>Post order</button>;
default:
return "Done!";
}
}
Testaaminen
Toisin kuin alkuperäistä ratkaisua, tilakoneen toimintaa voi myös testata ilman Reactia.
import { interpret } from "xstate";
it("Should wait for passport and payment when receiving an order", () => {
const actualState = fetchMachine.transition("Begin", {
type: "ReceiveOrder",
order: { passport: true },
});
expect(actualState.matches("WaitingForPassportAndPayment")).toBeTruthy();
});
it("Should reach Done on a proper sequence of inputs", (done) => {
const fetchService = interpret(fetchMachine).onTransition((state) => {
if (state.matches("Done")) {
done();
}
});
fetchService.start();
fetchService.send({ type: "ReceiveOrder", order: { passport: true } });
fetchService.send("ReceivePassport");
fetchService.send("ReceivePayment");
fetchService.send("MailOrder");
});
Yhteenveto
Matkan varrella rivimäärä tuplaantui “suoraviivaiseen” toteutukseen verrattuna. Lisäksi tilakoneen käyttäminen vaatii ymmärrystä sekä tilakoneen toiminnasta että kirjaston käytöstä. Takaisin saa toki hyötyjä - tilakone pakottaa prosessin toimimaan tilasiirtymien mukaisesti. Testaaminen helpottuu, sillä sovelluksen logiikan saa irrotettua omaksi kokonaisuudekseen.
Tilakoneet mallintavat tilaa ja tilasiirtymiä. Ne soveltuvat erityisen hyvin ongelmiin, joissa ongelman voi kirjoittaa uudelleen viestipohjaiseksi. Tilakoneet ei kuitenkaan ole ratkaisu kaikkeen, vaan niitä tulee ajatella vain yhtenä lukuisista työkaluista, joilla voidaan mallintaa järjestelmiä. Paras työkalu riippuu aina ongelmasta!