Zawodowo zgrzewam plastik. Nie w czystym laboratorium z reklamy, tylko nad gorącym pistoletem, w pomieszczeniu, które już samo z siebie jest za ciepłe. Łączę części, które po pracy nie mają się rozpaść. Wieczór jest drugą połową dnia. Wtedy powstaje PC Workman.
Projekt zaczął się od małego skryptu obserwującego przegrzewający się komputer. Nie było pieniędzy na zgadywanie, dlaczego laptop obniża taktowanie. Później doszedł lokalny asystent hck_GPT, model temperatur uczący się konkretnej maszyny i w końcu publikacja w Microsoft Store.
Sklep nie zmienił kodu. Zmienił sposób, w jaki go czytałem
W dniu publikacji żaden plik w repozytorium nie poruszył się sam. Nadal były te same funkcje i ten sam autor. Zmieniła się odpowiedzialność. Ktoś w innym kraju mógł nacisnąć Instaluj i uruchomić program na własnym zasilaczu, własnym CPU i własnych temperaturach. Projekt przestał być prywatną zabawką.
Z takim momentem przychodzi rachunek. Gdy widzisz kod jako coś, od czego zależy obca osoba, trudniej po raz kolejny przewinąć plik omijany od roku.
Plik, którego nie chciałem otwierać
Nazywał się builder.py. Jedna klasa, ResponseBuilder, miała 6533 linie.
Była sercem asystenta: otrzymywała rozpoznaną intencję, na przykład pytanie o temperaturę albo
możliwość uruchomienia gry, i budowała dwujęzyczną odpowiedź z bieżących danych. Istniało 96
handlerów, po jednym dla każdej intencji. Wszystkie mieszkały w tej samej klasie i w tym samym pliku.
Nie stało się to wskutek jednej złej decyzji. Każda nowa odpowiedź dodawała kilkanaście lub kilkadziesiąt linii. Taka zmiana nigdy nie wygląda groźnie. Powtórzona wiele razy kończy się plikiem, przy którego otwieraniu edytor zatrzymuje się na chwilę.
Koszt monolitu nie jest widoczny na ekranie aplikacji. Każda zmiana dotyka ściany niepowiązanego kodu, więc część potrzebnych zmian odkładasz. Dwie osoby, albo jedna osoba i jej kod sprzed pół roku, częściej wchodzą sobie w drogę. Błąd łatwo ukrywa się w miejscu, którego nikt nie potrafi objąć jednym czytaniem.
Plik, którego boisz się otworzyć, jest plikiem, który już przestałeś ulepszać.
Fasada ukrywa, gdzie naprawdę mieszka kod
Cała aplikacja rozmawiała z builderem przez jedno wywołanie:
response_builder.build(result, lang). Używał go panel czatu, monitor proaktywny i testy.
Nie chciałem zmieniać tego kontraktu. Refaktoryzacja wymagająca poprawienia każdego klienta byłaby
osobnym projektem.
Klasa została więc na swoim miejscu, ale jej ciało przeniosło się do mniejszych modułów.
builder.py stał się fasadą, która nadal odpowiada na każde stare wywołanie:
class ResponseBuilder(HardwareResponses, UpgradeResponses, ThermalResponses,
GamingResponses, SystemResponses, PerformanceResponses,
InsightsResponses, AssistantResponses):
"""Builds bilingual responses for every parsed intent."""
Każdy rodzic jest mixinem zawierającym jedną kategorię odpowiedzi. HardwareResponses
obsługuje CPU, GPU, RAM i dyski. ThermalResponses skupia temperatury, wentylatory oraz
linie bazowe napięć. Dziedziczenie składa je w jedną klasę, więc z zewnątrz
ResponseBuilder nadal ma wszystkie 96 metod.
Dispatch nie zmienił ani jednego znaku
Sposób zamiany intencji na odpowiedź pozostał ten sam:
intent = self._INTENT_ALIASES.get(result.intent, result.intent)
handler = getattr(self, f"_resp_{intent}", None)
if handler is None:
return None
out = handler(result, lang)
getattr wyszukuje metodę po nazwie w trakcie działania. Nie interesuje go plik, w którym
metoda została zapisana. Kolejność rozwiązywania metod Pythona przechodzi przez mixiny i znajduje
właściwy handler. Intencja temperature nadal trafia do
_resp_temperature, choć metoda przeniosła się do r_thermal.py.
Dla wywołujących zmiana jest niewidoczna.
Podział według odpowiedzialności, nie liczby linii
Nie ciąłem pliku na równe kawałki. Handlery trafiły do grup odpowiadających temu, jak szukam ich podczas pracy:
| Moduł | Handlery | Zakres |
|---|---|---|
r_hardware.py | 25 | CPU, GPU, RAM, dyski, płyta i pełna specyfikacja |
r_insights.py | 15 | trendy, porównania, podsumowania sesji i zmiany |
r_performance.py | 13 | obciążenie, throttling i przyczyny spowolnień |
r_system.py | 13 | kondycja, procesy, autostart i bezpieczeństwo |
r_thermal.py | 11 | temperatury, wentylatory i napięcia |
r_assistant.py | 11 | powitania, pomoc, informacje i pamięć odpowiedzi |
r_gaming.py | 6 | wymagania gier oraz czas gry i pracy |
r_upgrade.py | 2 | lokalna zgodność podzespołów |
Wspólne funkcje pomocnicze trafiły do common.py, a rozmowy prowadzone krok po kroku do
flows.py. Dawny plik z 6533 liniami ma teraz mniej niż 600 i robi jedną rzecz:
składa mixiny oraz kieruje żądania.
Najmłodszy r_upgrade.py dostał własny moduł od pierwszej linii. Ma dziś dwa handlery
i miejsce na rozwój. Wcześniej wkleiłbym nową funkcję do pliku, który akurat był otwarty. Tym razem
funkcja od początku dostała adres.
Dwa testy, aby monolit nie odrósł
Sam podział trwał jedno popołudnie. Utrzymanie granic jest trudniejsze, bo presja, która stworzyła pierwszy monolit, nie zniknęła. Kolejny handler zawsze łatwiej wkleić niż zastanowić się nad miejscem. Dlatego dwie reguły zatrzymują build, zamiast liczyć na pamięć autora.
Pierwsza ogranicza każdy moduł odpowiedzi do 1600 linii:
def test_monolith_guard(self):
for p in glob.glob(_RESPONSES_GLOB):
n = _read(p).count("\n") + 1
self.assertLess(n, 1600,
f"{os.path.basename(p)} has {n} lines - split it "
f"instead of growing another monolith")
Po przekroczeniu granicy test nie tylko zgłasza błąd. Mówi przyszłemu autorowi, co ma zrobić: podzielić moduł, zamiast budować następny monolit.
Drugi test pilnuje ostrej krawędzi mixinów. Gdy dwa z nich zdefiniują metodę o tej samej nazwie,
jeden może cicho wygrać przez kolejność dziedziczenia. Test zbiera definicje
_resp_* ze wszystkich modułów i odrzuca duplikaty:
all_defs = []
for p in glob.glob(_RESPONSES_GLOB):
all_defs += re.findall(r'def (_resp_[a-z0-9_]+)\(', _read(p))
dupes = {d for d in all_defs if all_defs.count(d) > 1}
self.assertEqual(dupes, set())
To nie była jedyna dojrzałościowa zmiana
Strona Mój PC otwierała się przez około dwie sekundy. Po zmianie wraca w 1-17 ms. Przyczyną były dwa
wywołania wmic wykonywane na wątku interfejsu, każde z limitem trzech sekund. Tożsamość
sprzętu jest teraz pobierana raz przy uruchomieniu, a strona pozostaje zbudowana zamiast powstawać
od nowa przy każdym wejściu.
Elementy podrzędne paska bocznego powstają dopiero przy pierwszym otwarciu sekcji. Zimny start okna spadł do około 190 ms. Numer wersji, wcześniej powielony w ośmiu plikach i już niespójny, znajduje się teraz w jednym źródle. Test odrzuca każdą nową, wpisaną na sztywno kopię. Zestaw testów wzrósł z 21 w czerwcu do 194.
Nie są to funkcje widoczne na zrzucie ekranu. Są pracą, która obniża koszt każdej następnej zmiany. Refaktoryzacja ma też cenę: przez kilka tygodni użytkownik nie zobaczy nowego przycisku, a przeniesienie sześciu tysięcy linii może wprowadzić błąd właśnie dlatego, że ma nie zmieniać zachowania. Każdy etap przeszedł więc przez testy publicznego wywołania.
Nie ufam własnej pamięci dotyczącej pliku z 6533 liniami. Ufam małym, sprawdzalnym krokom. Alternatywą był plik rosnący do momentu, w którym nikt nie chce go dotknąć, wewnątrz programu instalowanego przez obcych ludzi.
Przy zgrzewaniu dobra robota rzadko jest efektowną częścią. To połączenie, które później się nie rozpada. W oprogramowaniu jest podobnie. Najważniejsza zmiana tej wersji nie zostawia śladu na ekranie: 6533 linie stały się ośmioma modułami, które można otworzyć bez pauzy i zmieniać bez strachu.
Microsoft Store nie zrobił z PC Workmana prawdziwego projektu. Pokazał mi tylko lustro, a ja w końcu posprzątałem to, co w nim zobaczyłem.
Paragon Artykuł PRO · ciche awarie Najgroźniejszy błąd nie wywala programu. On kończy się sukcesem. Sześć prawdziwych awarii, które działały bez zarzutu i nie robiły nic, tydzień z asystentem piszącym szybciej, niż zdążyłem czytać, i pięć kontroli, które to łapią. Przeczytaj artykuł →