Hlavní navigace

Události v aplikaci - III

4. 1. 2022 0:00 Jiří Raška

Tento článek navazuje na předchozí příspěvek Události v aplikaci – II.

Auditní záznam do SQL databáze

Třída:

JPAEventListener

Profil:

listener-jpa

V této kapitole se podívám na řešení, na které jsem se odkazoval již v úvodu této série článků, a sice na auditní záznamy o běhu aplikace.

V podstatě se jedná o perzistentní uložení vybraných údajů o událostech vzniklých za běhu aplikace.

Takže co k tomu budu potřebovat?

  1. Vydefinovat si události a údaje, které o nich budu sbírat

  2. Připravit si perzistentní úložiště, což bude v tomto případě nějaký typ SQL databáze

  3. Implementovat reakci na vybrané události

Vzhledem k tomu, že po perzistentním úložišti nebudu požadovat žádné zvláštní služby, použil jsem pro přístup k SQL databázi podporu JPA integrovanou rovnou do framework.

Auditní záznamy

Pro ukázku mně bude postačovat, pokud budu reagovat na událost poskytovatele služeb o úspěšně provedené službě.

Nejdříve potřebuji navrhnout datové struktury, které mně budou reprezentovat události. Jejich implementace je v package entity/audit.

Opět mám připraveného jednoho společného předka pro všechny typy auditních záznamů, AuditRecord:

@Entity
public class AuditRecord {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false)
    private UUID oid;

    @Column(nullable = false)
    private String event;

    @Column
    private URI tid;

    @Column(nullable = false)
    private Date ts;

    public AuditRecord() {  }
    public AuditRecord(String event, URI tid) {
        this.oid = UUID.randomUUID();
        this.event = event;
        this.tid = tid;
        this.ts = new Date();
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public UUID getOid() { return oid; }
    public void setOid(UUID oid) { this.oid = oid; }
    public String getEvent() { return event; }
    public void setEvent(String event) { this.event = event; }
    public URI getTid() { return tid; }
    public void setTid(URI tid) { this.tid = tid; }
    public Date getTs() { return ts; }
    public void setTs(Date ts) { this.ts = ts; }
}

Jsou zde definovány všechny údaje, které bodu sledovat pro každý auditní záznam.

No a pak zde mám ještě jednu specifickou třídu pouze pro typ události poskytovatele, ProviderAuditRecord:

@Entity
public class ProviderAuditRecord extends AuditRecord {

    @Column
    private String requestFrom;

    @Column
    private String providerName;

    @Column
    private ResponseCodeType code;

    public ProviderAuditRecord() { super(); }
    public ProviderAuditRecord(String event, URI tid) { super(event, tid); }

    public String getRequestFrom() { return requestFrom; }
    public void setRequestFrom(String requestFrom) { this.requestFrom = requestFrom; }
    public String getProviderName() { return providerName; }
    public void setProviderName(String providerName) { this.providerName = providerName; }
    public ResponseCodeType getCode() { return code; }
    public void setCode(ResponseCodeType code) { this.code = code; }
}

Poslední třídu, kterou můžete v package najít, je URIPersistenceConverter. Ta slouží ke konverzi třídy URI na řetězec znaků pro uložení v databázi.

@Converter(autoApply = true)
public class URIPersistenceConverter implements AttributeConverter<URI, String> {

    @Override
    public String convertToDatabaseColumn(URI uri) {
        return (uri != null) ? uri.toString() : null;
    }

    @Override
    public URI convertToEntityAttribute(String s) {
        return (StringUtils.isNoneBlank(s)) ? URI.create(s.trim()) : null;
    }
}

Úložiště pro auditní záznamy

Jako SQL úložiště jsem pro účely ukázky použil databázi H2. Jistě to nebude preferované řešení pro rutinní použití, ale zde to bude postačovat.

Databáze má spuštěnou podporu pro webovou konzolu na URL http://localhost:8080/h2-console.

Pro připojení budete potřebovat URN, které se vám zobrazí při startu serveru. Vypadá nějak takto jdbc:h2:mem:fa36e33a-9ed0–4928-beac-55c54c6f75b4, ale při každém startu aplikace se mění.

Třídy, jejichž instance budu chtít ukládat do databáze, jsou anotovány dle požadavků JPA. V této ukázce nechci suplovat daleko lepší návody na JPA, takže zde jsem použil jen to nejnutnější pro prezentaci.

Pro přístup k databázi budu potřebovat instanci třídy odvozené od CrudRepository.

Takovou třídu najdete v component/ProviderAuditRepository:

@Service
@Profile("listener-jpa")
public interface ProviderAuditRepository extends CrudRepository<ProviderAuditRecord, Long> { }

Vzhledem k tomu, že si vystačím se základními CRUD operacemi, může být tělo třídy prázdné.

Implementace reakce na událost

A nyní se již dostávám k metodě reagující na událost zápisem auditního záznamu do databáze.

@Service
@EnableAsync
@Profile("listener-jpa")
public class JPAEventListener {

    private static final Logger logger = LoggerFactory.getLogger(JPAEventListener.class);

    @Autowired private ProviderAuditRepository repo;

    @Async
    @EventListener
    public void handleEvent(ProviderEvent event) {
        if (event.getPhase() == ProviderEvent.Phase.ANSWERED) {
            ProviderAuditRecord record = new ProviderAuditRecord("service.provider.answer", event.getRequest().getTid());
            record.setRequestFrom(event.getRequest().getName());
            record.setProviderName(((ServiceProvider<? extends Request, ? extends Response>)event.getSource()).getInstanceName());
            if (event.getResponse() != null)
                record.setCode(event.getResponse().getCode());
            repo.save(record);
            logger.info("ProviderAuditRecord was written to the database ...");
        }
    }
}

První věc, na kterou bych rád upozornil, je asynchronní zpracování události.

Metoda zpracovává pouze událost typu ProviderEvent, a navíc pouze ve fázi ANSWERED (tedy událost po dokončení zpracování služby).

Vytvořím instanci ProviderAuditRecord, naplním jí daty a uložím prostřednictvím JPA repository (instance ze Spring kontextu).

Přístup k auditním záznamům

Obsah databáze můžete zkontrolovat prostřednictvím webové konzoly, jak jsem popsal v předchozí kapitole.

Nebo jsem udělal ještě jednu možnost, a to webovou službu rest/AuditController, která mně vrátí obsah repository.

@RestController
@Profile("listener-jpa")
public class AuditController {

    @Autowired private ProviderAuditRepository repo;

    @RequestMapping(value = "/audit", method = {RequestMethod.GET, RequestMethod.POST})
    public List<AuditRecord> findAll() {
        List<AuditRecord> response = new ArrayList<>();
        repo.findAll().forEach(response::add);
        return response;
    }
}

Výsledkem je JSON pole se všemi auditními záznamy, a to vše na adrese /audit.

Jak si to mohu zkusit

Začneme opět tím, že spustíme server, nyní s profilem listener-jpa.

[raska@fedora jv-application-events-guide]$ java -jar target/application-events-guide-1.0.0.jar --spring.profiles.active=listener-jpa

A z jiného terminálu si vyvolám postupně obě služby:

[raska@fedora ~]$ jo -p nid=local:node02 name=node02 tid=${RANDOM} value=20 | curl -s -X GET --data-binary @- -H "Content-type: application/json" http://localhost:8080/rest/serviceA | jq .
{
  "nid": "local:node01",
  "name": "node01",
  "tid": "22904",
  "ts": "2021-12-12T17:14:24.760+00:00",
  "code": "OK",
  "result": 28
}
[raska@fedora ~]$ jo -p nid=local:node03 name=node03 tid=$RANDOM text=blablabla | curl -s -X GET --data-binary @- -H "Content-type: application/json" http://localhost:8080/rest/serviceB | jq .
{
  "nid": "local:node01",
  "name": "node01",
  "tid": "28395",
  "ts": "2021-12-12T17:14:31.261+00:00",
  "code": "OK",
  "text": "text length: 9"
}

A nyní se můžu přesvědčit, co se mně objevilo v databázi:

[raska@fedora ~]$ curl -s http://localhost:8080/audit | jq .
[
  {
    "id": 1,
    "oid": "6f25acca-501f-4fd9-8188-ac6f8706eae8",
    "event": "service.provider.answer",
    "tid": "22904",
    "ts": "2021-12-12T17:14:24.776+00:00",
    "requestFrom": "node02",
    "providerName": "Provider A",
    "code": "OK"
  },
  {
    "id": 2,
    "oid": "45266ed7-5a5d-493d-9a23-58ba3bd260e2",
    "event": "service.provider.answer",
    "tid": "28395",
    "ts": "2021-12-12T17:14:31.277+00:00",
    "requestFrom": "node03",
    "providerName": "Provider B",
    "code": "OK"
  }
]

Perzistentní uložení do NoSQL databáze

Třída:

CouchDBEventListener

Profil:

listener-couchdb

Zápis auditních záznamů do SQL databáze, jak jsem jej ukazoval v předchozí kapitole, má jednu významnou nepříjemnost. Musíte si dopředu navrhnout, jaké údaje chcete ke každé události zaznamenat. K nim pak vytvořit entity a jejich mapování do databáze. Pokud se v průběhu času rozhodnete sledovat nějaké další události či rozšířit množinu sledovaných údajů, musíte opět sáhnout do definice entit a jejich mapování. Takže shrnuto, je to dost práce.

Co kdybych ale nemusel řešit již při sběru informací o událostech, jaké údaje budu později potřebovat.

Pro tento účel by mně mohla dobře posloužit NoSQL databáze, ve které nemusím striktně dodržovat strukturu dat. Až při jejich použití si vybírám pouze ty, které mne v daný moment zajímají.

Pro ukázku jsem použil CouchDB databázi instalovanou na stejném stroji, na kterém vyvíjím.

Implementace reakce na událost

Informace o události budu zapisovat do databáze prostřednictvím běžného REST rozhraní.

Moje implementace reakce na událost vypadá následovně:

@Service
@EnableAsync
@Profile("listener-couchdb")
public class CouchDBEventListener {

    private static final Logger logger = LoggerFactory.getLogger(CouchDBEventListener.class);

    private final RestTemplate restTemplate;

    @Value("${couchdb.url-base}")
    private URI resourceBase;
    @Value("${couchdb.username}")
    private String userName;
    @Value("${couchdb.password}")
    private String password;

    public CouchDBEventListener(@Autowired RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.build();
    }

    @Async
    @EventListener
    public void handleEvent(CustomEvent event) {
        UUID id = UUID.randomUUID();
        URI path = resourceBase.resolve(resourceBase.getPath() + "/" + id);
        ResponseEntity<CouchDBRestResponse> response = restTemplate.exchange(path, HttpMethod.PUT, new HttpEntity<>(event, createHeaders(userName, password)), CouchDBRestResponse.class);
        if (response.getStatusCode().is2xxSuccessful())
            if (response.getBody() != null)
                logger.info("Event stored to CouchDB: id={} rev={}", response.getBody().getId(), response.getBody().getRev());
            else
                logger.info("Event stored to CouchDB with no response object");
        else
            if (response.getBody() != null)
                logger.info("FAILED: error={}, reason={}", response.getBody().getError(), response.getBody().getReason());
            else
                logger.info("FAILED with no response object");
    }
    private HttpHeaders createHeaders(String username, String password) {
        HttpHeaders headers = new HttpHeaders();
        String auth = username + ":" + password;
        byte[] encodedAuth = Base64.encodeBase64(auth.getBytes(StandardCharsets.US_ASCII));
        headers.set("Authorization", "Basic " + new String(encodedAuth));
        return headers;
    }
}

Opět se jedná o asynchronně volanou metodu, neboť předpokládám, že zápis dokumentu do databáze přes REST rozhraní přece jen nějaký čas zabere.

Jedná se o metodu, která bude spuštěna pro každou událost odvozenou od třídy CustomEvent. Pokud tedy do budoucna přidám nějakou událost od ní zděděnou, bude tato událost automaticky zahrnuta do zápisu.

Parametry pro připojení na databázi mám načtené ze specifického konfiguračního souboru pro daný profil.

Navíc musí mít každý dokument v databázi unikátní ID, proto jsem použil generované UUID.

Pro vyvolání REST rozhraní použiji instanci RestTemplate poskytovanou frameworkem.

V případě že komunikuji s REST rozhraním CouchDB pro operaci zápisu JSON dokumentu, bude výsledkem opět JSON objekt. Abych mohl tento výsledek načíst, připravil jsem si třídu obsahující atributy vrácené při úspěchu i chybě:

public class CouchDBRestResponse {

    private Boolean ok;
    private UUID id;
    private String rev;
    private String error;
    private String reason;

    public Boolean getOk() { return ok; }
    public void setOk(Boolean ok) { this.ok = ok; }
    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }
    public String getRev() { return rev; }
    public void setRev(String rev) { this.rev = rev; }
    public String getError() { return error; }
    public void setError(String error) { this.error = error; }
    public String getReason() { return reason; }
    public void setReason(String reason) { this.reason = reason; }
}

A nyní mám již všechno potřebné, abych si mohl vše vyzkoušet.

Jak si to mohu zkusit

Nejdříve je dobré si ověřit, že mně funguje CouchDB server, a to nejjednodušeji takto:

[raska@fedora ~]$ curl -s http://localhost:5984 | jq .
{
  "couchdb": "Welcome",
  "version": "3.2.1",
  "git_sha": "244d428af",
  "uuid": "2e2af3d6e57ed59cd03de3e5020ba617",
  "features": [
    "access-ready",
    "partitioned",
    "pluggable-storage-engines",
    "reshard",
    "scheduler"
  ],
  "vendor": {
    "name": "The Apache Software Foundation"
  }
}

Nyní si ještě potřebuji vytvořit databázi events pro ukládání dokumentů (jméno je zahrnuto do URL v konfiguračním parametru couchdb.url-base).

[raska@fedora ~]$ curl -s -X PUT http://admin:admin@localhost:5984/events | jq .
{
  "ok": true
}

Nyní si již můžu spustit svoji aplikaci s profilem listener-couchdb:

[raska@fedora jv-application-events-guide]$ java -jar target/application-events-guide-1.0.0.jar --spring.profiles.active=listener-couchdb

A v jiném terminálu si spustit dvě služby:

[raska@fedora ~]$ jo -p nid=local:node02 name=node02 tid=${RANDOM} value=20 | curl -s -X GET --data-binary @- -H "Content-type: application/json" http://localhost:8080/rest/serviceA | jq .
{
  "nid": "local:node01",
  "name": "node01",
  "tid": "12879",
  "ts": "2021-12-13T18:57:32.215+00:00",
  "code": "OK",
  "result": 26
}
[raska@fedora ~]$ jo -p nid=local:node03 name=node03 tid=$RANDOM text=blablabla | curl -s -X GET --data-binary @- -H "Content-type: application/json" http://localhost:8080/rest/serviceB | jq .
{
  "nid": "local:node01",
  "name": "node01",
  "tid": "10430",
  "ts": "2021-12-13T18:57:42.043+00:00",
  "code": "OK",
  "text": "text length: 9"
}

Tak, a teď bych se mohl podívat, jaké dokumenty mám v databázi events:

[raska@fedora ~]$ curl -s http://admin:admin@localhost:5984/events/_all_docs | jq .
{
  "total_rows": 4,
  "offset": 0,
  "rows": [
    {
      "id": "2ded6704-35ea-49d5-9188-f969b6931185",
      "key": "2ded6704-35ea-49d5-9188-f969b6931185",
      "value": {
        "rev": "1-9457044ad6d8efc78b33f1fc54067e5f"
      }
    },
    {
      "id": "63410079-bc10-49c3-a6b8-cd596487ff5f",
      "key": "63410079-bc10-49c3-a6b8-cd596487ff5f",
      "value": {
        "rev": "1-91cc33fc84fae21b2e9f400f28730a2f"
      }
    },
    {
      "id": "77b460e0-5dd5-4b3c-9b75-9bb30c240ad0",
      "key": "77b460e0-5dd5-4b3c-9b75-9bb30c240ad0",
      "value": {
        "rev": "1-ec9104b1f577555eef97ca08a3f348a1"
      }
    },
    {
      "id": "8fa0b94c-fb8f-499a-9751-f62380fde6e9",
      "key": "8fa0b94c-fb8f-499a-9751-f62380fde6e9",
      "value": {
        "rev": "1-3a5ddbf558cf12047d8c873d7b1a1ba2"
      }
    }
  ]
}

Podle očekávání mám v databázi čtyři dokumenty, pro každou provedenou službu dva.

Mohu se podívat na obsah nějakého dokumentu:

[raska@fedora ~]$ curl -s http://admin:admin@localhost:5984/events/8fa0b94c-fb8f-499a-9751-f62380fde6e9 | jq .
{
  "_id": "8fa0b94c-fb8f-499a-9751-f62380fde6e9",
  "_rev": "1-3a5ddbf558cf12047d8c873d7b1a1ba2",
  "source": {
    "instanceName": "Provider B"
  },
  "timestamp": 1639421862042,
  "phase": "ASKED",
  "request": {
    "nid": "local:node03",
    "name": "node03",
    "tid": "10430",
    "ts": "2021-12-13T18:57:42.042+00:00",
    "text": "blablabla"
  },
  "response": null,
  "event": "CustomEvent.ProviderEvent.ASKED"
}

Nebo si vybrat jen některé atributy z každého dokumentu bez ohledu na jeho strukturu:

[raska@fedora ~]$ curl -s -X POST -d @- -H 'Content-Type:application/json' 'http://admin:admin@localhost:5984/events/_find' <<! | jq .
> {
  "selector": {},
  "fields": [
    "_id",
    "timestamp",
    "event",
    "request.tid"
  ]
}
!
{
  "docs": [
    {
      "_id": "2ded6704-35ea-49d5-9188-f969b6931185",
      "timestamp": 1639421852211,
      "event": "CustomEvent.ProviderEvent.ASKED",
      "request": {
        "tid": "12879"
      }
    },
    {
      "_id": "63410079-bc10-49c3-a6b8-cd596487ff5f",
      "timestamp": 1639421862045,
      "event": "CustomEvent.ProviderEvent.ANSWERED",
      "request": {
        "tid": "10430"
      }
    },
    {
      "_id": "77b460e0-5dd5-4b3c-9b75-9bb30c240ad0",
      "timestamp": 1639421852215,
      "event": "CustomEvent.ProviderEvent.ANSWERED",
      "request": {
        "tid": "12879"
      }
    },
    {
      "_id": "8fa0b94c-fb8f-499a-9751-f62380fde6e9",
      "timestamp": 1639421862042,
      "event": "CustomEvent.ProviderEvent.ASKED",
      "request": {
        "tid": "10430"
      }
    }
  ],
  "bookmark": "g1AAAAB4eJzLYWBgYMpgSmHgKy5JLCrJTq2MT8lPzkzJBYqrWKQlGiRZmiTrpiVZpOmaWFom6lqamxrqppkZGVsYpKWkmqVagvRywPQSrSsLAKBcIIU",
  "warning": "No matching index found, create an index to optimize query time."
}

Komentář na závěr kapitoly

Na první pohled může tento postup být velice zajímavý, neboť můžete dodatečně doplňovat sledované události, které se jakoby samy od sebe začnou zapisovat do databáze.

Je to ale dvousečná zbraň. S událostí mohou putovat i citlivé údaje, které nechcete, aby se objevovaly v nějaké externí databázi (v mém případě jsou to výsledky provedení služeb, což sice moc citlivé není, ale asi to v auditních záznamech nechceme).

A nemusí se jednat pouze o citlivé údaje. Mohou to být také údaje, které mají pouze dočasný význam a v databázi by nám jen zbytečně zabíraly místo.

Berte prosím předchozí řádky jako ukázku, která nemusí za každé situace vyhovovat.

Sdílet