Groovy i dane do testowania
Maksymilian Żurawski - 19 września 2017
Jak ułatwić sobie życie na projekcie za pomocą narzędzi do tworzenia skryptów?
Poniżej przedstawiam przykłady, kiedy znajomość narzędzia do tworzenia skryptów na tzw. „kolanie”, zasadniczo przyspieszy nam pracę na projekcie.
Case 1
Czasami flow procesowania w jakiejś aplikacji wymaga zaimportowania wielu danych z kilku różnych źródeł. Kiedy po zaimportowaniu danych z tych źródeł i po karkołomnym przeprocesowaniu tych źródeł okaże się, że dane wyjściowe zawierają błędy, testerzy tworzą ticketa/buga i wysyłają go do teamu developerów. Problem z dużą ilością danych i ze skomplikowanym procesem importu jest taki, że trwa długo. Na projekcie, na którym byłem do końca maja problem był spory, zwłaszcza, gdy chciało się zreprodukować buga i go naprawić. Importowane były produkty bankowe, które do projektowego systemu trafiały w różnych formatach w zależności od źródła. I tak pojawiały się produkty w xml’u, csv i jsonie. Przy czym jeden produkt potrafił przychodzić z różnych źródeł jednocześnie. Zadaniem projektowego systemu było zmergowanie produktu ze źródeł na podstawie skomplikowanych reguł i odpalenie na zmergowanym produkcie aktualizacji, które przychodziły z jeszcze innych źródeł. Na wyjściu stan produkt mógł się zmienić. Testerzy przygotowując test-casy importowali dane ze wszystkich źródeł dla wszystkich możliwych produktów. Produktów było około 160k. Paczka wszystkich źródeł z wszystkimi produktami, to dobre kilkadziesiąt MB spakowanych zipem. Czas trwania pełnego importu – od startu, poprzez mergowanie i odpalanie wszystkich reguł – trwał około 2 godzin i 30 minut. W przypadku błędu, testerzy wrzucali do opisu ticketa biznesowy identyfikator produktu, którego błąd dotyczył. Reprodukując buga trzeba było wyłuskać konkretny produkt z wszystkich źródeł i importować tylko ten jeden produkt. Wtedy chociażby debugowanie miałoby jakiś sens. Wyłuskiwanie produktu można zrobić ręcznie. Owszem. Ale przy takiej masie danych testowych, czy wielkich xml’i, w których można dostać oczopląsu, jest to trochę nudne zadanie. Zwłaszcza, że praktycznie powtarzałoby się co każdy ticket z bugiem. Dlatego żeby ułatwić sobie życie, wystarczyło stworzyć sobie mały skrypt, który te dane będzie zbierał, przygotuje odpowiednie pliki i wrzuci do katalogu, z którego można je zaimportować.
Case 2
Dosyć banalny przykład. Chodzi o generowanie insertów do bazy danych z różnymi zrandomowanymi danymi. Zaczęło się od tego, że dostałem zadanie pogrupowania w miarę prostych obiektów, na podstawie 4 atrybutów w tych obiektach. Przy czym 4 atrybuty tworzą jedną grupę. Lista tych grup + należące do nich obiekty mają być wysyłane do klienta webowego. DAO + maska klienta były już gotowe. Moją działką było grupowanie. Wszystko pięknie, tylko nie było żadnych danych w bazie, bo to początek projektu. Kiedyś w dalekiej przyszłości tabele bazy danych mają zostać wypełnione jakimiś danymi z innych systemów, ale póki co na żadnej bazie z projektem nie było żadnych entriesów dla tych obiektów. Mało tego. Jeden z atrybutów grupowania znajduje się w obiekcie-dziecku głównego obiektu. Głównych obiektów ma być docelowo ok. 50k. Obiekt główny mapowany jest z podobiektem jako OneToMany. Więc grup może być teoretycznie więcej niż 1 dla jednego obiektu głównego. Żeby jakoś przetestować zaimplementowane grupowanie potrzebowałem większej puli obiektów, niż zdołałem ręcznie wyprodukować w SQL Developerze. I znowu wspomogłem się groovym.
Case 1
Dane źródłowe
Przykładowe dane źródłowe zostały odpowiednio „spreparowane” (content i nazwa) i znacznie „skurczone”, co by nie upubliczniać prawdziwych danych. Produkty nazywane są w źródłach instrumentami. Instrumentów jest kilka typów. Skrypt dopasowany został do spreparowanego „typu_z” instrumentów.
Produkty typu „z” powstają z instrumentów ze źródła A i źródła B, templateów produktów i czegoś, co nazwałem tutaj „basics”. Źródłem template’ów jest serwis RESTowy, zmockowany w systemie lokalnym alternatywą, zczytującą plik 'templates.json’. Źródłem 'basics’ też jest serwis RESTowy, zmockowany lokalnie alternatywą zczytującą plik 'basics.json’ Oba pliki zawierają zasadnicze informacje o produkcie i jego pod-elementach.
Identyfikacja instrumentu/produktu
Każdy instrument ma 3 biznesowe atrybuty, które go identyfikują: isin/wkn/ssd_id. Instrumenty z ssd_id nie mają isin i wkn. Te z isin i wkn nie mają ssd_id.
Importowanie danych – ogólne informacje
Żeby zaimportować dane dla konkretnego produktu typu „z”, trzeba mieć 5 plików źródłowych:
– basics.json
– source_A_import.xml
– source_B1.csv
– source_B2.csv
– templates.json
Źródło B dostarcza dwa pliki B1 i B2 – informacje o produkcie mogą znajdować się w jednym z nich, ale ze względów biznesowych czasami dane mogą być w źródle B1, a czasami w źródle B2. W całym flow importu każde ze źródeł musi być obecne, nawet jeśli któreś nie zawiera żadnych danych. Uwarunkowane to jest tym, że nigdy nie wiadomo, które ze źródeł trafi do kolejki jako pierwsze i w którym ze źródeł znajdują się jakieś dane. Request do źródła B zakłada, że odpowiedź będzie w postaci dwóch plików B1 i B2. Zawsze. Przy czym np. B2 może zawierać jedynie nagłówki pól, bez danych.
Oryginalne dane importowane przez testerów
Dane źródłowe znajdują się w katalogu:
dataimport/typ_z
Katalogi wewnątrz to: basics, original i templates. Źródła produkcyjne (te na których testerzy testowali system), znajdują się w katalogu original. Produkty typu „z” pochodzące ze źródła A mogą być przesyłane w kilku plikach xml, stąd 3 pliki:
source_A_01.xml, source_A_02.xml, source_A_03.xml
Produkty typu „z”, pochodzące ze źródła B zawsze przychodzą w jednym z dwóch plików csv, przy czym import ze źródła B musi mieć zawsze dwa pliki:
source_B.1.csv, source_B.2.csv
Uwaga: ważna jest tu też konwencja nazewnictwa dla źródła B.
Skrypt
Skrypt groovego, który przygotowuje import dla konkretnego produktu jest dosyć banalny. Można go sztucznie podzielić na kilka elementów:
– definicje celu
– definicje źródeł
– zaimportowanie potrzebnych bibliotek
– przetworzenie oryginalnych danych
Definicje celu
def OUTPUT_DIRECTORY = "output";
def FILE_OUTPUT = OUTPUT_DIRECTORY + "/" + "source_A_import.xml"
def SEARCHED_ID = "DE000AK0A635";
Ustalamy katalog docelowy, nazwę pliku dla źródła A (narzuconą przez konwencję samego importu), oraz definiujemy ID biznesowy produktu, dla którego chcemy przygotować import.
Definicje źródeł
def SOURCES_DIRECTORY = "dataimport/typ_z";
def SOURCES_A_DIRECTORY = "$SOURCES_DIRECTORY/original/";
def SOURCES_B_DIRECTORY = "$SOURCES_DIRECTORY/original/";
def TEMPLATES_DIRECTORY = "$SOURCES_DIRECTORY/templates/";
def BASICS_DIRECTORY = "$SOURCES_DIRECTORY/basics/";
def SOURCES_A_EXTENSION = "xml";
def SOURCES_A_PREFIX = "source_A_";
def SOURCE_B_PREFIX = "source_B.";
def SOURCE_B_SUFFIX = "CSV";
def SOURCE_B_OUTPUT_PREFIX = "source_B";
Nic szczególnego – po prostu definicje potrzebne do dalszego procesowania, jak:
– katalog skąd brać pliki dla źródeł A
– katalog skąd brać pliki dla źródeł B
– prefixy plików – jak rozpoznać pliki źródłowe A i B.
Zaimportowanie potrzebnych zależności i bibliotek
@Grab(group='org.apache.commons', module='commons-csv', version='1.4')
@Grab(group='org.apache.commons', module='commons-lang3', version='3.5')
@Grab(group='commons-io', module='commons-io', version='2.5')
import java.awt.List
import java.nio.file.CopyOption
import java.nio.file.Files
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.nio.file.CopyMoveHelper.CopyOptions
import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVParser
import org.apache.commons.csv.CSVRecord
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import groovy.io.GroovyPrintStream
import groovy.xml.StreamingMarkupBuilder
import groovy.xml.XmlUtil
Tutaj wykorzystujemy Grape’a do ściągnięcia zależnych bibliotek zewnętrznych (nie-groovowych) – to ta adnotacja @Grab. Reszta to zwykłe importy – jak w javie.
Utworzenie katalogu docelowego
Zaczynamy od utworzenia docelowego katalogu:
folder = new File(OUTPUT_DIRECTORY)
if(!folder.exists()) {
folder.mkdir()
} else {
folder.delete();
folder.mkdir();
}
Przetworzenie oryginalnych danych źródła A
Tworzymy obiekt XmlSlurper, który wykorzystamy do parsowania xml’a, podobnie jak to się robi w wyrażeniach XPath:
def xs = new XmlSlurper();
Następnie znając strukturę plików źródłowych ze źródła A, tworzymy obiekt StreamingMarkupBuilder, przy pomocy którego utworzymy najpierw strukturę dla naszego instrumentu/produktu, a następnie zapiszemy content konkretnego instrumentu w odpowiednim pliku docelowym.
String nxml = new StreamingMarkupBuilder().bind {
instruments {
new File(SOURCES_A_DIRECTORY).eachFileRecurse() { file ->
if(file.getName().startsWith(SOURCES_A_PREFIX) && file.getName().endsWith(SOURCES_A_EXTENSION)) {
def p = xs.parse(file);
def instruments = p.children();
instruments.each { element ->
if(element.master_data.isin == SEARCHED_ID || element.master_data.wkn == SEARCHED_ID || element.master_data.ssd_id == SEARCHED_ID)
mkp.yield element
}
}
}
}
}
Metoda bind tworzy obiekt typu Writable, który można wykorzystać do zapisania tworzonego markupa do stringa, lub bezpośrednio do strumienia. W skrypcie wykorzystana jest pierwsza opcja – do stringu. Otwieramy markup tagiem instruments, i teraz zaczynamy przeszukiwać pliki źródła A w celu zidentyfikowania produktu/instrumentu, którego jeden z biznesowych identyfikatorów jest taki sam, jak ten, którego szukamy. Korzystamy przy tym ze specjalnej przestrzeni nazw mkp, żeby pominąć „normalny” proces budowania obiektu wyjściowego („normalny” w znaczeniu przy pomocy StreamingMarkupBuilder’a). mkp daje nam dostęp do obiektu MarkupBuilderHelper, z którego wywołujemy metodę yield(Object value), co powoduje wypisanie (do stringu) ciała aktualnego tag’a, z pominięciem encji XML. Czyli po prostu, jeśli identyfikator biznesowy elementu (taga instrument) zgadza się z naszym szukanym identyfikatorem, to wyrzucamy zawartość całego elementu do taga instruments. I już.
Teraz już tylko musimy zapisane w stringu informacje ze źródła A zapisać do pliku:
File targetFile = new File(FILE_OUTPUT);
if(!targetFile.exists()) {
targetFile.createNewFile();
}
XmlUtil.serialize(nxml, new GroovyPrintStream(FILE_OUTPUT, 'utf-8'))
Przetworzenie oryginalnych danych źródła B
Plik ze źródła B przypomina „skustomizowany” csv. Posiada nagłówek, który wygląda następująco:
REQUEST000001.1
;DATA001 ;DATA002 ;DATA003 ;DATA004 ;
;-----------;-----------;------------;------------;
Oczywiście nazwy kolumn zostały „spreparowane” ze względu na dobro danych klienta. Co tu jest istotne to to, że pierwszy wiersz identyfikuje źródło B1 lub B2 i numer requesta o dane. Czy źródło jest B1, czy B2 możemy rozstrzygnąć przez numer po „.” na końcu wiersza.
W skrypcie podejście jest stosunkowo proste:
– zczytać z pierwszego wiersza numer pliku
– linie 2 i 3 skopiować (nazwy kolumn i separator nazw kolumn od danych)
– sprawdzenie dla każdego wiersza od wiersza 4 czy wartość kolumny DATA003 lub DATA004 jest taka sama jak nasz szukany identyfikator.
W skrypcie takie procesowanie robimy dla każdego pliku źródłowego B z katalogu źródłowego:
new File(SOURCES_B_DIRECTORY).eachFileRecurse() { file ->
if(file.getName().startsWith(SOURCE_B_PREFIX)) {
// TUTAJ WŁAŚCIWE PROCESSOWANIE (patrz niżej)
}
}
I teraz wewnątrz if’a wykonujemy następująco:
Definiujemy tablicę z liniami z pliku źródłowego B, które chcemy zapisać do pliku docelowego B:
def linesToSave = new ArrayList<String>()
I ustawiamy licznik plików B na 1:
def bFileNumber = 1;
Następnie procesujemy każdy plik źródłowy B według opisanego wcześniej schematu:
new File(SOURCES_B_DIRECTORY, file.getName()).eachLine { line, nb ->
// NOTE: extract header to get file number, which will be used as source_B[BFileNumber].csv
if(nb == 1) {
def bHeader = "$line";
def splitted = bHeader.split("\\.")
bFileNumber = splitted[1];
linesToSave << line } // NOTE: keep second line as it is if(nb == 2) { linesToSave.add("$line") } // NOTE: keep third line as it is if(nb == 3) { linesToSave.add("$line") } // NOTE: prepare next lines, by searching corresponding ID, columns 3 and 4 are ID's of produkt (WKN/ISIN) if(nb > 3) {
def splitted = line.split(";")
if(splitted[4].trim() == SEARCHED_ID || splitted[3].trim() == SEARCHED_ID) {
linesToSave << line
}
}
}
Następnie musimy zapisać tablicę przeprocesowanych linii do pliku docelowego:
new File(OUTPUT_DIRECTORY, SOURCE_B_OUTPUT_PREFIX + bFileNumber + "." + SOURCE_B_SUFFIX).with { outFile ->
outFile.withWriter { out ->
linesToSave.each { out.println it}
}
}
Na samym końcu kopiujemy pliki z basics i templates do katalogu docelowego:
FileUtils.copyDirectory(new File(TEMPLATES_DIRECTORY), new File(OUTPUT_DIRECTORY))
FileUtils.copyDirectory(new File(BASICS_DIRECTORY), new File(OUTPUT_DIRECTORY))
//Kliknij tu=> case1
Case 2
Model danych i definicje
Załóżmy, że model zależności między obiektami wygląda tak:
____________ ________ ____________
| |1 1 | |1 1..* | |
| INSTRUMENT |--------> | BASKET |-----------> | UNDERLYING |
|____________| |________| |____________|
Do grupowania potrzebne są następujące atrybuty:
– emissiontyp
– origin
– instrumenttype
– asset
Wszystkie cztery atrybuty tworzą jedną grupę. Reszta atrybutów jest dla tego taska nieistotna.
Atrybuty do grupowania znajdują się w następujących obiektach:
Atrybut |
Gdzie |
emissiontyp |
INSTRUMENT |
origin |
INSTRUMENT |
instrumenttype |
INSTRUMENT |
asset |
UNDERLYING |
W celu przetestowania zaimplementowanego grupowania z możliwie jak największą ilością randomowych kombinacji, posłużymy się skryptem, który wygeneruje nam odpowiednią ilość zrandomizowanych danych.
Załóżmy, że wszystkie atrybuty to enumy. I tak:
emissiontype = ["F", "P", "H"];
origin = ["USA", "EU", "OTHER"];
instrumenttype = ["Type A", "Type B", "Type C", "Type D"]
assets = ["AKTIONS", "INDEXES", "GOLD"];
Skrypt
Na samym początku zdefiniujemy sobie pewne wartości, jak:
– nazwa pliku wyjściowego (outputfile)
– ile instrumentów chcemy utworzyć (vamount)
– ile underlyingów może być maxymalnie w baskecie (urange)
def outputfile = "dummy_case2.sql"
def vamount = 2000; // amount of instrument & baskets
def urange = 2; // max amount of underlyings in basket
Następnie zdefiniujmy nasze enumy:
def emissiontype = ["F", "P", "H"];
def origin = ["USA", "EU", "OTHER"];
def instrumenttypes = ["Type A", "Type B", "Type C", "Type D"]
def assets = ["AKTIONS", "INDEXES", "GOLD"];
Zdefiniujmy sobie tablicę, w której będziemy zapisywali wygenerowane sql’e:
def linesToSave = []
Pamiętając o modelu i zależności INSTRUMENT -> BASKET możemy zacząć od tworzenia BASKETa:
(1..vamount).each { key ->
linesToSave.add("insert into BASKET (ID, VERSION) values ($key, 0);")
}
Następnie tworzymy dla każdego basketa (vamount) randomową (1 -> urange) ilość underlyingów. Dodatkowo randomowo wybierany jest typ underlyingu.
def underlyingtyp = ["UNITS", "INDEX"];
int uindex = 1;
(1..vamount).each { key ->
def array = (1..urange);
def randamount = array.get(new Random().nextInt(array.size()));
(1..randamount).each { ukey ->
def randassetklasse = assets.get(new Random().nextInt(assets.size()));
def randunderlyingtyp = underlyingtyp.get(new Random().nextInt(underlyingtyp.size()));
linesToSave.add("insert into UNDERLYINGS (ID, VERSION, WKN, ISIN, DESCRIPTION, CURRENCY, TYP, ASSET, BASKET_ID) " +
" values ($uindex, 0, 'U$uindex', 'U000$uindex', 'Test Underlying $uindex', 'EUR', '$randunderlyingtyp', '$randassetklasse', $key);");
uindex++;
}
}
Następnie tworzymy instrumenty:
def quanto = [0, 1];
def instrumenttemplate = ["Doublechance", "US Actions", "Turbos"];
(1..vamount).each { key ->
def randemissiontype = emissiontype.get(new Random().nextInt(emissiontype.size()));
def randorigin = origin.get(new Random().nextInt(origin.size()));
def randquanto = quanto.get(new Random().nextInt(quanto.size()));
def randinstrumenttemplate = instrumenttemplate.get(new Random().nextInt(instrumenttemplate.size()));
def randunstrumenttyp = instrumenttypes.get(new Random().nextInt(instrumenttypes.size()));
linesToSave.add("insert into INSTRUMENTS ( " +
"ID, " +
"VERSION, " +
"WKN, " +
"ISIN, " +
"DESCRIPTION, " +
"TEMPLATE_NAME, " +
"DDV, " +
"WM, " +
"STOCK, " +
"KAPITAL, " +
"NOTE, " +
"QUANTO, " +
"CREATOR, " +
"EMITTOR, " +
"INSTRUMENTTYP, " +
"BASKET_ID, " +
"emissiontype, " +
"ORIGIN ) " +
"values ( " +
"$key, " +
"0, " +
"'DE0$key', " +
"'DE000$key', " +
"'Test Bezeichnung $key', " +
"'$randinstrumenttemplate', " +
"'DDV $key', " +
"'WM $key', " +
"'1', " +
"'1', " +
"'1', " +
"$randquanto, " +
"1, " +
"1, " +
"'$randunstrumenttyp', " +
"$key, " +
"'$randemissiontype'," +
"'$randorigin'); ");
}
a na samym końcu zapisujemy wszystkie wygenerowane sql’e do pliku wyjściowego:
new File(outputfile).with { outFile ->
outFile.withWriter { out ->
linesToSave.each {out.println it}
}
}
// Kliknij tu=>case2
Maksymilian Żurawski
Software developer.
Programista Javy i baz danych. Namiętny bugfixer. Interesuje się automatyzacją wszystkiego, co możliwe i pisaniem drobnych skryptów w groovym i bashu. W wolnym czasie tata, książkozaur (horror, s-f, fantasy) i kinoman (tak samo jak książki), aktywny, ale tylko hobbystycznie, użytkownik blendera 3d.
Comments