středa 30. září 2009

Práce s číselníkovými hodnotami

V tomto příspěvku s lehce nudným názvem bych se rád pokusil o svěží zamyšlení nad prací s číselníkovými hodnotami. Konkrétně mě bude zajímat porovnávání číselníkových hodnot a rozhodování podle číselníkových hodnot v obchodní logice. Mám na mysli otázky typu „Jak zpracovat adresu typu 'sídlo firmy'?“ nebo například „Jak vypočítat provizi podle schématu 'fixní procentní podíl'?“ Je to další profláknuté a docela jednoduché téma a na většině projektů asi není nutnost „zamýšlet se“ nad ním. Zároveň ale platí, že na většině projektů, u kterých jsem byl, by se porovnávání číselníkových dalo řešilo o dost přehledněji a čitelněji, což mě nakonec po troše váhání vedlo k napsání tohoto příspěvku.

Co je číselník

Číselníkovou položkou myslím třídu,
  • která je perzistentní
  • a má minimálně atributy reprezentující kód, se kterým může pracovat obchodní logika,
  • a text, případně odkaz na jazykový text (tzn. českou, anglickou, … verzi), který se zobrazuje uživateli.
Mějme takovou číselníkovou položku reprezentovanou třídou AbstractCatalogEntity.
public abstract class AbstractCatalogEntity {
private Long id;
private String code;
private String label;
...
}
Tento text neřeší:
  • Jestli je o hodnotách, které chci porovnávat, lepší mluvit jako o „kódech“ nebo třeba „klíčích“.
  • Jestli má být odkaz na číselník realizován přes „id“ nebo „code“.
  • Jestli mají mít číselníkové položky platnost a jestli ji má porovnávání hodnot zohledňovat.
  • Další související otázky.
Opravdu bude zajímat pouze vyhodnocování stringového kódu, tedy atributu code.

Příklad

Budu používat jednoduchý příklad s číselníkem států, číselníkem typů adres, adresou a vazbou na adresu.
public class Country extends AbstractCatalogEntity { }
public class AddressType extends AbstractCatalogEntity { }

public class Address {
private Long id;
private Country country;
private String city;
private String street;
private String postcode;
...
}
public class AddressLink {
private Long id;
private AddressType addressType;
private Address address;
private String note;
...
}
Adresa má vlastnost country ukazující do číselníku zemí. Číselník zemí je příklad toho, kdy v obchodní logice potřebujeme vyhodnocovat pouze jednu nebo několik položek číselníku a ostatní nás víceméně nezajímají. Příklady budou zjišťovat, jestli je daná country rovna České republice (tedy jestli je daná adresa v Česku).
Vazba na adresu má vlastnost addressType určující typ adresy – tzn. jestli je daná adresa adresou sídla firmy, adresou fakturační nebo třeba adresou pobočky. Odpovídající číselník typů adres je příkladem toho, kdy se budou v obchodní logice vyhodnocovat všechny jeho položky. Podle typu adresy se může zpracování adresy lišit.

Stringové literály bez konstant

Přímočarým řešením je porovnávat číselníkové kódy na očekávanou hodnotu všude tam, kde je to třeba.
Assert.assertEquals(“CZ”, address.getCountry().getCode());
Java kód plný stringových literálů ale není můj ideál, proto od tohoto způsobu radši rychle pryč. Každé řešení je ale dobré nebo špatné vždy v určitém kontextu, proto jsem pro úplnost zmínil i tuto variantu.

Stringové konstanty v doménové třídě

Dalším krůčkem k lepšímu řešení může být zavedení konstant v doménových třídách reprezentujících číselníky:
public class Country extends AbstractCatalogEntity {
public static final String CZECH_REPUBLIC = "CZ";
}
Samotný kód tedy máme definovaný na jednom místě. Jeho případné změny jsou bezbolestné, snadno můžeme vyhledávat všechna jeho použití.
Assert.assertEquals(Country.CZ, address.getCountry().getCode());
Stále ale porovnáváme řetězce a to nemusí být vždy vhodné.

Má smysl porovnávat kódy?

Má tedy porovnávání kódu vůbec smysl? No, číselníkové kódy máme reprezentované jako řetězce, takže někde se ty řetězce porovnávat musí. Otázka byla míněna jinak – má smysl porovnávat kódy v obchodní logice? Domnívám se, že ne a proto se dostáváme k dalšímu řešení.

Doménová třída s isX metodami

V obchodní logice je porovnávání číselníkových kódů typicky velmi „malým“ problémem a jeho realizace by teda mohla být podobně úsporná a čitelná. Typicky tam nejsou důležité otázky typu „jestli je daný kód rovný jinému kódu“, ale spíš jestli daný kód nebo instance doménové třídy splňuje vlastnost reprezentovanou daným číselníkovým kódem. Můžeme si proto představit následující drobné rozšíření doménových tříd.
public class AddressLink {
public static final String RESIDENCE = "RESIDENCE";
public static final String INVOICE = "INVOICE";
public static final String BRANCH = "BRANCH";
….
public boolean isResidence() {
return addressType != null && RESIDENCE.equals(addressType.getCode());
}
public boolean isInvoice() {
return addressType != null && INVOICE.equals(addressType.getCode());
}
}
IsX metody rozhodnou, jestli daná doménová třída splňuje vlastnost reprezentovanou číselníkovým kódem. Na pozadí samozřejmě musí dojít k porovnání číselníkových kódů, obchodní logiku tím ale nemusíme zatěžovat.
Assert.assertTrue(addressLink.isResidence());
Alternativně můžeme tyto konstanty a metody umístit do třídy AddressType. Záleží víceméně na osobním vkusu.

Oddělení definice číselníku od jeho hodnot

Jsou případy, kdy je nutné oddělit definici číselníků od jejich hodnot. Někdy není žádoucí aby např. třída AddressType znala svoje možné hodnoty, to znamená obsahovala konstanty s možnými hodnotami číselníkových kódů. Příkladem mohou být projekty, kdy jsou číselníkové a doménové třídy součástí nějakého frameworku nebo komponenty a mohou mít na různých zákaznických projektech různé hodnoty. Tomuto požadavku se bude věnovat zbytek článku.

Stringové konstanty v k tomu určeném rozhraní

Pokud nechceme, aby doménové třídy znaly číselníkové kódy, můžeme kódy umístit třeba do k tomu určenému rozhraní.
public interface IAddressTypeCodes {
String RESIDENCE = "RESIDENCE";
String INVOICE = "INVOICE";
String BRANCH = "BRANCH";
}
Po předchozí argumentaci je jasné, že nejsem nakloněn porovnávání kódů přímo v obchodní logice. Toto řešení ale uvádím jako nejčastěji používané (z toho, co jsem měl možnost vidět) a jako řešení, které jsem dříve automaticky používal taky. 'I' je v názvu rozhraní jenom proto, abych ho odlišil od dalších příkladů.

Rozhraní?

Někdo může namítnout, že rozhraní reprezentuje kontrakt mezi jeho implementací a klientským kódem a že proto není vhodné používat jej jako kontejner na stringové konstanty. A bude mít pravdu. Jsou číselníkové kódu součástí takového kontraktu? Ano i ne. Tento faktor chápu jako dost subjektivní, ale kloním se k tomu, že do rozhraní číselníkové kódy nepatří. Otázka typu „A proč to máš v rozhraní?“ vlastně vedla ke vzniku tohoto příspěvku.

Enum s match metodou

Od Javy 1.5 je přirozeným prostředkem k evidování konstant typ enum. Pouhé vyjmenování číselníkových kódů ale mnoho neřeší. Je vhodné vyžadovat, aby obchodní logika volala metody typu Country.getCode nebo AddressType.getCode? Jinými slovy, je vhodné, aby obchodní logika „znala“ vnitřní strukturu číselníků? Rozhodně to není nutné. Enum může vedle číselníkových hodnot obsahovat i prostředky k jejich porovnání. Takovým prostředkem může být metoda match:
public enum ECountryCodes {
CZ;

public boolean match(Country country) {
return country != null && name().equals(country);
}
}
Místo podmínek typu
Assert.assertTrue(ECountryCodes.CZ.equals(address.getCountry().getCode()));
tak můžeme dostat výrazně přehlednější kód
Assert.assertTrue(ECountryCodes.CZ.match(address.getCountry()));
Opět, 'E' je v názvu enumu jenom proto, abych ho odlišil od ostatních příkladů.

Enum s isX metodami


Abychom se úplně vyhnuli porovnávání a mohli vyhodnocovat jenom to, jestli daná instance doménové třídy splňuje vlastnost reprezentovanou číselníkovým kódem, můžeme opět použít isX metody.
public enum EAddressTypeCodes {
RESIDENCE, INVOICE, BRANCH;

public static boolean isResidence(AddressType addressType) {
return RESIDENCE.match(addressType);
}
public static boolean isInvoice(AddressType addressType) {
return INVOICE.match(addressType);
}
public static boolean isBranch(AddressType addressType) {
return BRANCH.match(addressType);
}
public boolean match(AddressType addressType) {
return addressType != null && name().equals(addressType.getCode());
}
}
Vyhodnocení číselníkových hodnot potom konečně vypadá celkem elegantně.
Assert.assertTrue(EAddressTypeCodes.isResidence(addressLink.getAddressType()));
Navíc, isX metody samozřejmě nemusí mít jako vstupní parametry jenom samotné číselníkové třídy, pro pohodlné použití a úspornější volání mohou obsahovat například i metody isCzechRepublic(Address) nebo isResidence(AddressLink).

Závěr

Tolik jednoduché zamyšlení nad porovnáváním číselníkových hodnot. Nekladl jsem si za cíl přinést úplný výčet všech myslitelných řešení, šlo mi o to popsat posloupnost úvah, které vedou k mnou preferovanému řešení – enumu s isX metodami.
Jak porovnávání číselníkových hodnot řešíte vy? Podělte se o své nápady a zkušenosti v diskuzi pod článkem.

11 komentářů:

  1. Osobne volim moznost konstant uvnitr entit.
    Osobne mi to prijde nejintuitivnejsi.

    Vyctovy typ mi k tomuto reseni uplne nesedi. Prilis mnoho prace a vysledek tomu neodpovida :)

    Samozrejme nejlepsi je, zamyslet se nad resenim, zda je onen ciselnik skutecne dobre navrzen v zavislosti na pozadavcich v apl. logice.

    OdpovědětVymazat
  2. Nekolik postrehu:
    - pro zakaznika na Slovensku bys volal isCzechRepublic() ? nebo isMyCountry()?
    - kdyz bude vyjimka napr pro zeme EU nebo neEU nebo bojkotovane zeme, mel by byt udaj v ciselniku isChina() , isTotalita()?
    - co jeste metoda AdressUtils.isCzechRepublic() ?
    To se mi ale nelibi, protoze se spatne hledaji souvislosti.
    Uf

    OdpovědětVymazat
  3. Také preferuji konstanty uvnitř entit. Cokoliv spojené s entitou hledám prvně na entitě. V případě, že použiju enum, musí na to programátor buď sám přijít anebo znát nějaké pravidlo/konvenci firmy, že se pro takovéto případy pravidelně používá enum. Až v případě, který uvádíš (pokud nechceme/nemůžeme, aby doménové třídy znaly číselníkové kódy) bych volil tvé řešení.

    OdpovědětVymazat
  4. Osobně používám kombinaci Konstant a databáze, kdy celý číselník je umístěný v DB a jen některé hodnoty jsou reprezentovány konstantami. Elegantní řešení, při kterém mi odpadá problém zobrazení číselnikových hodnot pro web aplikace, početní úkony nad číselníkem(DPH). A jednak i stare hodnoty jsou stále platné, stačí začít používat jen příznak smazání v DB.

    OdpovědětVymazat
  5. Ja pouzivam enum a casto vyuzivam i moznost implementovat k polozkam funkce, takze temer nedochazi k nejakym porovnavanim.

    Napr. mam objekty s nejakymi vlastnostmi, v GUI se zobrazuje prehled nejake vlastnosti. Udelam enum, kde jsou polozky pro kazdou vlastnost a aktualni typ zobrazeni si eviduji prave tim enumem. Cele to vypada treba takto:

    class Data {
    int age;
    int money;
    }

    enum DataType
    {
    AGE int getValue(Data d) { return d.age},
    MONEY int getValue(Data d) { return d.money},
    }

    void Draw(Data ar[], DataType t) {
    for (d : ar)
    Add(t.getValue(d));
    }

    Zhruba tak. Kdyz pridam dalsi vlastnost, staci pridat polozku do enumu, zbytek (GUI, sber statistik, ...) funguje sam.

    A na zaver - kdyz uz enum a porovnavani, tak mi pripada vhodnejsi se co nejrychleji zbavit stringu a porovnavat jen enumy. Stringy pak muzou byt bud primo u enumu jen pro prvni rozpoznani nebo v nejakem poli indexovanem tim enumem.

    OdpovědětVymazat
  6. @Anonymní (Nekolik postrehu): Vedle metody isCzechRepublic může mít třída Country i metody isSlovakia nebo třeba isRussia. Rozhodně bych v ní ale neimplementoval metody typu isMyCountry, isEU nebo isTotalita. Hranici tady vidím celkem jasně - třída Country může porovnávat svůj číselníkový kód na konkrétní hodnoty, ale už by ty hodnoty neměla nijak interpretovat. Takové rozhodnutí imho patří do servisní vrstvy nebo třeba do pomocné třídy. Např. pro rozhodnutí typu "isMyCountry" potřebuju my country znát nebo nějak identifikovat (třeba podle nastaveného locale). Takové rozhodování bych umístil jinam než do doménové třídy.

    OdpovědětVymazat
  7. @Anonymní (pravopis): Hrubku jsem opravil. Díky.

    OdpovědětVymazat
  8. @IA: Vidím rozdíl mezi používáním enumů pro zachycení hodnot určitého typu oproti použití enumů pro popis toho typu. Sice už to nesouvisí s porovnáváním číselníkových hodnot, ale v zavedení enumu DataType nevidím žádnou zásadní výhodu. Vadila by mi např. hrozící ztráta informace o typu - kdyby třída Data obsahovala navíc třeba nějaký String, Date nebo BigDecimal. Mám radši POJO. Pokud bych chtěl dosáhnout obecnosti metody Draw, dal bych před enumem DataType přednost rozhraní např. interface ValueProvider { Object getValue(Object bean); }. Ale stejně bych to asi řešil jinak.

    OdpovědětVymazat
  9. Jo, je fakt, ze kdyz se tam vyskytne vic ruznych typu, tak uz je enum k nicemu. V tom pripade se mi ale libi resit kazdy atribut implementaci nejakeho generickeho interface.
    Vzdy me v pripade napr. pridani dalsiho atributu desi, na kolik mist se musi sahnout, kde vsude bobtnaji ify a switche. Mam radsi navrh, ktery funguje pokud mozno sam, staci treba implementovat jednu tridu.

    Jinak obecne jsem rad za tenhle blog (i pribuzny na AW), pousti se do temat nad kterymi rad hloubam. Radsi tyden premysleni a jedno odpoledne implementace, nez obracene, zejo...

    OdpovědětVymazat
  10. To Tomas (Nekolik postrehu) Mate pravdu a diky za odpoved. isMyCountry() potrebuje vic nez jen ciselnik.

    OdpovědětVymazat