Throw to taki inny return…
Przynajmniej w Javie. Jak już mamy Javę 8 to możemy pokusić się o takie podejście pod pewnymi warunkami.
Przypadek
Klient ma Sonara i nie zawahał się go użyć. Rzecz w tym, że ocenie podlega tylko kod z zewnątrz organizacji, czyli nasz, a ten wewnętrzny może być chujowy. My przejęliśmy ten chujowy kod i teraz naprawiamy…
Jednym z elementów jest maszynka do zamiany jsonów na obiekty i na odwrót. W niej jest kawałek kodu do pracy z datami. Wygląda on mniej więcej tak
Listing 1. Konwerter dat
public class DateConverting {
public static void main(String[] args) {
String dateJson = "1"; // 1-1-1970 :D
Date date = convertStringToDate(dateJson);
System.out.println(date.toString());
}
private static Date convertStringToDate(String s) {
if (s == null || s.trim().isEmpty()) return null;
try {
return new SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss.SSS").parse(s);
} catch (ParseException e) {
}
try {
return new SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss").parse(s);
} catch (ParseException e) {
}
try {
return new SimpleDateFormat("yyyy-MM-dd'T'hh:mm").parse(s);
} catch (ParseException e) {
}
try {
return new SimpleDateFormat("yyyy-MM-dd").parse(s);
} catch (ParseException e) {
}
return new Date(Long.valueOf(s));
}
}
Mamy cztery formaty daty i jeżeli String nie pasuje do żadnego z nich to traktujemy jak zwykły long. Jeżeli jest pusty albo jest nullem to zwracamy null. Straszna paskuda. Sonar też to stwierdził i strasznie czerwony raport. Klient kręci germańską główką i szwargocze… Jak to można naprawić.
Wersja prosta
Na razie rzecz prosta i bez udziwnień. Generalnie mamy sobie dopuszczone formaty, robimy z nich listę iterujemy i jak na listingu 2
Listing 2. Konwerter dat pierwsza poprawka
private static Date convertStringToDate(String s) {
if (s == null || s.trim().isEmpty()) return null;
ArrayList<String> patterns = Lists.newArrayList(YYYY_MM_DD_T_HH_MM_SS_SSS,
YYYY_MM_DD_T_HH_MM_SS
, YYYY_MM_DD_T_HH_MM
, YYYY_MM_DD);
for (String pattern : patterns) {
try {
return new SimpleDateFormat(pattern).parse(s);
} catch (ParseException e) {
}
}
return new Date(Long.valueOf(s));
}
Generalnie to rozwiązanie jest takie samo jak poprzednie. Różnica jest na poziomie „zamiast stu zmiennych zrób tablicę”.
My oczekujemy czegoś innego. Teraz będzie dużo kodu i magii javy 8 oraz trochę programowania prawie funkcyjnego 😉 Da się to rozwiązanie zaimplementować też z użyciem np. Guavy, ale to jak musicie mieć javę 7.
Użyjmy javy 8
Koncepcja tej refaktoryzacji opera się o stwierdzenie, że rzucenie wyjątku jest tak na prawdę rodzajem instrukcji powrotu. Nasza metoda ma w takim przypadku dwa możliwe typu na wyjściu. Jeden ten zadeklarowany i drugi dla wyjątku.
Interfejs opakowujący
Na początek zdefiniujmy sobie interfejs opakowujący ParseTask. Pozwoli on na ujednolicenie różnych mechanizmów parsowania.
Listing 3. Interfejs ParseTask
interface ParseTask<R>{
R parse() throws ParseException;
}
Implementacja będzie realizować zadanie parsowania. Może zwrócić coś R albo wyrzucić wyjątek ParseException.
Rezultat parsowania
Potrzebujemy też klasę, która będzie reprezentować rezultat parsowania. Nie chcę używać Optional ponieważ jest zbyt ogólny, a jeżeli chcemy gdzieś zachować informacje o błędzie to musimy mieć klasę specjalizowaną.
Listing 4. Klasa ParseResult
abstract class ParseResult<R> {
static class Valid<R> extends ParseResult<R>{
final R r;
Valid(R r) {
this.r = r;
}
@Override
public boolean isValid() {
return true;
}
@Override
public R result() {
return r;
}
}
static class Failed<R> extends ParseResult<R>{
final ParseException e;
Failed(ParseException e) {
this.e = e;
}
@Override
public boolean isValid() {
return false;
}
@Override
public R result() {
throw new NoSuchElementException("No value present");
}
}
public abstract boolean isValid();
public abstract R result();
}
Maszynka do uruchamiania
Czyli klasa Parser, której zadaniem jest uruchamianie poszczególnych zadań parsowania i tworzenie odpowiednich wyników
Listing 5. Klasa Parsersamp>
class Parser<R> implements Function<ParseTask<R>, ParseResult<R>> {
@Override
public ParseResult<R> apply(ParseTask<R> parseTask) {
try {
return new ParseResult.Valid(parseTask.parse());
} catch (ParseException e) {
return new ParseResult.Failed(e);
}
}
}
Uruchamiamy
Finalnie nasza metoda konwertująca może wyglądać w następujący sposób:
Listing 5. Klasa Parser
private static Date convertStringToDate(String s) {
if (s == null || s.trim().isEmpty()) return null;
ArrayList<String> patterns = Lists.newArrayList(YYYY_MM_DD_T_HH_MM_SS_SSS,
YYYY_MM_DD_T_HH_MM_SS
, YYYY_MM_DD_T_HH_MM
, YYYY_MM_DD);
return patterns.stream()
.map(p -> (ParseTask<Date>) () -> new SimpleDateFormat(p).parse(s))
.map(new Parser<>())
.filter(ParseResult::isValid)
.map(ParseResult::result)
.findFirst().orElseGet(() -> new Date(Long.valueOf(s)));
}
Najpierw tworzymy sobie listę zdań parsowania. Następnie odpalamy parser (feralna nazwa), sprawdzamy czy wynik jest poprawny i pierwszy poprawny wynik zwracamy. Jeżeli nie będzie żadnego poprawnego wyniku to dokonujemy domyślnej konwersji.
Podsumowanie
Czy kod wynikowy jest lepszy? Jeżeli ta operacja konwersji i parsowania była by tylko w jednym miejscu w całym systemie to można takie rozwiązanie potraktować jako ciekawostkę. Jeżeli mamy jednak wiele różnych praserów i konwersji to można wprowadzić prostą klasę narzędziową, która ułatwi nam życie:
Listing 6. Klasa ParserMatcher
class ParserMatcher<R>{
public R findParserAndParse(Collection<ParseTask<R>> pt, Supplier<R> defVal){
return pt.stream()
.map(new Parser<>())
.filter(ParseResult::isValid)
.map(ParseResult::result)
.findFirst().orElseGet(defVal);
}
}
i pozwoli zredukować kod do kilku prostych regułek.
Cały „myk” w tym rozwiązaniu opiera się na dwóch rzeczach. Pierwsza to redukcja efektu ubocznego jakim jest wyjątek do poziomu opcjonalnego wyjścia. Efekt nie opuszcza miejsca, w którym się pojawił. Zostaje opakowany i zwrócony jako rezultat. Druga to rozgraniczenie sposobu przetwarzania, czyli parsowania od danych, czyli w tym przypadku parserów. W pewnym sensie tworzymy tu bardzo prymitywną gramatykę i efektywnie język obróbki informacji.