Banalne zadania w IT chyba nie istnieją. Migracja do nowej tabeli nie powinna być niczym trudnym — niemniej, w żyjącej na produkcji aplikacji, w połączeniu z dodaniem nowego zestawu funkcji stała się całkiem ciekawym wyzwaniem. O tym chcę Ci dzisiaj opowiedzieć w moim poście.
Po pierwsze: Po co ta migracja?
Odpowiedź na to pytanie była dość kluczowa dla procesu, wszak musiałam wytłumaczyć biznesowi, dlaczego coś, co wydawało im się bardzo prostym zadaniem, powinniśmy poprzedzić taką małą rewolucją. I myślę, iż każdy większy refaktor w jakim moczysz palce powinno poprzedzić takie rozważanie: Po co? Jaki będzie tego koszt? Co zyska produkt? (bardziej szczegółowy zestaw pytań znajdziesz we wpisie o wyborze biblioteki, co jest podobnej skali przedsięwzięciem).
Aby odpowiedzieć na te pytania odbyłam kilka spotkań z zespołem BE, a potem, gdy miałam już konkretne argumenty, Product Managerem. Na technicznych spotkaniach rozmawialiśmy głównie o konsekwencjach i kosztach takiej decyzji vs jej alternatywy, a także szukaliśmy ogólnej architektury nowego rozwiązania. Nie zgłębiając się w super detale implementacji, na początku pracy nad produktem, dla którego piszę kod, została podjęta decyzja o wykorzystaniu wewnętrznej platformy/ serwisu, który służył podobnemu celowi. Z powodu jego architektury, która jest w dużej mierze monolityczna, współdzielona jest też baza danych, w tym także niektóre istniejące wcześniej tabele. Ta decyzja przyniosła naszemu zespołowi wiele korzyści — dużo dostaliśmy ‘za darmo’, dzięki czemu w bardzo krótkim czasie mogliśmy wypuścić gotowy produkt. Niemniej były też pewne minusy tej decyzji, z czego jeden najważniejszy — jeden z modeli, który postanowiliśmy używać, był w innych systemach wykorzystywany w zupełnie inny sposób. Nasze ‘obejście’ tego systemu działało sobie dobre 1.5 roku, po czym inne zespoły zaczęły — słusznie — dość szorstko reagować na kolejne dodawane tam kolumny. Nie dostaliśmy czerwonego światła, niemniej zaczęliśmy dostrzegać, iż nasz potworek powoli chce uciec z akwarium ;) wtedy właśnie podjęliśmy decyzję o przeniesieniu go do nowej struktury, a główną korzyścią była niezależność, którą my zyskaliśmy, a pozostałe zespoły odzyskały. Gdy dostałam zielone światło zabrałam się za pracę (w moim zespole takie zadanie będzie realizował 1 programista, być może z małym wsparciem na poszczególnych taskach od innych członków zespołu, niemniej cały plan działania, propozycja architektury i ponad 90% realizacji będą autorstwa tej jednej osoby).
Po drugie, zaplanuj (i spisz!)
Zaplanowanie wszystkiego również było ważne. Miałam przed sobą 1.5–2 lata żywego produktu. Z kilkoma rzeczami napisanymi trochę na agrafkę,z co najmniej kilkoma innymi serwisami, w których również trzeba było wprowadzić zmiany. Na szczęście jest OpenGrok, który pozwolił mi zmapować wszystkie miejsca wymagające modyfikacji.
Poniżej znajdziesz mój plan działania — w ramach ćwiczenia zastanów się, jakich kroków wymagała by taka operacja w twoim projekcie?
Z grubsza mój plan wyglądał następująco:
- Dodaj nową tabelę:
- Stwórz nowy model(obiekt) + tabelę
- Zaktualizuj obecne modele, które używają tego obiektu (i ich tabele)
- Migracja danych (stara tabela do nowej tabeli + obiekty współzależne używające id z nowej tabeli)
- Dodaj logikę związaną z pisaniem do nowej tabeli:
- Nasz System:
- CRUD
- Używanie id z nowej tabeli przez inne obiekty w ich CRUD,
- Dodaj logikę bufera (z poprzednią implementacją ta funkcja nie była łatwa w implementacji),
- Dodaj wysyłanie eventów do innych systemów, gdy dane ulegają zmianie,
- Dodaj możliwość nadawania kolejności (z poprzednią implementacją ta funkcja nie była łatwa w implementacji),
- Uaktualnij system importu, by używał nowego modelu
- Dodaj wsparcie dla używania nowego modelu przez:
- System do indeksowania,
- System do wykrywania konfliktów,
- Wyszukiwanie,
- System do rekomendacji,
- Nasz System:
- Dodaj logikę do czytania z nowej tabeli:
- Dodaj w oparciu o gate logikę do używania nowej tabeli przez API,
- Używaj danych z nowej tabeli przez system do renderowania.
- Synchronizacja:
- Gdy updatujesz stare dane, synchronizuj je z nowymi,
- Gdy updatujesz obiekty, które wykorzystują id migrowanego obiektu, uzupełniaj stare i nowe id.
- Dodaj joby, które będą sprawdzać synchronizację pomiędzy starą a nową tabelą.
- Sprzątanie:
- Oznacz starą implementacje jako @Deprecated,
- Loguj użycia starego API,
- Usuń kod, a także pomocniczą kolumnę (legacyId) z nowej tabeli.
Zrozum obecną implementacje, tak by znaleźć najprostszy sposób na zmianę
Zakładam, iż twój plan różni się od mojego. Ba, mogę Ci śmiało powiedzieć, iż sama musiałam zmienić swoje bardzo wstępne założenia. W idealnym świecie, w pierwszym etapie robiłabym zapisywanie do nowej i starej tabeli, a potem — gdy wiem, iż wszystkie dane są synchronizowane — zrobiłabym to samo dla odczytywania. I jak to wszystko by działało, zaczęłabym dodawać nową logikę. Niemniej, podjęłam inna decyzję, głównie z uwagi na wewnętrzne biblioteki jakie używamy — powodują one iż dużo skomplikowanej logiki po prostu dziedziczyliśmy (mamy takie główne klasy dla resource czy repository, które również korzystają z koncepcji life cycle handlerów — czyli klasy, których metody są używane przed lub po jakimiś operacjami — np. operacjami na bazie danych: beforeCreate, afterDelete itp.) a sposób ich implementacji nie pozwalał na proste dodanie nowego dao, by tą całą logikę utrzymać. Stąd podjęłam decyzje o dziedziczeniu całej logiki (ale niestety przez nowe klasy), i o własnej logice do synchronizacji. Było to znacznie bardziej bezpieczne, bo corowe funkcje mogły być dziedziczone, niemniej było w tym sporo duplikacji kodu. Konsekwencja takiego podejścia była podmianka API na FE, czyli dodatkowa praca, na którą musiałam mieć zgodę tego zespołu. Niemniej takie rozwiązanie było najbezpieczniejszym dla tego refaktoru.
Migracja danych to nie wszystko
Znacznie bardziej skomplikowana jest synchronizacja danych. Tak naprawdę, przed puszczeniem migracji musisz mieć gotową całą logikę synchronizacji, tak by w trakcie joba, ten sync był już możliwy. Dzięki niemu znacznie łatwiej jest też stopniowo wystawiać użytkowników na nowe API.
Tutaj chyba najważniejsze jest odpowiednie logowanie, żeby wiedzieć kiedy synchronizacja zawiodła (np. log error z narzędziem typu Sentry). Dodatkowo, uruchomiłam jeszcze codzienne joby, które sprawdzały nowe i stare dane. W ten sposób łatwo było wychwycić każdy niezaimplementowany jeszcze przypadek, a także dawało pewność, iż dane klientów są w porządku.
Kolejnym pomocnikiem było użycie bramek (ang. gates), których zasada działania jest prosta, przed wykonaniem operacji, sprawdzasz czy dane id (np. użytkownika) jest dopuszczone dla danej funkcjonalności: jeżeli tak — używasz, jeżeli nie — pomijasz. Może brzmi to górnolotnie, ale w praktyce to zwykły if w twoim kodzie:
if(isUngated(id,”NewFeature”){ newLogic(); }else{ old Logic(); }W tym procesie wykorzystałam dwie takie bramki, jedną dla panelu admina (zapis + odczyt), a drugą dla logiki, która pokazuje te dane publicznie(tylko odczyt).
W ten sposób mogłam najpierw udostępniać tylko część funkcjonalności, a potem etapami zwiększać ilość korzystających z niej użytkowników.
Nie przepisuj wszystkiego
Wiadomo — pełny refaktor kusi, w szczególności, gdy nadarza się taka okazja, niemniej rekomenduje tutaj dużą ostrożność. Głównym celem mojego procesu było przeniesienie logiki tak, by nie dodać do niej żadnego nowego buga. Nie znaczy to jednak, iż masz tylko zrobić kopiuj-wklej starego kodu. Z każdym commitem możesz dodać małe zmiany do kodu, które razem naprawdę robią różnicę. Gdy tworzysz taki PR dokładnie je wytłumacz, i podlinkuj starą logikę. Mówiąc o poprawianiu, pamiętaj też o testach — te kiepsko napisane, mogą Ci tylko zaszkodzić, dlatego jeżeli tylko możesz zweryfikuj te, które są już w kodzie i dodaj brakujące scenariusze czy asercje (tutaj może przydać się jakieś narzędzie do code coverage czy testów mutacyjnych).
Bądź ostrożny z dodatkowymi zmianami
Migrowanie czegoś innego w tym samym czasie nie jest najlepszym pomysłem i może pokrzyżować twoje plany. Tego mój zespół doświadczył na własnej skórze, bo razem z tymi zmianami równolegle migrowaliśmy inne (w teorii były to znacznie mniejsze zmiany). I tak — miało to sens robić to w tym samym czasie (tak by mój nowy obiekt, korzystał już z tej nowej logiki), ale dodało to znacznie więcej scenariuszy i przypadków brzegowych do obsłużenia. Musiałam zsynchronizować swoją pracę z drugim programistom, i mieć absolutną pewność, iż oboje mamy taką samą wizję na te zmiany.
Następnym razem chyba wolałabym zmieniać to po sobie, a nie równolegle.
Bądź cierpliwy
To było naprawdę duże zadanie. Pojawiały się blokery, gdy np. musiałam zmienić coś w bibliotece używanej przez kilkaset innych wewnętrznych projektów (więc musiałam poczekać, aż wszystkie będą na ‘nowej’ wersji moich obiektów, zanim będę ich realnie używać w interakcjach z innymi serwisami). Cześć z moich zadań nie była interesująca, bo jakby nie patrzeć sporo było tutaj kopiowania ze zrozumieniem ;) No i w końcu — to po prostu trwało i czasem ciężko było zobaczyć postęp. Tutaj zbawieniem była moja lista zadań z której skreślałam te już wykonane. Dodatkowym aspektem było to, iż ta migracja nie przynosiła bezpośrednio nowych korzyści biznesowych, w rezultacie czego 2 razy musiałam pauzować ten proces, bo inne zadania miały większy priorytet. Było to może interesujące oderwanie od tego naprawdę sporego zadania, ale z drugiej strony zmniejszało moją pewność siebie co do jakości implementacji — taki powrót po tygodniu pracy nad innymi rzeczami powodował, iż sporo musiałam sobie przypomnieć. Niemniej, dzięki testom i sprawdzaniu synchronizacji mogłam gwałtownie wrócić do dalszej pracy.
Jak to wszystko poszło?
Dzisiaj cała logika jest już na produkcji, dla wszystkich z użytkowników. Z uwagi na bardzo wolne udostępnianie logiki dla użytkowników, a także joby, które sprawdzały synchronizacje danych udało nam się to osiągnąć bez żadnego błędu zgłoszonego przez końcowych użytkowników, niemniej było kilkanaście bugów, które złapaliśmy w trakcie całego procesu. Wydaje mi się, iż najtrudniejszym etapem całego procesu było planowanie i zaprojektowanie synchronizacji danych. Ważne było dla mnie wsparcie zespołu FE, a także BE, który pomagał mi przy przenoszeniu małych porcji logiki.
Mam nadzieję, iż powyższy opis okazał się dla Ciebie interesujący i pomocny. jeżeli masz dodatkowe pytania — pytaj śmiało, bo zdaję sobie sprawę, iż część tego procesu opisałam pobieżnie. Z chęcią też zapoznam się z twoją ostatnią migracją — zostaw komentarz z opisem twojego podejścia do takiego zadania!