Convenzioni

In questo documento verranno elencate tutte le convenzioni e le linee guida di sviluppo software che dovrebbero essere sempre seguite per garantire un ecosistema vivibile, un certo livello di esercibilità delle applicazioni, e anche per permettere a qualsiasi sviluppatore che debba lavorare, dopo di noi, sul nostro software, di non dover fare reverse engineering o cercare di capire, per deduzione, le motivazioni delle nostre scelte architetturali.

Logging

Un buon logging deve garantire la perfetta comprensione di cosa sta accadendo all’interno dell’applicazione. Il formato del log, per essere accettato, deve essere come segue:

[Timestamp] [Severity] [Messaggio]

Prenderemo ora in considerazione i diversi campi e gli strumenti a disposizione per loggare.

Strumenti

E’ buona norma utilizzare degli strumenti standard per il logging. In Monksoftware vengono utilizzati diversi linguaggi, e per alcuni di essi sono state sviluppate delle soluzioni proprio per gestire il logging in maniera consistente.

  • Per Java, utilizzare Log4j. Più in basso mostriamo un esempio di configurazione possibile per log4j.properties

  • Per Python? Work in progress

  • Per Ruby? Work in progress

  • Per PHP? Work in progress

  • Per NodeJS esiste MonkLog. Per aggiungerlo al proprio software usare il comando:

npm i @monksoftware/monlog

oppure

yarn add @monksoftware/monlog

Formato

Il formato, come detto sopra, dovrebbe includere il timestamp, la severity, ed un messaggio. Tutto il log afferente ad un evento deve essere contenuto in una sola riga, per quanto lunga possa essere. Il timestamp dovrà avere il seguente formato:

YYYY-MM-DD HH24:mi:ss.SSSZ

Dove i millisecondi sono facoltativi (SSS), ma la timezone (Z) è obbligatoria. Attenzione: non forzare assolutamente la timezone, ma utilizzare quella del server. Lo sviluppatore non dovrebbe mai preoccuparsi di dove giri un determinato software. Praticamente tutte le librerie di logging esistenti inseriscono il timestamp per conto loro, utilizzando la corretta timezone, quindi spesso e volentieri usando semplicemente una direttiva come log.warning(“XXXXX”) si ottiene il risultato corretto e non bisogna preoccuparsi di altro.

Severity

La severity dei log identifica la tipologia di messaggio che si sta scrivendo. Nel log applicativo, a seconda dell’impostazione di severity in configurazione, verranno mostrati solo i messaggi che hanno almeno la severity indicata. Ad esempio: con log level INFO verranno loggati i messaggi INFO, NOTICE, WARNING, ERROR, CRITICAL. La nomenclatura standard segue questo elenco di severity, dal meno “grave” al più “grave”:

  • TRACE: Log di bassissimo livello, possono comprendere anche dei console.log con l’intero oggetto dumpato. Si consiglia di racchiudere questo livello in una direttiva che controlla se il debug sia abilitato (in java, ad esempio: logger.isDebugEnabled()), in modo da non scrivere troppo sul canale

  • DEBUG: Log di basso livello, comprende informazioni di debug complete (esempio: dump completo del challenge response HTTP, con header, etc). Vale lo stesso discorso di cui sopra

  • INFO: Questo è il log level generalmente usato in produzione. E’ importante quindi che in questa severity vengano scritti tutti i messaggi che permettano di verificare che l’applicazione stia funzionando correttamente, senza entrare troppo nel dettaglio. Un esempio potrebbe riguardare quando un utente si sia loggato e quando si sia sloggato, se abbia cambiato qualche impostazione (quindi operazioni dispositive), e se sia stato contattato qualche sistema esterno: con quali parametri e se la risposta sia soddisfacente/la transazione sia andata a buon fine. Vedi comunque sotto per i messaggi

  • NOTICE: Qui, generalmente, si scrivono messaggi che hanno una maggiore importanza, non errori, ma che riguardano attività delle quali è fondamentale sapere l’andamento, come ad esempio lo svecchiamento di qualche tabella, oppure un job programmato che ha girato

  • WARNING: Qui bisogna loggare problematiche che non inficiano il corretto funzionamento dell’applicazione, ma che potrebbero creare problemi a lungo andare, oppure configurazioni mancanti per le quali si è preso il default (che potrebbe quindi non andare bene), e così via

  • ERROR: Qui si loggano tutti gli errori che comportano un malfunzionamento applicativo

  • CRITICAL: Generalmente a questo livello si loggano gli errori che creano un totale disservizio

Messaggi

I messaggi di log sono molto importanti, perchè permettono di capire cosa sta succedendo nella nostra applicazione. Se l’applicazione deve essere gestita da operation o dai sysadmin, e arriva una segnalazione, è importante che nel log level INFO sia possibile in qualsiasi momento risalire a tutti i dettagli di un intero ciclo di vita di una richiesta alla nostra applicazione. Si richiede il log in lingua inglese e non in italiano.

I dettagli potrebbero includere: un thread-id, o un client-id univoco (come ad esempio un indirizzo IP, o il campo X-Forwarded-Ip per le richieste HTTP girate dai frontend NGINX (vedi architettura MonkSoftware)) che possa univocamente identificare tutte le richieste che arrivano per un dato client. In questo modo, effettuando un filtro con quel client-id, sarà possibile leggere tutti i dettagli relativi alle attività di quel dato client-id. Il messaggio NON deve contenere, generalmente (fanno eccezioni log TRACE) il dump dell’oggetto brutale, per evitare di saturare il filesystem nel caso di alto traffico: ma deve avere tutti i dettagli necessari per capire cosa sia successo.

Alcuni esempi validi di tipo INFO:

  • “[client-id] successful login for username <user1>”

  • “[client-id] contacted WIND registration service with parameters: <par1=value1>, <par2=value2>, elapsed 0.5s

Non abbiamo bisogno di loggare quale sia l’indirizzo del WIND registration service, perchè, magari, lo abbiamo loggato a livello NOTICE al momento dello startup dell’applicazione e possiamo recuperarlo così, o leggendo la configurazione. E’ molto utile, nel caso in cui vengano contattati sistemi esterni, loggare il tempo necessario per ricevere la risposta.

Alcuni esempi validi di tipo NOTICE:

  • “Old table <xxx-yyy> moved to archive in 52s”

  • “Service throttled because we received more than 50req/s”

Alcuni esempi di tipo ERROR:

  • “[client-thread] when performing registration for user <user1>, couldn’t contact WIND server, error: <ExceptionMessage, oppure un errore applicativo parlante>, elapsed: 0.1s”

Mettetevi sempre nei panni di voi stessi fra un anno, e cercate di capire se, leggendo i log della vostra applicazione in modalità INFO o superiore, se sia possibile per voi comprendere appieno cosa stia succedendo: alzate o abbassate il livello di logging per capire se, tramite le informazioni loggate, sia possibile capire al 100% cosa non sia andato a buon fine.

E infine tenete sempre conto del fatto che non c’è mai “troppo logging”, a patto che i messaggi siano sempre correttamente classificati nella severity adeguata.

Commenti

Anche qui vale il discorso del “da qui ad un anno”. Pensate a voi stessi tra un anno, leggendo il frammento di codice che avete scritto. E’ comprensibile perchè usate delle direttive parlanti del tipo isServiceAvailableToUser == true ? Allora, non è necessario scrivere sopra “// Here we check if the service is available to the user”.

Ma se fate cinque o sei chiamate in cascata, l’una dipendente dall’altra, è utile scrivere quali siano le dipendenze e cosa comporti il fallimento di una o più delle chiamate. Oppure, se le chiamate non sono parlanti, riassumere cosa stiamo per fare prima della sequenza delle chiamate, ad esempio:

/*
Now we will register the user on
the WIND documentation service, then we'll
refresh the remote cache: finally, we will
insert or update the user data on the database
*/

Infine, se siano state fatte delle scelte architetturali a prima vista non chiare, o addirittura “scellerate”, è fondamentale descriverle appieno, con parole semplici, in modo che sia chiaro perchè siano state prese certe decisioni. Ad esempio:

/*
Here we have to use this function because
the client didn't provide us enough documentation
and we're alone, left in the dark
*/

Anche qui: non ci sono mai troppi commenti, tranne quelli ridondanti. Pensate che se una cosa potrebbe essere non comprensibile al 100% per voi, per un’altra persona che si occuperà del vostro codice quando non ci sarete sarà Sanscrito antico. E questo vale anche per gli altri nei vostri confronti.

Sad Path

L’happy path come via dello sviluppo può andare bene in una fase di POC o di testing interno: ma perchè l’applicazione che stiamo scrivendo abbia una qualità production-grade, è fondamentale utilizzare un approccio al coding completamente pessimista. Pensate, sempre, che non funzioni NULLA. E ricordate che il momento in cui pensate: “questo non andrà mai storto” oppure “è talmente banale che non può rompersi”, vorrà dire che sarà proprio quel pezzo a rompersi per primo! Quando contattate un sistema esterno, pensate innanzitutto a tutte le cose che possono andare male, gestitele TUTTE, dalla prima all’ultima (anche accorpandole in un’unica gestione, se non abbia senso differenziarle), e solo alla fine gestite il caso positivo. Ad esempio, utilizzando metacodice:

if server-unavailable

else if server-timeout

else if database-duplicate-key

else if other-problem

else
   positive-case

Ed è anche importante gestire il rollback tra le chiamate dipendenti tra loro. Ad esempio, se dovete fare cinque o sei invocazioni di sistemi esterni, che creano una consistenza relativamente ad un qualche dato, e la terza o la quarta invocazione fallisce, dovete sempre prevedere un modo per annullare le prime due o tre invocazioni che sono andate a buon fine. Nel caso in cui non sia possibile perchè manca l’API per farlo, o non sia previsto dai sistemi esterni, loggate con ERROR o CRITICAL il problema!

Build targets

Se possibile, utilizzate sempre i build target della vostra IDE. Se dovete effettuare build di una app, ad esempio, con diversi puntamenti, e per cambiarli dovete modificare a mano gli indirizzi, createvi delle direttive di configurazione come config.production e config.preproduction, contenenti tutti i parametri locali a quell’ambiente, collegateli ad un build target della vostra IDE in modo che basterà cliccare su BUILD PRODUCTION o BUILD PREPRODUCTION per caricare i puntamenti corretti e non avere alcun problema quando inviate la vostra applicazione allo store.

Automation

Automatizzate qualsiasi attività ripetitiva con uno script! Se dovete eseguire un comando per più di due volte, anche solo, ad esempio, per svuotare il vostro database da tutti i dati rapidamente, scrivete uno script che ve lo faccia automaticamente.

Database

Grazie agli ORM, è possibile astrarre il concetto di database dalle nostre invocazioni, utilizzando delle direttive come findOne, findAll, etc. Questo permette di non preoccuparsi della tipologia di database che si sta utilizzando, non dovendo scrivere una riga di codice SQL. Tuttavia è importante ricordare che:

  • Qualsiasi WHERE che mettiamo nella nostra invocazione comporta sempre una condizione che viene inviata sul database

  • Nel caso in cui la tabella su cui facciamo invocazioni cresca di numerosità e sia più grande di 1000 righe, la performance delle nostre invocazioni peggiorerà all’aumentare delle righe

Questo accade perchè, nel caso in cui dovessimo cercare una parola in un libro senza un indice, dovremmo scorrere ogni volta il libro nella sua interezza per trovarla. Nel caso in cui il libro sia composto da due pagine, l’operazione sarebbe abbastanza veloce, ma se fosse composto da 1000 e più pagine, potrebbe impiegare dei minuti o delle ore. In un’applicazione di produzione questo non è accettabile per cui:

  • Per ogni where condition che viene effettuata, creare un indice sul database!

  • Se vengono fatte più where condition, e sono sempre le stesse, creare un indice composito su tutte le colonne! (Ad esempio: (colonna1, colonna2))

  • Se prevedete di fare, ad esempio, una, due, o tre where condition, e la presenza è sempre la stessa, ad esempio: colonna1=1 and colonna2=2 and colonna3=3, oppure colonna1=1 and colonna2=2 oppure colonna1=1 ma NON colonna3=colonna3 da sola, potete creare un indice composito nello stesso ordine di dipendenza (nel caso sopra: (colonna1, colonna2, colonna3)). Nel caso in cui faceste interrogazioni utilizzando solo la colonna3 o la colonna2, dovrete creare un indice dedicato che contenga solo quella. Questo permette di risparmiare spazio.

Attenzione, gli indici possono diventare un’arma a doppio taglio: nel caso in cui ce ne fossero troppi, questo potrebbe rallentare le INSERT e le UPDATE, dato che occorre aggiornare tutti gli indici ogni volta che viene modificato un campo indicizzato.

Transazioni

Utilizzate le transazioni, se possibile! Se, ad esempio, estraete informazioni da una coda a blocchi di 1000, o 2000, e la scrivete su database, non fate 2000 INSERT! Aprite una transazione in scrittura (utilizzate le direttive dell’ORM), e effettuate la COMMIT solo alla fine. In questo modo minimizzerete i tempi di scrittura, in quanto il disk I/O verrà coinvolto soltanto al termine di tutte le operazioni. Non esagerate, però, dato che il numero di operazioni utilizzabili in una transazione è dipendente dalla memoria che il sysadmin ha allocato per ogni sessione. Se avete qualche dubbio, chiedete. Tenete anche conto del fatto che, generalmente, se una transazione composta da 1000 righe fallisce alla riga 530, tutte le restanti 470 righe verranno ignorate. In questo caso è importante: fare COMMIT o ROLLBACK (e rispettivamente le restanti 530 righe verranno scritte su disco o annullate) e poi gestire il caso specifico.

Se utilizzate Postgres-XL, state contattandolo tramite PgBouncer, che si preoccupa, già di suo, di gestire il pooling verso il database. Potete quindi aprire quante connessioni volete (ne supporta veramente tante), ma tenete conto del fatto che il “tubo” che va da PgBouncer al database ha generalmente un numero massimo di sessioni pari a 10-20. Tenete quindi le sessioni parallele al minimo, accorpate le operazioni tra di loro e ricordate che l’operazione più onerosa per un database, dopo la scrittura su disco, è l’instaurazione della sessione utente.

Migration

Utilizzate le migration e testatele in ambiente di sviluppo! Tramite le migration del vostro ORM il vostro database rimarrà sempre in uno stato consistente: per fare ciò, però, è importante controllare che sia la fase “on” della migration, sia la fase “off”, facciano quello che ci si aspetta. Testate correttamente l’annullamento di tutte le migration e il ripristino, ogni volta che ne aggiungete di nuove, in modo da essere certi che il db rimanga coerente. Le migration in ambiente di produzione/preproduzione non saranno permesse, in quanto l’utente applicativo non potrà più modificare la struttura del database, ma verranno comunque eseguite con un utente privilegiato. E’ importante quindi segnalare al sysadmin/operation, nel momento in cui venga fatto un deploy, se ci siano delle migration da eseguire, indicando come eseguirle.

Testing e documentazione

E’ fondamentale scrivere gli unit test della vostra applicazione MENTRE la state scrivendo. Nel caso in cui stiate aggiungendo una feature che comporta più chiamate, provate ad utilizzare gli unit test per verificare che i casi di errore vengano tutti correttamente indirizzati (come quelli elencati nel paragrafo Sad Path), utilizzando quindi dei mockup di risposta che simulino l’errore. Ci si aspetta che la vostra applicazione in produzione gestisca tutti i casi di errore: non è sempre necessario fare gli unit test per tutte le situazioni che si presentano, nel caso in cui siate sicuri che eventuali modifiche del vostro codice non possano mai impattare quel punto specifico dell’applicazione - ma nel dubbio, se avete tempo, gli unit test permettono di alzare la qualità applicativa di vari ordini di grandezza, quindi non ce ne sono mai troppo pochi.

Al termine della scrittura della vostra applicazione, lo sviluppo non è completo. Un’applicazione senza documentazione non è terminata: un’applicazione non testata end-to-end non è esercibile e non verrà messa in produzione. I test end-to-end devono essere pensati ad altissimo livello per macro funzionalità, ad esempio “Registrazione utente” o “Segnalazione problemi” o “Scrittura di un nuovo Post”, non entrano nel merito delle funzionalità di basso livello, e possono essere scritti anche dal Marketing (anzi, sarebbe incoraggiato). Un test end-to-end può essere svolto ad ogni nuova release del software per verificare che non siano state introdotte regression con delle modifiche successive! E’ importante avere la completa certezza che tutte le feature che compongono la nostra nuova versione non abbiano introdotto dei breaking changes non voluti. Questo modo di pensare è fondamentale quando si producono applicazioni che vadano oltre un livello di complessità medio-basso, e risparmia moltissimo stress sia al sistemista/operation sia allo sviluppatore, che a quel punto ha la certezza che, una volta deployata l’applicazione in produzione, funzionerà tutto perfettamente: chiaramente qualcosa può sfuggire, ma a quel punto basta aggiungere lo unit-test o il test e2e alla test list, in modo che alla successiva release venga tutto indirizzato correttamente. Questo permette di aumentare la maturità della nostra applicazione mano a mano che la sviluppiamo, e rende anche un piacere il metterci le mani sopra, senza dover ogni volta reinventare la ruota o affidarsi al caso. E aiuta soprattutto nuovi sviluppatori che mettano le mani sul nostro codice se noi non ci siamo, o se abbiamo cambiato azienda: si tratta di rispetto reciproco.

Ricordate sempre che il debug di un’applicazione in produzione spesso è complicatissimo e a volte addirittura impossibile, dovuto alle richieste degli utenti in contemporanea a quelle che dobbiamo verificare, oppure alla lentezza del dover ogni volta coinvolgere operation/sysadmin per capire cosa stia succedendo, o anche al livello del logging non sufficiente per capire appieno il problema (in produzione, come detto, usiamo INFO, ma magari potremmo dover avere bisogno di mettere il log in DEBUG per capirci qualcosa di più, saturando il filesystem in pochissimo tempo per colpa del traffico degli altri utenti).

Il tempo che quindi “perdiamo” a scrivere test e documentazione, magari anche andando lunghi di uno o due giorni rispetto alla deadline, lo guadagnamo di sicuro in seguito, almeno dieci volte tanto!

Infine, nel caso in cui la nostra applicazione abbia un alto traffico potenziale, è importante farsi comunicare il target, e coinvolgere il sysadmin di turno che dovrà produrre una suite per fare dei test di carico che verranno svolti in ambiente di preproduzione. Un’applicazione che funzioni per 2-3 utenti contemporaneamente, o che magari avete testato solo con il vostro telefono, potrebbe avere seri problemi con 10 utenti contemporaneamente: e se magari è stata progettata per milioni di utenti contemporanei, è fondamentale simularne il traffico, sia preparando una base dati adeguata, con milioni di righe, sia fornendo gli endpoint al sysadmin per creare correttamente la suite di test di carico. Questo permetterà ad OGNI rilascio di verificare che tutto continui a funzionare correttamente. All’aumentare della complessità applicativa, è un’attività fondamentale da svolgere.

Se trovate utile l’avere delle credenziali di test pre-inserite sugli ambienti di preproduzione/sviluppo, comunicatelo al sysadmin, e aggiungetele alla vostra documentazione/README su gitlab. Infine, se risolvete un problema difficile, da soli, o con l’aiuto di qualcuno, documentate il problema e la sua soluzione. All’aumentare di questo tipo di segnalazioni potremmo decidere di installare un wiki aziendale.

Allarmi

Tutte le applicazioni che finiscono in produzione sono allarmate. Gli allarmi vengono implementati di concerto con il sysadmin: tuttavia questi non può sapere come si possa verificare il corretto funzionamento della vostra applicazione, e quindi voi, in primis, dovreste pensare a quali siano le parti critiche del software che avete scritto, e come controllarlo. Esempi utili possono essere:

  • Se usate delle code, mettete a disposizione degli endpoint che permettono di verificare quanti job sono in coda, quanti sono falliti, etc, e definite insieme al sysadmin delle soglie “di pericolo”

  • Se si tratta di un sito web, fornite delle credenziali di test che possono essere utilizzate per loggarsi sul sito e verificare che funzioni

  • Se il vostro sito effettua poche operazioni che comportano tanti step su sistemi di backend, fornite degli endpoint che, se invocati con credenziali di test, effettuano tutte le chiamate ai sistemi di backend in sequenza, verificando che tutto stia funzionando correttamente. In caso contrario, rispondete con HTTP 500 o errori simili

  • Qualsiasi controllo che sia possibile automatizzare può potenzialmente diventare un punto di allarme, e contribuisce quindi all’esercibilità della vostra applicazione. Non deve essere il cliente a rendersi conto che il nostro software non funziona, ma noi.

Conclusioni

Considerate sempre il vostro software come terminato solo quando corredato almeno da:

  • Degli unit test

  • Una test list e2e (scritta magari con l’aiuto del MKTG che identifica le macro aree)

  • Un buon logging

  • Degli ottimi commenti comprensibili

  • Un versioning ben gestito e indirizzato

  • Un manuale di gestione che contiene magari anche delle procedure standard da eseguire (ad esempio: per svecchiare il database fare così, per cancellare un utente fare così), in modo che non vi si debba chiamare alle 11 di sera per avere supporto perchè la vostra applicazione non funzioni, o perchè magari non si sappia come intervenire

Happy coding!