Dacă nu ai scris aplicații de la zero (ceea ce nu avem des ocazia), foarte probabil că ai observat un șablon: aproape fiecare proiect și companie are propria abstracție de logging (logare): un adaptor (wrapper) peste third-party-ul care face logarea sau chiar o implementare alternativă. De asemenea, dacă scrii o aplicație de la zero, te vei confrunta cu această decizie: să folosești direct interfața (API) furnizată de frameworkul de logare preferat sau să îl îmbraci în ceva ce ți s-ar potrivi mai bine în opinia ta?
Cam în toate firmele în care am lucrat, am observat că aveau deja un adaptor propriu de logare. Am început să mă întreb de ce această tendință. Ce avantaje avem dacă procedăm aşa? Merită efortul (costul)? Întrebare delicată, în condițiile în care majoritatea ne pricepem foarte bine să folosim un log4j, slf4j/logback sau commons-logging. Tendința de a le folosi direct este extrem de naturală. Probabil că mulți dintre noi am configurat în detaliu logback sau log4j, poate chiar am scris propriile log destinations (appenders), și am vrea să punem abilitățile dobândite în folosul proiectului (și al nostru, nu?). Dacă începi proiectul de la zero, întrebarea e cu atât mai importantă, pentru că o decizie nepotrivită te poate costa ulterior. Sigur, orice se poate repara, dar nu cred că îți dorești să primești plângeri de la clienți și să nu găsești nimic în logurile furnizate (pentru care uneori trebuie să duci lupte serioase) pe motiv că nu ai configurat bine logback, de exemplu. În plus, recent au apărut și variante care promovează renunțarea la loguri, de exemplu https://www.overops.com/. Un motiv în plus pentru a te decide pe ce rută vrei să mergi: loghezi folosind API-ul frameworkului preferat (sau impus deja de proiect); îți scrii propria abstracție de logare sau pur și simplu nu loghezi, folosind (și) alte mijloace de diagnosticare a problemelor?
Presupunând că nu dorești să renunți la loguri, voi încerca să răspund la întrebarea enunțată inițial: de ce ai scrie propria abstracție de logare. Sub nici o formă nu voi încerca să vă 'vând' un framework de logare în defavoarea altuia, deși personal eu sunt un mare fan logback. Aceasta poate fi o discuție separată, altădată.
Logarea este una dintre principalele metode de diagnosticare a problemelor ce pot apărea când sistemul rulează în producție. Ca atare e important să fie făcută bine, adică să îndeplinească minimum următoarele:
Să fie cât mai puțin invazivă: adică să fie banal să adaugi logging statements, și, cel mai important, să nu ai de recompilat sute (mii) de clase, când vrei să activezi logarea mai detaliată (DEBUG, TRACE).
Să fie cât mai configurabilă: în strânsă legătură cu dezideratul anterior, practic îți dorești să poți schimba ușor destinația mesajelor, să ai opțiuni privind ce ajunge pe consolă, să poți schimba nivelul de detaliu la runtime fără să dai restart la aplicație.
Să fie performantă: nu dorești să irosești cicluri de procesor pe calcule inutile pentru linii de log care apoi să fie filtrate (deci invizibile!) - subiect delicat ce se poate dezbate separat.
Să respecte ordinea mesajelor: într-un mediu concurent e important să vezi liniile de log în ordinea aproximativ corectă, nu amestecate aleatoriu, după cum a decis thread schedulerul întrucât ai high contention pe obiectele din biblioteca de logare (sau pe bufferele asociate fișierelor destinație).
Există și alte calități dezirabile la un framework de logare , dar cele enunțate mai sus ar reprezenta în opinia mea un minim necesar (nu neapărat și suficient).
Cât privește opțiunile, poți lua în considerare programarea orientată pe aspecte (AOP). Cam în orice tutorial, exemplul tipic de aspect este chiar logarea! Aceasta ar avea avantajul că nu trebuie să scrii nici o linie de cod dedicată logării, lăsând totul în seama frameworkului care îți va injecta (weave) aspectul în codul aplicației. Deși sună promițător, în practică nu este aşa de simplu. Trebuie în primul rând să știi ce metode vor reprezenta pointcuturile de interes pentru logare, lucru nu tocmai obișnuit în aplicații multi-layered sau care rulează în servere de aplicație. Apoi, ce ai putea să incluzi în liniile de log care să se potrivească pe cazul general? Dacă semnăturile nu respectă un pattern bine stabilit ești limitat la a loga valorile parametrilor. Sau, dacă ești ambițios, poți adăuga suport pentru logarea excepțiilor care apar (cu un joinpoint de tip after-throwing). Sigur, dacă faci aceasta doar la nivel de subsistem și toate clasele respectă un anumit șablon, poți obține o oarecare decuplare și codul în acel subsistem rămâne nepoluat cu instrucțiuni specifice logării. Dar trebuie să te ocupi de procesul de weave-ing, cu cele două opțiuni: compile sau runtime weaving. Dacă din diverse motive ești nevoit să recurgi la runtime weaving, apar alte complicații: de exemplu: interferența aspectelor în mediile de tip application server. Astfel că, deși sună promițător, a folosi AOP pentru logare nu este chiar banal; mai mult, eu nu știu nici o aplicație care să fi mers în direcția aceasta (deși este perfect posibil și poate fi un exercițiu foarte bun când înveți AOP).
Presupunând că nu folosești AOP, de ce să apelezi la o abstracție de logare proprie? Ce probleme ai rezolva mai bine decât frameworkul de logare preferat?
Aplicațiile (Java) moderne nu rulează standalone ci în diverse medii controlate, de tip container / application server. Ceea ce înseamnă, printre altele, că îți va fi furnizat un class loader de către container. Să ne amintim că majoritatea frameworkurilor de logare se bazează pe un bloc de inițializare static, adică pe o rutină de inițializare ce se execută când respectiva clasă core (Logger, LogManager, …) este încărcată. Când se încarcă acea clasă? De obicei, la execuția unui bloc de cod care face referință la ea. De exemplu: când este întâlnită prima declarație de obiect Logger în aplicație (în alt bloc static, de data aceasta în clasele tale). Apoi, în mediile de tip application server class loaderele sunt ierarhice și e foarte posibil ca în ierarhie să existe mai mult de o versiune a claselor ce compun frameworkul de logare. În funcție de ce class loader ajunge să definească respectiva clasă (the defining class loader, în termeni specifici) poți avea surpriza apariției unei configurații neașteptate. Adică este important de subliniat că procesul de inițializare nu este 100% sub controlul tău, ca application developer. Abordarea bazată pe blocuri de cod statice are un mic avantaj: mașina virtuală (JVM) îți garantează execuția blocurilor de inițializare statice înainte de a te lăsa să instanțiezi clasa respectivă; prin urmare, în momentul când chiar ajungi să loghezi poți fi sigur că blocurile de inițializare ale claselor cheie din framework s-au executat cu succes. (Dacă ar cauza erori, aplicația nu ar funcționa, întrucât nu ai avea clasele necesare definite.) Dar ceea ce nu ți se poate garanta este ce class loader va încărca respectivele clase și de asemenea ce resurse (fișiere de configurare) vor fi disponibile în ierarhia acelui class loader.
Dacă ai scrie un adaptor pentru logare, ai putea să implementezi tu inițializarea frameworkului de logare folosit într-o manieră mai predictibilă. Ai putea să faci:
Să scrii cod care setează system properties cerute de frameworkul de logare la valori care îți convin ție. De exemplu, să furnizezi propria clasă care inițializează JDK logging și să setezi o proprietate specifică la numele complet calificat al clasei scrise de tine.
Să împachetezi configurații predefinite și să instruiești frameworkul de logare să le folosească. Cu cât ești mai specific, cu atât mai bine. De exemplu, în loc să lași log4j să caute în classpath o resursă cu numele 'log4j.xml', ai putea să-i zici 'configurează-te din fișierul my-logging-adapter-log4j.xml' și să furnizezi un astfel de fișier, cu defaults, ca resursă în JAR-ul pe care îl produci și distribui.
Să forțezi clasele cheie din frameworkul de logare să se încarce când îți convine ție. (Class.forName(), referințe directe la acele clase în inițializarea adaptorului)
Să împachetezi o clasă cu numele specific căutat de framework (de exemplu, slf4j caută să încarce o clasa specifică de log 'binding' și dacă nu o găsește, te informează ca atare). În biblioteca în care implementezi propria abstracție de logare, ai putea să furnizezi tu acea clasă și apoi să ai grijă ca alte module (Java 9+) sau JAR files (java până la versiunea 8) nu o conțin. (Specific pe acest exemplu: nu incluzi logback-classic.jar în classpath.)
E foarte important ca inițializările necesare să se întâmple la momentul potrivit, pentru a nu avea surprize. Un caz particular ar fi mediile OSGI sau în general, containerele. Poți să furnizezi, pentru compatibilitate, și o componentă (în termeni OSGI) sau conector (JCA) care să inițializeze framework-ul de logare înainte de a fi utilizat.
Frameworkurile de logare moderne (log4j 2, logback, ...) sunt rezultatul unui continuu proces de rafinare și perfecționare, astfel încât e puțin probabil să vrei să scrii un framework alternativ. Este destul de improbabil să vrei să renunți la un framework cu care te-ai obișnuit și să îl înlocuiești pur și simplu. Dar cum aceste frameworkuri evoluează și își îmbunătățesc performanțele de la release la release, poți ajunge în punctul în care o schimbare să fie necesară în beneficiul proiectului. Dacă este să alegi dintre opțiuni, ce preferi: să modifici integrarea cu frameworkul în abstracția ta de logare, probabil izolată într-o bibliotecă in-house și în proiect doar să incluzi noua versiune a bibliotecii (ideal fără nici o modificare în cod!) sau să modifici linii de logging calls și inițializarea frameworkului spre care migrezi? Sigur că și varianta a doua poate fi parțial automatizată la nevoie, dar eu unul aș alege prima variantă.
Dacă ai scris propria abstracție de logare, poți expune abilități de monitorizare (JMX de exemplu) care să nu fie specifice unui framework de logare sau altul, ci adaptate peste nevoile tale (ale companiei). În esență, exact unul dintre beneficiile unui adaptor (sau fațade). Implementarea acestor operații (JMX calls, web services, ...) va fi localizată în biblioteca pe care o scrii, independent de codul aplicației.
De asemenea, poți să implementezi abilități de reconfigurare dinamică, independente, la nivel de API, de specificitățile frameworkului de logare ales. În implementare vei avea grijă să faci conversiile necesare între adaptorul tău și frameworkul target. Orice modificare în frameworkul respectiv (inclusiv upgrades) sau migrarea la alt framework va fi îngrădită doar la biblioteca pe care o scrii, fără să afecteze codul aplicațiilor care o folosesc. Este important să ai grijă să expui doar API-ul tău de monitorizare, preferabil dezactivându-le pe cele oferite în plus de către framework ( de exemplu, logback are suport pentru JMX). Expunând în paralel și operațiile logback și pe acelea scrise de tine, poți cauza confuzie și chiar probleme.
În enterprise-uri de dimensiuni medii și mari (și nu numai) logurile sunt centralizate și păstrate pe servere specializate, fiind șterse după un timp, de pe mașinile unde sunt generate (prin procesul de logshipping). Pentru a ușura procesul de centralizare și investigațiile bazate pe loguri e preferabil să ai configurații unitare, cel puțin în privința următoarelor aspecte:
Politica de rotație a logurilor. (De obicei, zilnic la miezul nopții, dar poți modifica acest detaliu.)
Identificarea precisă a sursei logurilor. (IP-ul / hostname-ul mașinii, aplicația, environmentul, eventual și alte informații.)
Dimensiuni maxime per fișier de log; procent maxim de ocupare a discului eventual.
Posibilitatea de a reduce (dinamic, de preferință) nivelul de detaliu, dacă ai depășit un anumit prag cu spațiul ocupat pe disc.
Acestea fiind zise, ajută mult dacă împachetezi în abstracția ta de logare o configurație default (sau mai multe) pe care să le pasezi automat către framework când se inițializează. Vei face utilizatorii foarte fericiți, pentru că au asigurată corectitudinea formatului și a configurațiilor predefinite în raport cu standardele companiei și cerințele toolurilor utilizate pentru procesarea logurilor. Un use case: folosești stiva ELK (ElasticSearch, LogStash, Kibana) pentru a analiza logurile. Dacă toate liniile de log respectă același format, va fi ușor și să îl ai preconfigurat în soluția ELK aleasă. În plus, această configurare va fi centralizată și nu vei lăsa utilizatorilor luxul de a greși și de a nu respecta formatul.
Un caz special: redirectarea standard output streams. Oricâtă grijă ai avea, nu ai control 100% peste ce fac bibliotecile third party utilizate. Așadar e posibil ca să apară, de te miri unde, să ai linii care ajung pe consolă (fie calluri directe System.out fie ascunse bine într-un log4j / logback ConsoleAppender, să zicem). Dacă ai făcut deploy pe Amazon sau pe o mașină unde nu ai consola standard, aceste mesaje se vor pierde, cel mai probabil. Așadar, ar fi preferabil ca și ele să fie standardizate, adică supuse acelorași reguli (rotație, restricții de dimensiuni, format unificat pe cât posibil). Ai putea în abstracția de logare pe care o scrii să implementezi redirectarea output streams către fișiere al căror nume și locație să fie configurabile, la fel ca pentru restul logurilor.
Când scrii unit teste vei exercita (vrei nu vrei) și cod care loghează. Cum majoritatea testelor nu au ca scop testarea logurilor, ai vrea să ai opțiunea să dezactivezi logarea în teste (sau alte configurații). Sigur, poți utiliza mocking, dar va trebui să fii atent la metodele statice sau finale - poate să devină complicat. Nu ar fi mai simplu dacă în adaptorul tău, ai oferi posibilitatea de a substitui să zicem un Log Factory care dezactivează complet logurile, sau le redirectează pur și simplu către consolă (care e salvată de Jenkins sau alte tooluri de continuous integration)? Şi apoi în Setup-ul suitei de teste să scrii maxim o linie care instruiește adaptorul de logare să folosească acest stub? Atunci nu mai trebuie sa te ocupi de convențiile fiecărui framework de logare specific ( de exemplu, în logback poți crea un fișier logback-test.xml în src/test/resources și el va avea prioritate, fiind citit prima dată de către logback).
Subiectele atinse aici merită probabil tratate separat în câte un articol. Din păcate, aici nu avem spațiu să abordăm aspectele tehnice aferente fiecărui subiect (class loading, execuție privilegiată, redirectare output streams, logshipping, correlation ids, repackaging, …).
Am vrut în esență doar să arăt că merita să te gândești la acest aspect (adaptor de logare propriu) când începi un proiect, chiar dacă apoi alegi din diverse motive varianta mai simplă - folosirea directă a unui framework de logging. Aș vrea să reiau avantajele enunțate mai sus: controlul mai bun asupra inițializării, configurații standardizate, monitorizare unificată, adaptabilitate la schimbări ulterioare. Ce e important (și din păcate, am observat că asta se încalcă cel mai adesea) e să alegi o abordare sau alta și să o folosești consecvent: degeaba ai un wrapper sofisticat, dacă apoi folosești în paralel și logback sau log4j (sau uneori mai multe frameworkuri de logare în paralel). Revii practic la problema inițială, lipsa de control asupra inițializării, numai că acum ai de administrat un utilitar în plus. Prin urmare, una sau alta: wrapperul tău sau un third-party direct. Nu ambele.
de Paul Resiga
de Mihai Babici
de Mircea Vădan