Folosirea instrumentelor de control al versiunii a devenit un obicei în modul nostru de lucru, iar una dintre proprietățile lor, aceea de a folosi branch-uri, poate fi fructificată după cum consideră fiecare echipa că aduce valoare. S-au scris multe articole despre strategiile de branching și în timp ce unele dintre ele au devenit foarte populare, aproape toate au o problemă comună care trebuie abordată. Aceasta este problema procesului merging între branch-uri. Există un motiv pentru care se discută nu doar în termeni de strategie de branching, ci in termeni de strategie de branching și merging.
Fuziunea se poate transforma ușor în coșmar atunci când strategia abordată este greșită. Putem să luăm ca exemplu una dintre cele mai populare strategii de fuziune, strategia Vincent Driessen prezentată în Figura 1. Această strategie poate să nu fie cea mai potrivită în anumite condiții întâlnite destul de des.
Figura 1: modelul Vincent Driessen
Ne concentrăm pe cele două branch-uri care sunt intens folosite: “feature” și “develop”.
Branch-ul “feature” este creat din branch-ul “develop” și este folosit de programator pentru a implementa o nouă funcționalitate.
Când termină implementarea, va aduce modificările de pe branch-ul “feature” în branch-ul “develop” astfel încât acestea vor deveni disponibile pentru restul echipei.
De urmărit următoarea situație:
Acesta pare a fi un workflow sănătos, însă ascunde câteva riscuri care în final vor duce la un efort adițional atât pe partea de dezvoltare, cât și pe cea de testare. Iată câteva dintre aceste riscuri:
Dan își petrece o zi testând toate particularitățile implementării Mariei și în final își dă acordul. Totul funcționează bine. Ceea ce el încă nu știe este faptul că Andrei lucrează cu două dintre fișierele modificate de Maria. Andrei își efectuează un merge al branch-ului său trei zile mai târziu în branch-ul develop și e bucuros că nu întâmpină conflicte. Dan începe testarea dar observă că funcționalitatea Mariei nu se mai comportă bine. Dan creează un tichet pentru Maria, Maria rezolvă problema folosind de această dată și modificările aduse de Andrei, iar Dan urmează să testeze din nou atât funcționalitatea Mariei, cât și pe cea a lui Dan.
Așadar Maria a pierdut timp pentru reparația respectivă, iar Dan a pierdut timp deoarece a trebuit să testeze cel puțin de două ori ambele funcționalități.
Se întâmplă des ca dezvoltarea funcționalităților și scrierea de teste unitare pentru ele să dureze câteva zile. Pentru a micșora riscul apariției de conflicte la fuziune programatorii încearcă să își aducă branch-ul “develop” cât mai des (probabil în fiecare zi) în branch-ul pe care ei dezvoltă separat funcționalitatea. Faptul că se implementează simultan mai multe funcționalități diferite pe branch-uri diferite este cert, iar riscul de conflicte la fuziune este oricum ridicat, iar cu cât programatorii petrec mai mult timp pe branch-ul lor specific, cu atât riscul crește.
Programatorii ar trebui să scrie teste unitare, teste între componente și teste de integrare. Atunci când aceste teste sunt dezvoltate împreună cu funcționalitatea, în izolare, pe branch-ul de implementare a funcționalității, și în același timp o altă funcționalitate este dezvoltată împreună cu testele corespunzătoare pe un alt branch, la momentul efectuării operației de merge șansele ca testele scrise să pice sunt ridicate. Testele nu au fost scrise luând în considerare comportamentul dezvoltat în paralel pe celelalte branch-uri. Ele vor trebui revizuite și reparate, ceea ce aduce un efort în plus.
Multe echipe încearcă să își automatizeze procesul de instalare al produsului. La nivel de baze de date rulează în mod automat script-uri care aduc starea bazei de date la versiunea corectă. Din cauza faptului că programatorii vor crea aceste script-uri iîn izolare, pe branch-uri diferite, există riscul de a strica ordinea în care ele trebuie rulate.
Dacă privim cu atenție rădăcina acestor probleme, observăm izolarea în care lucrează programatorii pe branch-uri diferite. O alternativă pentru acest mod de lucru este folosirea tehnicii de branching prin abstractizare.
Este definită de Martin Fowler ca fiind “o tehnică de a face o schimbare pe scară largă unui sistem software într-un mod gradual care permite lansarea sistemului în mod regulat în timp ce schimbarea e încă în desfășurare”.
Tehnica implică adăugarea unui nivel de abstractizare deasupra funcționalității la care se lucrează astfel încât codul client va comunica doar cu acest nivel de abstractizare, nefiind conștient dacă folosește versiunea originală sau nouă a implementării.
Oricare ar fi modul în care este folosită tehnica, există câteva puncte comune:
În timp ce programatorul scrie teste pentru funcționalitatea lui, el este sigur ca testele sale folosesc ultima versiune a codului deoarece colegii lui își adaugă modificările pe același branch. În acest fel nu vor exista operații de merge ulterioare care să strice testele, deoarece alte branch-uri nu există.
Este importantă plasarea corectă a nivelului de abstractizare și modul în care obiectele dintr-o versiune sau alta ale aceleiași funcționalități sunt instanțiate. Din acest punct de vedere există mult loc pentru creativitate deoarece această decizie depinde și de contextul problemei. Pot exista mai multe variante, dintre care vor fi prezentate două.
Această tehnică se aplică acolo unde este necesară modificarea sau rescrierea unei componente mari.
Un nivel de abstractizare trebuie adăugat astfel încât codul client să nu mai depindă de componentă ci de abstractizare. Această acțiune ar putea implica unele modificări (refactoring) la nivel de componentă și teste adiționale ar putea fi scrise. Modificările necesare în vederea extragerii nivelului de abstractizare ar putea fi costisitor, de aceea acesta este un punct important pentru a lua o decizie asupra strategiei de branching.
Noua implementare a componentei va fi făcută pas cu pas, astfel funcționalitățile vor fi adăugate în funcție de priorități. Când un set de funcționalități e finalizat, clientul codului poate schimba legăturile cu instanțele folosite și poate migra la noua componentă. Este important de reținut că aplicația trebuie să poată fi construită și rulată în oricare moment. În final nu vor mai exista dependențe către vechea implementare.
Figura 2: Branching prin abstractizarea componentei. ClientCode este într-un process de migrare pentru a folosi NewComponent.
Să luăm în considerare cazul în care abstractizarea la nivel de componentă nu este cea mai bună soluție. Motivele ar putea consta în efort prea mare de modificări pentru a extrage nivelul de abstractizare la nivel de componenta. Pentru astfel de situații putem lăsa codul așa cum este și să alegem o altă tehnică.
Figura 3 face referință la codul care folosește clasa (grupul de clase) ce trebuie actualizată. Descrierea claselor din figura:
Versiunea veche și versiunea nouă a implementării clasei care folosește codul ce trebuie actualizat:
„The Factory”
Figura 3: Ramificarea prin absractizare la nivel de punct de folosire. Versiunea originală și cea nouă a clasei care folosește codul ce trebuie actualizat.
Sintetizăm afirmațiile de mai sus privitoare la aspectele asupra cărora se vor concentra membrii echipei:
Programatorii:
Persoanele care testează:
De îndată ce nivelul de încredere în noua funcționalitate devine suficient mecanismul de schimbare între instanțe și instanța originală pot fi șterse. Important este de luat în considerare faptul că sistemul trebuie să poată fi construit și rulat cu succes în oricare moment.