Code Review – Indeed Scraper
- Jarosław Piszczała
- 18 Jun, 2020
- 18 Jun, 2020
Jak wiesz, pisanie kodu to nie wszystko. Codzienna praca programisty to także przeglądanie kodu, który zespół chce dostarczyć do wspólnej bazy, a przede wszystkim kodu, który już został dostarczony. Umiejętność odpowiedniego zrozumienia kodu, z którym nie mieliśmy do czynienia może się przydać na rozmowie kwalifikacyjnej, ale przede wszystkim gdy trafimy do projektu, który ma już swoją historię. By lepiej zrozumieć ten proces, przyjrzyjmy się pewnemu repozytorium na Githubie i zróbmy mu code review.
Indeed Scraper
Będziemy przeglądać kod aplikacji Indeed Scraper, która służy do pobierania wpisów z serwisu indeed.com. Skupimy się na zmianach aż do rewizji df8f839
, czyli ostatniej rewizji na moment pisania tego artykułu. Postaram się kod rozpatrywać elementami, gdyż branie pod uwagę całego kodu w tym przypadku wygenerowałoby dużo zamieszania w artykule.
Architektura repozytorium
Należą się brawa za plik README.md
z opisem, czym jest projekt, oraz jak go używać. Przy takich małych projektach to niestety rzadkość. Testy powinny być w głównym katalogu, w katalogu scraper
powinien znajdować się wyłącznie kod tej aplikacji. Również plik main.py
powinien tam wylądować, w zamian powinniśmy korzystać z setup.py
oraz zdefiniować tam entry_points
by łatwo odpalać aplikacje z poziomu terminala. W tym momencie uruchomienie go wymaga zaimportowania modułu i uruchomienia komendy.
Inicjalizacja obiektu klasy
Metoda __init__
służy do inicjalizowania obiektu danej klasy. Jest to miejsce na przyjęcie argumentów oraz zdefiniowanie innych, które będą potrzebne przez naszą logikę. Zdecydowanie nie jest to miejsce na wywoływanie metod klasy. Aktualna implementacja powoduje, że po stworzeniu obiektu klasy Scraper dzieje się magia, i wszystko pod skorupką się wykonuje. Wywołanie konkretnych metod tej klasy powinno być jawne.
Operacje na zmiennych obiektu
Mamy tu przykład nadmiarowych metod. Jeżeli programista chciał ograniczyć dostęp bezpośredni użytkownika do zmiennej _url, lepszym rozwiązaniem jest użycie property. Jednak w tym przypadku w metodzie connect przypisujemy poprzez metodę url pod zmienną _url tylko po to, aby następnie poprzez inną metodę go odczytać.
W tym przypadku, jeżeli potrzebne nam jest zachowanie adresu w obiekcie, wystarczyłoby bezpośrednie przypisanie wartości, jeżeli jest to nam potrzebne, a następnie użycie zmiennej lokalnej spod argumentu url
.
Obsługa wyjątków
Kolejna uwaga to obsługa wyjątków. O ile powinniśmy pogratulować programiście obsługi konkretnego wyjątku, to sposób jego obsługi nie ma największego sensu. W tym przypadku nasza funkcja zwraca obiekt requests.models.Response
, a gdy wystąpi błąd, zwróci ona requests.exceptions.RequestException
. Te klasy nie mają tego samego interfejsu, co spowoduje, że nasz kod może generować masę błędów.
Dużo lepsze będzie tutaj zalogowanie błędu poprzez logger
i obsługę tej sytuacji w klasie get_content
.
Formatowanie stringów
Skoro już przy metodzie get_content
jesteśmy, to warto zwrócić dodatkowo uwagę na str
tworzony jako argument metody connect
. Jest on nieczytelny, a przecież w Pythonie tak łatwo to rozbić.
Wystarczy w nawiasach okrągłych dodać po kolei stringi, zostaną one złączone w jeden. Przy tak skomplikowanych elementach powinniśmy przenosić takie argumenty do zmiennych, to żadna oszczędność gdy brak zmiennej generuje brak czytelności.
Niepoprawne zmienne
Nazewnictwo w programowaniu jest istotne. Dlatego też, gdy programista widzi zmienne z prefiksem is_
lub are_
to oczekuje po nich, że znajdzie tam wartość logiczną True
lub False
. W tym przypadku znajdziemy tam y
albo n
, co przez Pythona zostanie potraktowane jako True
albo True
.
Lepiej byłoby od razu przekształcić wartość pobraną od użytkownika na wartość logiczną, co ułatwi następnie korzystanie z tej zmiennej w metodach.
Zależności
Klasa wstępnie wygląda na wygodną do użycia w kodzie, jednak jest tutaj zaszyta zależność, a jest nią input
.
Jeżeli reszta danych wprowadzana jest przez użytkownika przed utworzeniem obiektu klasy Scraper
, to lepiej byłoby pytać go także i o tę wartość, dzięki czemu nasza klasa byłaby łatwiejsza do użycia w testach, i nie byłoby potrzeby mockowania tego elementu podczas testów.
Możemy także pomyśleć o zamianie input
na użycie argparse.ArgumentParser
, aby móc korzystać z tej aplikacji za pośrednictwem linii poleceń.
Typowanie
Typowanie jest fajne, pomaga IDE szybciej podpowiadać metody, których możemy użyć z daną zmienną, gdyż IDE wie wtedy, czego może od takiej zmiennej oczekiwać. Jednak typy nie są domyślnie sprawdzane podczas działania kodu. I są tu pewne haczyki związane z typowaniem.
Po pierwsze, do typowania kolekcji takich jak dict
czy list
powinniśmy korzystać z obiektów, które znaleźć można w module typing
.
Poza tym, jeżeli już typujemy, to musimy robić to poprawnie. Pomijając fakt, że z funkcji nie powinniśmy zwracać różnych typów zmiennych, to w poniższym przypadku typowanie jest niepoprawne, ponieważ zwracamy z funkcji int
albo bool
. W tym przypadku powinno to być Union[int, bool]
, ale zdecydowanie lepsze byłoby zwracanie 0
w przypadku gdy nie ma stron, co ma sens logiczny, a dodatkowo zwracamy wtedy już tylko jeden typ zmiennej.
Gdy funkcja nie zwraca żadnej wartości, to według mnie informacją nadmierną jest pisanie None
jako wartości zwracanej. Jest to wartość domyślna, więc nie trzeba o niej informować. Jednak jest to tylko moja opinia.
Ze względu na to, że Python nie korzysta bezpośrednio z typowania, musimy skorzystać z zewnętrznego narzędzia, które typy przez nas wpisane zweryfikuje. Służy do tego narzędzie mypy
. W tym przypadku wykryło ono aż 27 niepoprawnie zdefiniowanych typów, i jeżeli chcemy ich używać, zdecydowanie powinniśmy to poprawić.
Skomplikowane operacje
Kiedy tworzymy funkcje, chcemy robić w nich jak najbardziej atomowe zadania, i robić to jak najczytelniej się da. Jeżeli mamy mieć w funkcji do czynienia ze złożonymi zmiennymi, to warto je odpowiednio przekształcić przed przekazaniem ich do funkcji. Zwłaszcza gdy patrząc na tę funkcję ciężko zrozumieć, dlaczego wywołujemy na zmiennych str
dodatkowe operacje.
Po przekazaniu odpowiednich wartości przy wywołaniu tej metody wyglądałaby ona jak poniżej. Czy nie jest to zdecydowanie czytelniejsze rozwiązanie?
Użycie wbudowanych funkcji
Czasem nie znając możliwości funkcji wbudowanych, próbujemy tworzyć swój własny kod, a wcale nie jest to potrzebne! W tym przypadku tworzymy listę stron z mnożnikiem 10.
Możemy to samo osiągnąć, używając wyłącznie funkcji range
, która została tutaj użyta. Jeszcze lepiej byłoby zwrócić po prostu range
jako generator, lub użyć go bezpośrednio w metodzie. Dodatkowo sama nazwa metody nie jest zgodna z jej funkcjonalnością. Funkcja ta generuje listę kolejnych elementów do wczytania, update
informuje, jakoby funkcja ta aktualizowała jakąś zmienną lub pewien zasób, a tak nie jest.
Poniższy przykład poprawy korzysta z list comprehension, aby rezultat był zgodny z typowaniem.
Powinniśmy także przenieść tę wartość magiczną jako zmienną, aby opisać, co oznacza ta liczba, na przykład jako offers_per_page
.
Pojedyncza odpowiedzialność
Nasze funkcje powinny opierać się na pojedynczej odpowiedzialności. Zwróćmy w pierwszej kolejności uwagę na nazwę metody, która sugeruje, że zwrócimy wartość logiczną. Dzieje się tak, jednak nie bezpośrednio. Co mógłbym tutaj zasugerować? Przekształcenie metody tak, aby zwracać location
lub None
. None
jest i tak traktowany jako False
, a location
jeżeli nie będzie puste, zostanie potraktowane jako True
, więc nie ma sensu zwracać nadmiarowych zmiennych.
Poza tym uważam, że z tą funkcją jest wciąż coś nie tak, ale przekształcenie jej mocniej wymagałoby wgryzienia się bardziej w kontekst.
Niepoprawne metody magiczne
Metody magiczne mają swoje konkretne cele, które spełniają w kodzie. To z góry ustalony interfejs. W tej sytuacji programista powinien skorzystać z metody magicznej __str__
w miejsce __repr__
używanego do reprezentacji obiektu w formie tekstowej. Wartość spod __repr__
powinna być możliwa do przetworzenia przez funkcje eval
. W poniższym przypadku nie możemy tego oczekiwać.
Poprawna hermetyzacja
Poprzez używanie metod publicznych, prywatnych oraz chronionych wyznaczamy interfejs dostępny dla użytkownika i klas dziedziczących. W tym przypadku metoda, która wygląda jako główna logika całej klasy, jest ustawiona, jako chroniona (poprzez podwójny znak podkreślenia) co powoduje, że użytkownik nie powinien z niej korzystać, gdyż nie nadaje się ona do użytku publicznego. To błąd, zwłaszcza gdy duża ilość metod w tej klasie nie powinna być dostępna publicznie, a jest. Warto się zastanowić w takich przypadkach, które z metod chcemy, aby użytkownik mógł używać, a które nie. Wiąże się tez z tym kwestia testowania, gdyż powinniśmy testować wyłącznie metody publiczne. Z metod chronionych powinniśmy korzystać w ostateczności!
Mieszanie poziomów metod
Gdy nasza funkcja zaczyna zajmować dużo linii, możemy być pewni jednego - gdzieś popełnimy błąd. Metoda ta traci na czytelności nie tylko poprzez dużo ukrytych zachowań, ale także przez skomplikowanie w kodzie. Mamy tutaj metody niskiego oraz wysokiego poziomu. Z jednej strony są to metody, które konkretnie opisują zachowanie więc mamy find_number_of_pages
i get_content
a następnie kod niskiego poziomu, który próbuje wyłuskać nam dane ze strony job.find(...)
. Powinniśmy taką logikę rozbić na osobne czytelne metody jak process_jobs
, get_attributes_from_job_offer
. Dodatkowo lepiej jest jawnie przekazywać zmienne między metodami niż robić to pod ukryciem, gdyż w tej logice nie mogę się doszukać, gdzie ukrył się obiekt pobranej strony.
Testy
Aby zacząć pracować nad powyższym kodem, należy napisać testy, które zapewnią nas, że nie popsujemy działającej tam logiki. Dobrym pomysłem także jest taka kompozycja naszego kodu, aby zewnętrzne zależności móc zastąpić adapterem. W tym przypadku pomyślano o “ala” adapterze, czyli odziedziczono po klasie Scraper
i nadpisano problematyczne metody. I byłoby to bardzo dobre rozwiązanie, gdyby nie nadpisanie metody __init__
zmieniając jego nagłówek oraz kod w nim wywoływany, a także stworzenie metody find_jobs
, która prawdopodobnie miała nadpisać metodę oryginalnej klasy.
Reszty testów nie będę opisywać, ponieważ ze względu na skomplikowaną logikę i powyższe nadpisanie głównej logiki klasy - testy niczego konkretnego nie testują.
Uwagi końcowe
Czy to wszystko? Zapewne nie, naprawa tych rzeczy będzie wymagać zmian w wielu miejscach w kodzie, co oznacza, że niektóre elementy zostaną usunięte. Ponieważ ja mogłem coś pominąć, to oznacza, że osoba programująca też mogła nie zauważyć pewnych zależności. To właśnie dlatego dla utrzymania jakości tak ważne jest code review w procesie. Poza tym opisywanie wszystkich bolączek spowodowałoby rozrost i tak już długiego artykułu.
Czy jest to zły kod? Odpowiedź jest jedna - nie. Jest to dobry kod - bo działa, i rozwiązuje problem. To, co można powiedzieć o nim, to że jest nieoszlifowany i brak autorowi pewnego doświadczenia. Gdy się uczymy ciężko o perfekcje, bo jeszcze nie wiemy, co to oznacza, więc musimy się skupić na tym, aby kod działał, a to autorowi udało się w pełni.
Jeżeli potrzebujesz, aby ktoś przyjrzał się Twojemu repozytorium, skontaktuj się z nami!