Käytännön tilakoneita

02.09.2021

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.

Tilakone yksittäisen ominaisuuden toteuttamisesta. Bytecraft Oy ei ota
vastuuta, jos tämän kuvan noudattaminen johtaa allnightereihin.
Tilakone yksittäisen ominaisuuden toteuttamisesta. Bytecraft Oy ei ota vastuuta, jos tämän kuvan noudattaminen johtaa allnightereihin.

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

Tilakone monimutkaisemmasta tilausprosessista.
Tilakone monimutkaisemmasta tilausprosessista.

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!