Kotlin i Ktor - obsługa błędów oraz prawidłowa architektura dla endpointów

0

Piszę aplikację (przy okazji uczę się) z wykorzystaniem Kotlin'a i Ktor'a. Sam Ktor póki co jak najbardziej na plus ale obsługa requestów w taki sposób:
https://ktor.io/servers/features/routing.html#routing-tree
dla mnie jest kompletnie nieczytelna kiedy endpointów jest już sporo i są bardziej rozbudowane. Podzieliłem więc endpointy na kilka metod (np.: fun Route.accountEndpoints() {} ) i dodałem fasady po stronie której wykonuję walidację, logikę itp a po stronie routingu Ktor'a parsuje jedynie request i zwracam wyniki. Wygląda lepiej ale kiedy np. chcę zarejestrować nowe konto muszę sprawdzić czy 1. adres email jest unikatowy, 2. pola są poprawnie wypełnione. Fasada próbuje zarejestrować konto ale jak się to nie uda leci wyjątek który po stronie routingu trzeba obsłużyć:

 route("/accounts") {
        post {
            val accountCmd = call.receive<AccountCmd>()

            try {
                call.respond(HttpStatusCode.Created, facade.save(accountCmd))
            } catch (ex: AccountAlreadyExistExceptions) {
                call.respond(HttpStatusCode.Conflict, ex)
            }
        }

To działa ale wygląda słabo tym bardziej że tych case'ów kiedy lecą różne wyjątki może być wiele i później będzie jakieś dziwne drzewo try catchów.
Ma ktoś pomysł jak to rozwiązać? Może w ogóle źle do tego podszedłem? Trochę sam kombinuje bo nie mogę znaleźć na necie żadnego dobrego przykładu dla Ktor'a. Szczególnie wołam @jarekr000000 i @Bambo bo wiem że mogą coś wiedzieć na jego temat.

1

Można rozbić handle do innych klas. Nie znam się na ktorze, ale podam przykład projektu, gdxie tak to jest robione:
https://github.com/komunumo/komunumo-backend

0
jarekr000000 napisał(a):

Można rozbić handle do innych klas. Nie znam się na ktorze, ale ppdam przykład projektu, gdxie tak to jest robione:
https://github.com/komunumo/komunumo-backend

Pomysł jest spoko i też tak nawet robiłem (trochę jedynie słabo że tego call'a trzeba zawsze przekazać) tylko nie byłem przekonany czy to na pewno jest poprawnie ale skoro postujesz to rozumiem że rozwiązanie do przyjęcia.
A co z tymi wyjątkami? Z tego co widzę w tym projekcie to jest dokładnie to samo:

  suspend fun handlePut(call: ApplicationCall) {
        val id = call.parameters["id"]!!
        authorizeMember(call, id)
        val user = call.receive<User>().copy(id = id)
        try {
            call.respond(UserService.update(user))
        } catch (e: NoSuchElementException) {
            call.respond(HttpStatusCode.NotFound)
        } catch (e: ConcurrentModificationException) {
            call.respond(HttpStatusCode.Conflict)
        }
    }

Pewnie działa ale nadal uważam że takie try'owanie jest słabe.

1

Możesz zaisntalować funkcję do łapania błędów. https://ktor.io/servers/features/status-pages.html

5

@eL:

A może zamiast rzucania wyjątkami zrobisz sobie jakiś obiekt AppError, który będzie zawierał enuma ErrorReason i message i z metod fasad, które mogą się wysypać będziesz zwracał Eithery ? Ja już w swoich projektach nie rzucam wyjątków ... zwracam właśnie Eithery i mam jedną klasę ResponseResolver, w której mapuję ErrorReasony na kody http.

Coś takiego:

class ResponseResolver {

    private final static Map<ErrorReason, HttpStatus> HTTP_STATUS_MAP =
            HashMap.of(
                    ErrorReason.COUNTRY_NOT_SUPPORTED, HttpStatus.BAD_REQUEST,
                    ErrorReason.COUNTRY_CANNOT_BE_NULL, HttpStatus.BAD_REQUEST,
                    ErrorReason.FORM_CANNOT_BE_NULL, HttpStatus.BAD_REQUEST,
                    ErrorReason.INCOME_CANNOT_BE_NULL, HttpStatus.BAD_REQUEST,
                    ErrorReason.INVALID_INCOME_VALUE, HttpStatus.BAD_REQUEST,
                    ErrorReason.INVALID_INCOME_FORMAT, HttpStatus.BAD_REQUEST
            );

    <T, R> ResponseEntity<?> resolve(final Either<AppError, T> either, final Function1<T, R> mapper) {
        return either
                .map(mapper)
                .map(this::createObject)
                .getOrElseGet(this::createError);
    }

    private ResponseEntity<Object> createObject(final Object object) {
        return new ResponseEntity<>(object, HttpStatus.OK);
    }

    private ResponseEntity<Object> createError(final AppError error) {
        return new ResponseEntity<>(error, HTTP_STATUS_MAP.getOrElse(error.getErrorReason(), HttpStatus.BAD_REQUEST));
    }

}
1

@Bambo: No muszę przyznać że brzmi ciekawie. Sam też myślałem o czymś w podobnych stylu ale jakoś nie mogłem tego zebrać do kupy. Co prawda ja piszę w Kotlinie więc u mnie będzie to trochę inaczej ale myślę że przykład jest okej żeby z niego skorzystać.
Jedyny minus to widze że jak wszystko się powiodło to zawsze rzucasz status 200. Ogólnie ok bo najczęściej tak będzie ale akurat przy tworzeniu nowych zasobów wolę rzucać 201. Tak czy inaczej dopracuje to pod swój case i myślę że będzie śmigać;) Dzięki.

EDIT.
@Bambo Kotlinowa implementacja bez Springa działa wyśmienicie :). Muszę przyznać że pomysł naprawdę ciekawy i całkowicie eliminuje try catche. Dzięki bardzo!

0

@Bambo: A mam jeszcze jeden case dla Ciebie. Either jest spoko ale tam gdzie musisz zwrócić jakąś wartość. Są jednak sytuacje kiedy chcesz coś przetworzyć bez żadnego returna ale coś ewentualnie po drodze może się wysypać. Wówczas Either się do tego nie nadaje a wyjątki uznaliśmy że nie są najlepszą drogą.
Masz na to jakiś pomysł? Obszedłem to tworząc coś podobnego na wzór Either ale bez drugiego parametru ale chętnie poznam Twoje zdanie.

0

@eL:

Może po prostu zwracać Try<Void> i wyżej obsłużyć ?

0

@Bambo: nie bardzo to widze szczerze mówiąc.
Powiedzmy że taka prosta sytuacja. Ktoś zarejestrował konto, dostał na maila link z aktywacją. Link wygląda mniej więcej tak: api.com/account/123/active/sda8f7asd87f6a7s8d6f78as6d78f6a
W linku jest ID użytkownika i jakiś token weryfikacyjny. Po kliknięciu w link flow może wyglądać tak:

  1. ID - 123 nie istnieje w bazie
  2. Token nie istnieje w bazie ( lub nie jest powiązany z tym id)
  3. Token wygasł
    Lub po prostu wszystko gra i konto jest aktywowane.

Trzeba więc obsłużyć 3-4 ewentualne błędy. Jeśli któryś z nich się wysypie to rzucam tak jak wcześniej poleciłeś AppError który ma opis i kod http.
Jeśli wszystko przebiegło bez problemu to leci 200 lub 201 i jest okej, nie ma żadnego body.

Mam więc endpoint który przyjmuje taki request i przekazuje te 2 parametry do jakiejś tam fasady w której wszystkie powyższe kroki są spradzane. Metoda ta musi też coś zwrócić do kontrolera żeby wiedzieć jaki status i ewentualnie body o błędzie ustawić. Ten Try mógłby wyjśc mocno pokręcony choć może ja jeszcze zbyt dobrze nie umiem tego używac.

2

To co podałeś to dla mnie typowy przykład na Eitherlub po prostu enum ze Statusem czyli zamiast Either<MyError,Void> zrób klasę ResultStatus z wartościami Ok, Problem1, Problem2 (np. Jako enum).

Polecam używać Try tylko w sytuacjach, gdzie współpracujemy ze starym api co rzuca wyjątkami.

0

No tak, w sumie @jarekr000000 odpowiedział najlepiej. W Kotlinie masz typealiasy dodatkowo,

ja zrobiłem sobie:

Attempt<K> = Either<AppError, K>

Możesz też dodać analogicznie dla

Attempt<Void>

:)

0

@jarekr000000: ok z tym statusem spoko, rozumiem ale napisałeś też:

jarekr000000 napisał(a):

To co podałeś to dla mnie typowy przykład na Either

jarekr000000 napisał(a):

Either<MyError,Void>

i tu miałem problem bo próbowałem to tak właśnie zaimplementować a zapomniałem że w Kotlinie (pisze od niedawna) zamiast Void używa się Unit i przy Void czekiwał też ciągle ode mnie jakiejś wartości... Zrobiłem więc tak:

Either<AppError, Unit> Jak są błedy to leci left, jak jest wszystko ok to zwracam tylko return Either.right(Unit) Mam nadzieje że ma to sens ;)

No i zdecydowanie bardziej przypadł mi do gustu ten Either. Super sprawa. Dzięki Panowie!

1 użytkowników online, w tym zalogowanych: 0, gości: 1