Jeśli wybierasz się na rozmowę rekrutacyjna to na pewno zainteresuje Cię zadanie rekrutacyjne Java jakie dostałem do rozwiązania. Czas na zaimplementowanie zadania to 60 minut. Frameworki i biblioteki do wykorzystania dowolne. Opiszę tutaj jak to zrobiłem w trakcie interview oraz jakbym to zrobił w sytuacji mniej stresującej.
Treść zadania rekrutacyjnego Java:
Korzystając z publicznego REST API: https://jsonplaceholder.typicode.com napisz aplikację klienta, która będzie pobierać listę osób (users) wraz ich zadaniami do wykonania (todos). Aplikacja powinna pobierać i wypisywać pobrane dane cyklicznie co 5 sekund.
Aplikacja powinna być odporna na błędy połączenia z API.
Pobrane dane powinny być wydrukowane w następującej postaci:
User #{id} ({userName}) [*] task: {title} [ ] task: {title} ...
Przykład:
User #7 (Elwyn.Skiles) [*] task: inventore aut nihil minima laudantium hic qui omnis [*] task: provident aut nobis culpa [ ] task: esse et quis iste est earum aut impedit [ ] task: qui consectetur id [ ] task: aut quasi autem iste tempore illum possimus
Wytyczne
Brak. Niestety pomimo tego, że dopytywałem rekruterów czy lepiej aby zadanie było zrobione jak najszybciej czy jak najładniej to niestety nie uzyskałem konkretnej odpowiedzi. Dla rekruterów, w teorii, najważniejsze było aby zadanie było kompletne (skończone) czyli żeby działało.
Strategia
Ponieważ najważniejsze było stworzenie działającego prototypu stwierdziłem, że spróbuję stworzyć coś co działa i realizuje założenia a potem (jeśli będę miał na to czas) będę się martwił jak upiękrzyć poprzez refactoring.
Wstępna analiza
Stwierdziłem, że najlepiej będzie zabrać się za zadanie etapami:
- Klient REST
- Mapping obiektów domenowych
- Wydrukowanie danych w odpowiednim formacie
- Cykliczne wykonywanie zadania
Rozwiązanie:
Zadanie rozpocząłem od wygenerowania projektu z generatora Spring Initializer. Dzięki temu miałem od razu szkielet projektu wraz z wymaganymi zależnościami. Stwierdziłem jednak, że dopóki nie będę wyraźnie potrzebował framework-a Spring to nie będę go używał. Okazało się, że Spring jednak nie był potrzebny.
1. Klient REST
Pierwsze co przyszło mi do głowy to pytanie jakiej biblioteki najlepiej użyć aby połączyć się po REST. I tutaj pomyślałem od razu o nowości w JDK 11 i nowym kliencie HTTP.
Przy okazji jeśli jesteś zainteresowany zmianami licencyjnymi Java od wersji 11 przeczytaj mój post na temat tego czy Java jest nadal darmowa?
Po chwili jednak namysłu stwierdziłem, że ponieważ nie używałem tego API wcześniej wiec lepiej będzie użyć coś sprawdzonego o czym będzie łatwo znaleźć informacje więc zdecydowałem się na Apache HTTP Client.
HttpHost target = new HttpHost("jsonplaceholder.typicode.com", 80, "http"); HttpGet getRequest = new HttpGet("/users"); HttpResponse httpResponse = httpclient.execute(target, getRequest);
2. Mapping obiektów domenowych
Do zmapowanie danych JSON zwróconych przez REST API użyłem biblioteki GSON.
String jsonStations = EntityUtils.toString(entity); Type listType = new TypeToken<ArrayList<User>>() {}.getType(); return new Gson().fromJson(jsonStations, listType);
3. Wydrukowanie danych w odpowiednim formacie
Wymaganie co do tego jak ma wyglądać output jest na tyle proste, że do wydrukowania zwróconych danych użyłem najprostszej metody czyli System.out.println oraz metody String.format.
for (User user : users) { System.out.println( "User #" + user.getId() + " (" + user.getUsername() + ")" ); List<Todo> todos = getTodosByUserId(user.getId()); for (Todo todo : todos) { System.out.println(String.format("\t[%s] task: %s", todo.getCompleted() ? "*" : " ", todo.getTitle())); } }
Dodatkowo w podsumowaniu zadania stwierdziłem, że można by tutaj użyć jakiegoś systemu szablonów typu Velocity lub Freemarker.
Myślę, że kolejną alternatywą było by tu wykorzystanie loggera (np. Logback).
4. Cykliczne wykonywanie zadania
Jak do tej pory w trakcie rozwiązywania zadanie nigdzie nie potrzebowałem używać framework-a Spring. Uznałem, że skoro do tej pory nie był potrzebny to bez sensu go dodawać tylko po to aby użyć adnotacji @Scheduled
. Stworzyłem więc w prymitywny sposób nowy wątek z pętlą while:
Thread t = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { printUsersWithTasks(); try { Thread.sleep(TimeUnit.SECONDS.toMillis(10)); } catch (InterruptedException e) { e.printStackTrace(); } } }); t.start();
Podsumowanie rozwiązania
Jak się okazało do rozwiązania całego zadania niepotrzebnie założyłem, że będzie potrzebny Spring. Zadanie rekrutacyjne z Java było na tyle proste, że obeszło się bez niego. Czy jednak było to rozwiązanie oczekiwane?
Rozwiązanie zadania zajęło mi około 30 minut. Po tym czasie omówiłem co bym ulepszył i zapytałem czy wykonać zaproponowane zmiany:
- Wspomniałem o brakujących testach i stwierdziłem, że jeśli zadanie nie było by ograniczone czasowo i priorytet byłby ustawiony na jakość a nie finalne działanie kodu to rozpoczął bym od napisania testów.
- Fallback oraz dodanie error handling-u w przypadku niepowodzenia wykonania call-a REST-owego i zwracanie rezultatu np. z lokalnego cache-a.
- Zamiana ręcznego startowania wątku na inny mechanizm np. adnotację Spring @Scheduled
Jak bym to zrobił gdybym miał to zrobić ponownie? Czyli jak rozwiązać zadanie rekrutacyjne Java po rekrutacji.
1. Generowanie szkieletu
Rozwiązując podane zadanie rekrutacyjne z Java drugi raz, również rozpoczął bym od wygenerowanie szkieletu z udziałem generatora Spring Initializr.
Z dependencji na początek dodam tylko:
- Lombok
- OpenFeign
Rozplanowanie komponentów
Czyli stworzenie struktury nie tylko pakietów ale również klas, które będą stanowić rozwiązanie zadanego problemu. To właśnie coś czego zabrakło podczas tworzenia mojego rozwiązania na rozmowie kwalifikacyjnej. W trakcie kodowania skupiłem się za bardzo na celu a nie na drodze do jego osiągnięcia.
Warstwę logiki aplikacji podzielmy na poniższe komponenty:
Scheduler
– Klasa odpowiadająca tylko i wyłącznie za cykliczne wykonywanie zadaniaFallbackSupportService
– Klasa w której dzieje się najwięcej. Jest tu obsłużenie sytuacji wyjątkowej gdzie w przypadku braku połączenia z serwisem REST zwracamy dane trzymane w kopii zapasowej.UsersProvider
– Klasa odpowiedzialna za dostarczanie danych o użytkownikach i ich zadaniach ToDo.Printer
– Klasa pomocnicza służąca do wydrukowania pobranych danych przy użyciu Loggera.
2. Deklaratywny klient REST
Jedną z najbardziej kontrowersyjnych decyzji w tym zadaniu jest wybór klienta REST. Opcji jest dość sporo, i każda znajdzie swoich zwolenników jak i przeciwników. Do wyboru mamy między innymi:
Po rozpatrzeniu wszystkich opcji zdecydował bym się na użycie Feign zamiast Apache HTTP Client. Kod dzięki użyciu tej biblioteki stanie się bardziej czytelny i odrazu będziemy mięć podział na komunikację REST i logikę biznesową.
@FeignClient(name = "usersClient", url = "https://jsonplaceholder.typicode.com") public interface UsersClient { @GetMapping("/users") List<User> users(); } @FeignClient(name = "tasksClient", url = "https://jsonplaceholder.typicode.com") public interface TasksClient { @GetMapping("/todos") List<Task> getTasksBy(@RequestParam("userId") Integer userId); }
2. Domena bez zmian (prawie)
Jeśli chodzi o klasy domenowe odpowiadające za przechowywanie danych z otrzymanego JSON-a to pozostały by takie jak w oryginalnym rozwiązaniu. Z jedną zmianą: W klasie User dodał bym listę zadań, które należą do danego użytkownika.
@Data public class User { Integer id; String name; String username; String email; List<Task> tasks; // here is the change } @Data public class Task { Integer id; Integer userId; String title; Boolean completed; }
3. Printer jako osobny komponent
Po zastanowieniu uznałem, że wydrukowanie danych na ekran, choć bardzo prymitywne, powinno być jednak odseparowane do osobnej klasy. Dodatkowo zrezygnowałem z systemowego println
na rzecz loggera.
@Service @Slf4j public class Printer { void printUsersWithTasks(List<User> users) { users.stream().forEach(user -> { log.info(MessageFormat.format("User #{0} ({1})", user.getId(), user.getUsername())); printTasksFor(user); }); } private void printTasksFor(User user) { user .getTasks() .stream() .map(task -> MessageFormat.format("\t[{0}] task: {1}])", task.getCompleted() ? "*" : " ", task.getTitle())).forEach(log::info); } }
4. Prawdziwy Scheduler
Skoro i tak w rozwiązaniu alternatywnym wszędzie korzystam z dobrodziejstw frameworka spring, więc tym razem oczywistością jest użycie adnotacji @Scheduled do ustawienia cyklicznego wykonywania zadania.
@Component public class Scheduler { private final FallbackSupportService fallbackSupportService; public Scheduler(FallbackSupportService fallbackSupportService) { this.fallbackSupportService = fallbackSupportService; } @Scheduled(fixedDelay = 10000) public void logAllUsersTasks() { fallbackSupportService.getUsersAndPrint(); } }
5. Wsparcie dla niepowodzenia
I na koniec coś czego nie zakodowałem w oryginalnym rozwiązaniu – Fallback support. Po napisaniu zadania i stwierdzeniu, że działa ono według wytycznych wspomniałem jedynie, jak można by dodać obsługę takich sytuacji niespodziewanych gdzie nasz serwis od którego jesteśmy zależni przestaje działać. Teraz natomiast pokażę jak bym to obsłużył:
@Slf4j @Component public class FallbackSupportService { private final UsersProvider usersProvider; private final Printer printResultService; public FallbackSupportService(UsersProvider usersProvider, Printer printResultService) { this.usersProvider = usersProvider; this.printResultService = printResultService; } private List<User> backupUsers = null; @HystrixCommand(fallbackMethod = "printCachedResults", commandProperties = { @HystrixProperty( name = "execution.timeout.enabled", value = "false"), @HystrixProperty( name = "circuitBreaker.requestVolumeThreshold", value = "1"), @HystrixProperty( name = "circuitBreaker.errorThresholdPercentage", value = "10") }) void getUsersAndPrint() { backupUsers = usersProvider.getUsersWithTasks(); printResultService.printUsersWithTasks(backupUsers); } @SuppressWarnings("unused") public void printCachedResults() { if (null != backupUsers) { log.warn("Current data are from backup!"); printResultService.printUsersWithTasks(backupUsers); } else { log.error("No results... try again later..."); } } }
FallbackSupportService jest odpowiedzialny za wykonanie całego zadania a jeśli coś pójdzie nie tak (brak połączenia REST) to niech serwis sobie radzi i wydrukuje dane poprzednio pobrane.
Użyłem tutaj bardzo wygodnego rozwiązania jakim jest Circut Braker – Hystrix. Gdzie możemy podać w adnotacji metodę fallback-ową, która będzie wołana w przypadku gdy danych obwód zostanie otwarty z powodu zbyt dużej ilość niepowodzeń.
Hystrix udostępnia również dashboard na którym możemy monitorować status obwodu:
Hystrix dashboard możemy aktywować poprzez dodanie odpowiedniej adnotacji:
@SpringBootApplication @EnableScheduling @EnableFeignClients @EnableCircuitBreaker @EnableHystrixDashboard public class RestclientApplication { public static void main(String[] args) { SpringApplication.run(RestclientApplication.class, args); } }
Podsumowanie
Jak widać stres i ograniczony czas bardzo wpływa na to jakie rozwiązanie możemy dostarczyć gdy mamy przed sobą takie czy inne zadanie rekrutacyjne z Java.
Druga kwestia to wyczucie czego oczekuje się od takiego rozwiązania. Dla jednej osoby najważniejsze będzie aby zadanie po prostu działało a dla drugiej zadanie nie musi być skończone ale powinno być perfekcyjnie napisane. Kwestia preferencji.
Trzecia rzecz to jakich technologii użyjemy. Jednym może się podobać zrealizować tak prostego zadania przy użyciu tylko i wyłącznie core Java i nie używanie żadnych bibliotek pomocniczych. Ktoś inny będzie oczekiwał użycia najnowszych framework-ów aby kod był zwięzły i czytelny.
Więcej…
Jeśli zainteresowało Cię to zadanie rekrutacyjne i chciałbyś zobaczyć więcej to zapraszam na kolejny wpis dotyczący zadań rekrutacyjnych tym razem dwu tygodniowe zadanie dla Full Stack Developera: Zadanie Rekrutacyjne Java w 2 tygodnie.
Świetny wpis. Fajna konstrukcja artykułu z wnioskami i dwoma rozwiązaniami. Brakuje mi w polskiej blogosferze javy takich moco inspirujących wpisów. Też jutro siadam do tego zadania tylko z innymi bibliotekami, których do tej pory nie znałem a tutaj wymieniłeś. Może warto abyś wrzucał podobne wpisy częściej? Co tydzień tekst zadania i rozwiązanie na szybko. A za kilka dni rozwiązanie po Bożemu. To byłoby super, szeroki rozwój w ramach jednego wpisu na blogu!
Bardzo fajny wpis, możesz dodać link do GH? Według mnie nie wziąłeś pod uwagę trzech rzeczy, klient wspierający http 2.0, klient z nieblokującym IO, a także timeoutów socketa, reada, czy też ustawień trzymania połączenia. Bardzo fajną prezentację na tej temat miał Adam Dubiel ze trzy lata temu na Confiturze.
Dzieki za wpis! Bardzo ciekawie wiedziec czego chca na rozmowach – oby takich wiecej 🙂
Ja swoje rozwiazanie w jednej klasie w ciagu 45 minut podaje – troche sie meczylem, bo zapomnialem o @EnableScheduling i \n w printf 😀
@Service
public class ApiService {
private final WebClient webClient;
public ApiService(WebClient.Builder webClientBuilder) {
this.webClient = WebClient.builder().build();
}
@Scheduled(fixedDelay = 5000)
public void perform() {
List users = getUsers();
List tasks = getTasks();
tasks.forEach(task -> {
users.get(task.getUserId() - 1).getTasks().add(task);
});
printUsersTaskStatus(users);
}
private List getUsers() {
User[] usersArray = webClient.get().uri("https://jsonplaceholder.typicode.com/users")
.retrieve()
.onStatus(HttpStatus::isError, response -> {
System.out.println("Error");
return Mono.error(new RuntimeException("Error"));
})
.bodyToMono(User[].class).block();
return Arrays.asList(usersArray);
}
private List getTasks() {
Task[] tasksArray = webClient.get().uri("https://jsonplaceholder.typicode.com/todos")
.retrieve()
.onStatus(HttpStatus::isError, response -> {
System.out.println("Error");
return Mono.error(new RuntimeException("Error"));
})
.bodyToMono(Task[].class)
.block();
return Arrays.asList(tasksArray);
}
private void printUsersTaskStatus(List users) {
users.forEach(user -> {
System.out.printf("User #%d (%s) \n", user.getId(), user.getUsername());
user.getTasks().forEach(task -> {
String status = Boolean.parseBoolean(task.getCompleted()) ? "*" : " ";
System.out.printf("\t[%s] task: %s \n", status, task.getTitle());
});
});
}
}
Dobry tekst. Czekam na więcej z zakresu zadań rekrutacyjnych 😃
Na stanowisko na jakim poziomie aplikowałeś Junior / Regular ? Ciekawi mnie na ile ogólnie znajomość Springa jest wymagana na stanowiskach Juniorskich.
Dlaczego nie użyłes RestTemplate ? Myslę że to najmniej problemy sposób
Nie wiem 🙂 limit czasowy, stres, zbyt dużo możliwości do wyboru. A dlaczego Ty byś użył RestTemplate?
Myślę właśnie że dlatego co napisałeś wyżej. Używając springa nie trzeba sciagać dodatkowych bibliotek. Jest fajne mapowanie i myślę że ogólnie do zadan gdzie tych zapytań nie ma bardzo dużo to jest fajne rozwiązanie.