Walidacja kraju czyli ISO 3166 country codes w praktyce

kod SK na słowackiej tablicy rejestracyjnej

Być może mamy bazę kontrahentów z różnych stron świata. Albo obsługujemy zakładanie konta użytkownika i wymagamy podania miejsca zamieszkania. Nawet jeśli nie pracujemy w Netfliksie, wcześniej czy później pewnie będziemy musieli zająć się przetwarzaniem danych o krajach.

Jak to przechować? Pole typu „dowolny string” spowoduje, że nasi użytkownicy wkrótce wrzucą tam zarówno „United States”, jak i „U.S.”, „U.S.A”, a nawet „Niemcy” (przynajmniej ja przeszedłem przez takie nieprzyjemne odkrycie). Lepszym wyjściem jest reprezentowanie kraju przez jednoznaczny identyfikator. Na szczęście istnieje ogólnie akceptowalny standard, ISO 3316, czyli w dużym uproszczeniu to, co widzimy w URL-ach albo na tablicach rejestracyjnych: „PL” to Polska, „DE” to Niemcy i tak dalej.

W dalszej części bardzo skrótowo objaśnię kody krajów w ISO 3316. Opiszę też, jak zaprogramować walidację poprawności kodów. Proponuję dwie opcje: z użyciem klasy Locale dostarczanej z JVM oraz przez zewnętrzną bibliotekę nv-i18n.

Standard ISO 3166 w 2 minuty

  • pomysł: niech każdy kraj dostanie swój kod
  • kod składa się z liter, bo zwykłym ludziom łatwiej je skojarzyć ze znaczeniem niż jakieś kombinacje cyfr czy innych znaków
  • PL bo Polska/Poland, DE bo Deutschland, ES bo España – i mniej więcej tak to idzie
  • jeśli kojarzysz dla danego kraju jego domenę internetową, to z dużą pewnością znasz jego kod ISO (wyjątki są nieliczne)
  • 2 znaki wystarczą do ogarnięcia wszystkich obecnie istniejących krajów świata (standard ISO 3166-1 alpha-2)
  • są też kody 3-literowe (alpha-3) oraz 4-literowe (ISO 3166-3); 4-literowe lepiej ogarniają kraje z przeszłości
  • niektóre kody 2-literowe są w standardzie, ale nie oznaczają istniejącego obecnie kraju (np. EZ „Eurozone”); zdarza się też, że kody są rezerwowane na kraj, który już przestał istnieć
  • standard alpha-2 możemy trzymać w bazach SQL-owych jako CHAR(2), to zawsze dwa znaki, nie ma czegoś takiego jak w tablicach rejestracyjnych, gdzie Polska to PL, ale Niemcy to jedna litera D

Tyle wystarczy na potrzeby tego artykułu. Skupiam się na kodach 2-literowych, bo są najprostsze, a do tego są preferowane przez obie wybrane przeze mnie implementacje dla Javy. Dla chcących dowiedzieć się więcej polecam bardzo przejrzyście napisany artykuł na Wikipedii: ISO 3166-1 alpha-2.

java.util.Locale

Biblioteka standardowa Javy implementuje ISO 3166 alpha-2, więc możemy przetwarzać i walidować kody krajów bez potrzeby korzystania z zewnętrznych bibliotek.

final class LocaleBasedValidator {

    private static final List<String> alpha2Codes =
            Arrays.asList(Locale.getISOCountries());

    static void validateCountry(String country) {
        if (!alpha2Codes.contains(country)) {
            throw new WrongLocale("not a valid country code: " + country);
        }
    }
}

Jeśli chodzi o zamianę kodu kraju na nazwę zrozumiałą dla człowieka, trzeba się trochę opisać, bo Locale nie ma metody, żeby zrobić to wprost:

logger.info(
        "Code for {} is {}",
        customer.country(),
        new Locale(
                "en", 
                customer.country()
        ).getDisplayCountry(Locale.ENGLISH)
);
Code for US is United States

Wszystko ładnie, tylko jedna rzecz trochę śmierdzi: nasz kod jest stringly typed, cierpi na primitive obsession:

public record Customer(String name, String country) {
}

Gdy patrzymy na ten rekord, nie widać, jaki jest format pola country (2 znaki? 3 znaki? 4? wielkie czy małe litery?), nie ma też wbudowanego sprawdzania poprawności, walidację musimy zaprogramować ręcznie. Jeśli zapomnimy wywołać walidacji, do pola można zapisać dowolne śmieci.

Przydałby się osobny typ reprezentujący kod kraju. Zamiast pisać go samemu, możemy skorzystać z biblioteki.

nv-i18n

Bibliotekę można znaleźć na GitHubie, dołączamy ją przykładowo tak:

implementation("com.neovisionaries:nv-i18n:1.29")

Dostajemy porządny typ do użycia w naszych encjach:

public record Customer2(String name, CountryCode country) {
}

Jest to enum, więc odrzuca niepoprawne wartości, a my nie musimy pisać ani linijki kodu:

DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `com.neovisionaries.i18n.CountryCode` from String "USA": not one of the values accepted for Enum class: [PR, PS, PT, PW, PY, QA, AC, AD

Kod wypisujący nazwę zrozumiałą dla człowieka jest krótszy:

logger.info(
        "Code for {} is {}",
        customer.country(),
        customer.country().getName()
);

Ale też produkuje pokracznie oficjalne nazwy:

Code for VE is Venezuela, Bolivarian Republic of

Z tego powodu pisząc kod produkcyjny zdecydowałem się na rozwiązanie hybrydowe: używam typu CountryCode z nv-i18n, ale do przekształcania go na zwykłą nazwę kraju używam java.util.Locale.

Jest jeszcze jedna pułapka: enum CountryCode akceptuje wszystkie kody przewidziane w aktualnym standardzie, w tym kody typu YU (Jugosławia) i EZ (Eurozone), które nie odnoszą się do istniejących teraz krajów. Jeśli chcemy mieć pewność, że chodzi o miejsce, które wydaje ważne paszporty, potrzebny będzie dodatkowy kod walidacji:

static void validateCountry(CountryCode countryCode) {
    if (countryCode.getAssignment() != CountryCode.Assignment.OFFICIALLY_ASSIGNED) {
        throw new WrongLocale("assignment of " + countryCode + " is " + countryCode.getAssignment());
    }
}
assignment of YU is TRANSITIONALLY_RESERVED

nv-i18n przechowuje dla każdego kodu jego „sposób przypisania” (assignment). Różne sposoby są opisane w standardzie (zapraszam na Wikipedię). Przykładowo, YU to „transitional reservation” nieistniejącej już Jugosławii, a UK to „exceptional reservation” dla Wielkiej Brytanii (powinno się używać kodu GB).

Mamy tu różnicę z implementacją z java.util.Locale: biblioteka standardowa Javy jest bardziej minimalistyczna, przechowuje tylko kody istniejących państw, nie trzeba dodatkowo sprawdzać, czy to kod „oficjalnie” przypisany, czy „w drodze wyjątku” (nawet jest to niemożliwe, bo nie ma tam potrzebnych danych). Z kolei nv-i18n wymaga dopisania dodatkowej walidacji, ale też daje dostęp do kodów używanych w przeszłości oraz specjalnego typu.

Podsumowanie

Dwuliterowe kodu krajów to szeroko obsługiwany standard. Możemy korzystać z nich z pomocą biblioteki standardowej Javy (klasa java.util.Locale). Możemy też wykorzystać zewnętrzną bibliotekę nv-i18n, która dostarcza typ specjalnie przeznaczony do reprezentowania kodu kraju. Korzystając z nv-i18n trzeba pamiętać, że wartości legalne dla enuma obejmują więcej, niż zazwyczaj chcemy akceptować, na przykład są tam jednostki niebędące państwami, są też kody krajów, które przestały istnieć.


Zdjęcie wykonał Krokodyl, na licencji CC BY 2.5.

Creative Commons License
Except where otherwise noted, the content by Piotr Kubowicz is licensed under a Creative Commons Attribution 4.0 International License.