CMake – automatyczna obsługa podprojektów z gita

Konfiguracja CMake - wszystkie wpisy

W poprzednim odcinku skonfigurowaliśmy sobie większy projekt. Mieliśmy oddzielne targety na poszczególne podprojekty. Dzięki temu dało się na przykład utworzyć bibliotekę statyczną, czy dodać bibliotekę header only. Dzięki odpowiedniej konfiguracji byliśmy w stanie raz skompilować podprojekt i używać go w wielu targetach. Teraz pójdziemy o krok dalej. Nasze podprojekty będą automatycznie ściągane z własnych repozytoriów.

W przykładzie użyje repo na GitHubie, ale równie dobrze można użyć innych systemów kontroli wersji, serwerów, czy nawet lokalnych plików. Zalety takiego rozwiązania w większych projektach są nieocenione. Po pierwsze możemy sensownie zarządzać aktualizacjami zewnętrznych bibliotek, a po drugie możemy tworzyć własne reużywalne biblioteki z aktualizacjami propagowanymi na wszystkie projekty.

FetchContent i ExternalProject

Aby wykonać nasze zadanie musimy posłużyć się jedną z dwóch komend udostępnianych przez CMake:

FetchContent pozwala na ściągnięcie zewnętrznych zależności podczas generowania skryptów. Czyli podczas wykonywania komendy cmake. Później podczas builda sprawdzane są również aktualizacje pliku CMakeLists.txt i w razie potrzeby generacja jest powtarzana. Jeżeli generacja jest zakończona i nie robimy już żadnych zmian w skrypcie CMake, build będzie używał tych samych plików ściągniętych z zewnętrznego repo.

Z kolei ExternalProject obsługuje te same zależności już w momencie buildu. Czyli w naszym skrypcie z poprzedniego artykułu będzie to komenda ninja. Czyli w tej opcji zmiany w repo podprojektu będą sprawdzane przy każdej kompilacji. Dla mnie dużo wygodniejsza jest opcja ze ściąganiem projektu raz przy konfiguracji. Odpowiada to również sposobowi pracy z zewnętrznymi repo, kiedy nie mamy automatyzacji. Dlatego komendą ExternalProjekt nie będę się dzisiaj zajmować.

Jednak mechanizm pod spodem działa praktycznie tak samo i łatwo podmienić w razie potrzeby jedną komendę na drugą. ExternalProject może mieć przewagę, jeżeli używamy projektu nie zawierającego własnej konfiguracji CMake. Możemy wtedy skonfigurować np. komendę skryptu budującego odpowiedni fragment podprojektu.

Konfiguracja FetchContent

Ok, to teraz pora przejść do sedna. Będę korzystać z tego samego projektu co poprzednio – znajdziesz go na GitHubie. Dotychczas w folderze test/Unity trzymałem skopiowaną zawartość biblioteki Unity, którą na GitHubie znajdziesz pod tym linkiem. Pora zastąpić ręczne kopiowanie automatyczną obsługą repo w CMake.

Do plików CMakeLists.txt w test/cyclic_buffer i test/cyclic_buffer_mock dodałem taki fragment kodu zamiast dodawania lokalnego projektu Unity przy okazji operacji add_subdirectory:

#add_subdirectory(../Unity unity)

include(FetchContent)
FetchContent_Declare(
  Unity
  GIT_REPOSITORY https://github.com/ThrowTheSwitch/Unity.git
  GIT_TAG        v2.5.2
)

set(UNITY_EXTENSION_FIXTURE ON CACHE BOOL "Enable Unity Fixture Extension")
set(UNITY_EXTENSION_MEMORY  ON CACHE BOOL "Enable Unity Memory Extension")
FetchContent_MakeAvailable(Unity)

Co tu się dzieje? Najpierw za pomocą include(FetchContent) dodajemy do naszego skryptu moduł FetchContent. Następnie deklarujemy konfigurację zewnętrznego projektu. Mamy nazwę, URL repozytorium gita i tag, który chcemy użyć. To ważne, żeby używać wersji releasowych, a nie master, czy dev. Nie chcemy, żeby zawartość się zmieniała i powodowała jakiekolwiek problemy z synchronizacją, failowała testy, zmieniała ścieżki i nazwy plików itp.

Kolejne dwie linie aktywują dodatki dostępne w projekcie Unity. Gdybyśmy nie aktywowali zmiennych UNITY_EXTENSION_FIXTURE i UNITY_EXTENSION_MEMORY, skompilowalibyśmy czyste Unity. Informacje o tym jak aktywować poszczególne opcje, albo jakie inne zmienne możesz przekazać aby wpłynąć jakoś na projekt musisz szukać w dokumentacji konkretnego projektu.

Ostatnia linijka to FetchContent_MakeAvailable(Unity), która zleca dodanie projektu zgodnie z zadeklarowaną konfiguracją. Dzięki użyciu tej komendy nie musimy martwić się o szczegóły. Repo z gita zostanie dodane do wewnętrznych folderów CMake dla naszego projektu, a zawartość projektu Unity będzie dostępna z naszego projektu głównego.

Kiedy uruchomimy sobie konfigurację i kompilację projektu test/cyclic_buffer i zobaczymy, że wszystko działa, możemy dokładniej przyjrzeć się folderom utworzonym przez CMake. Mamy dodatkowy folder _deps a w nim unity-build, unity-subbuild i unity-src. Jak pewnie się domyślasz – tam są źródła projektu z githuba, konfiguracja CMake i skompilowane binarki.

Kiedy powtórzymy tą samą operację na drugim projekcie – test/cyclic_buffer_out – znajdziemy tam te same pliki. I tutaj pojawia się problem. Nie po to wydzielamy projekt frameworka Unity do osobnego repo, żeby teraz każdy unit test sobie go sam nie tylko kompilował, ale również ściągał z GitHuba. Jeżeli takich testów będziemy mieli więcej – strata czasu będzie już bardzo dotkliwa.

Konfiguracja FetchContent – wersja ulepszona

Idealna sytuacja to stworzenie takiej samej struktury, jaką mieliśmy wcześniej z projektami lokalnymi. Był folder unity ze swoimi wynikami kompilacji i każdy projekt mógł sobie je wykorzystać i nie musiał od nowa tworzyć tych plików. Na szczęście dokładnie coś takiego jesteśmy w stanie osiągnąć przy pomocy FetchContent.

W tym celu zmieniłem skrypt CMake obsługujący repo i wydzieliłem go z powrotem do podprojektu dodawanego za pomocą add_subdirectory(Unity):

set(UNITY_OUT_DIR ${CMAKE_SOURCE_DIR}/../Unity/out)

include(FetchContent)

FetchContent_Declare(
  Unity
  GIT_REPOSITORY https://github.com/ThrowTheSwitch/Unity.git
  GIT_TAG        v2.5.2
  SOURCE_DIR     ${UNITY_OUT_DIR}/src
  BINARY_DIR     ${UNITY_OUT_DIR}/bin
)

set(FETCHCONTENT_BASE_DIR ${UNITY_OUT_DIR})

set(UNITY_EXTENSION_FIXTURE ON CACHE BOOL "Enable Unity Fixture Extension")
set(UNITY_EXTENSION_MEMORY  ON CACHE BOOL "Enable Unity Memory Extension")

FetchContent_GetProperties(Unity)
if (NOT Unity_POPULATED)
    FetchContent_Populate(Unity)
endif ()

add_subdirectory(${unity_SOURCE_DIR} ${unity_BINARY_DIR})

Najpierw tworzę zmienną przechowującą ścieżkę z wynikami kompilacji. Następnie dodaję moduł FetchContent i robię konfigurację za pomocą FetchContent_Declare. Jednak tym razem wykorzystałem dwa dodatkowe pola SOURCE_DIR i BINARY_DIR. Dodałem również zmienną FETCHCONTENT_BASE_DIR wskazującą miejsce przechowywania plików konfiguracyjnych CMake.

Kolejną różnicą jest użycie FetchContent_GetProperties i FetchContent_Populate zamiast FetchContent_MakeAvailable. Jest to alternatywny sposób dodawania zewnętrznego repo do projektu dający nam lepszą kontrolę kosztem prostoty. Możemy na przykład sprawdzić, czy dodawane przez nas repo już istnieje, możemy też przetestować różne zmienne CMAKE związane z tym projektem, czy ręcznie przygotować strukturę folderów korzystając z komendy file.

Na koniec musimy dodać ścieżki do źródeł i binarek naszego projektu za pomocą add_subdirectory, wcześniej FetchContent_MakeAvailable robił to za nas.

Teraz po wykonaniu konfiguracji i kompilacji widzimy, że w folderze test/Unity/out mamy foldery bin, src i unity-subbuild zawierające pliki, które wcześniej przechowywaliśmy w _deps. Te pliki są wykorzystywane zarówno przez projekt cyclic_buffer jak i cyclic_buffer_mock.

Kod projektu na GitHubie

Kod projektu możesz obejrzeć na moim GitHubie. Aby nawigować pomiędzy punktem wyjściowym, a pierwszą i drugą wersją z FetchContent użyj tagów: local_subrepos, fetchcontent-1 i fetchcontent-2.

Podsumowanie

No i udało się – mamy skrypt automatycznie pobierający dane z gita do podprojektów, a ściągnięte pliki mogą być używane przez inne targety. W ten sposób nie tylko zwiększamy wygodę pracy, ale również zmniejszamy ryzyko błędów.

Polecam przeczytać dokładnie dokumentację FetchContent, a także siostrzanej komendy ExternalProject_Add i zobaczyć, jakie mamy możliwości. Możemy np. dodać flagę FETCHCONTENT_UPDATES_DISCONNECTED kiedy nie mamy dostępu do repozytorium, a wcześniej ściągnęliśmy odpowiednią wersję. Możemy też podać lokalne repo, czy nawet spakowane pliki projektu. W materiałach dodatkowych daję również linki do dwóch fajnych artykułów na ten temat.

A jeżeli artykuł się podobał i nie chcesz przegapić kolejnych – zapisz się na mój Newsletter.

Materiały

Konfiguracja CMake - Nawigacja

2 Comments

  1. Może jakiś wpis porównujący użycie FetchContent vs submodules w sensie kiedy z którego podejścia korzystać plus wady i zalety w danym zastosowaniu.

    • GAndaLF

      31 marca 2021 at 21:51

      Git ma kilka opcji zarządzania podprojektami (chyba 3? Na pewno jest jeszcze subrepo i subtree) i każda z nich ma swoje wady. Trochę więcej korzystałem akurat z subrepo, ale ostatecznie dałem sobie spokój. Część wad tych rozwiązań jest wspólna.

      Problemy to przede wszystkim potrzeba ręcznego aktualizowania podprojektów po ściągnięciu repo i możliwość otrzymania aktualizacji z subrepo psującej główny projekt. W ten sposób tracimy główną zaletę gita – zapisany stan z commita działa deterministycznie.

      Poza tym były problemy z dodawaniem zmian w podprojekcie – były traktowane jako commit do głównego repo i oddzielnie do subrepo. Przez to łatwo było o czymś zapomnieć. Generalnie większość programistów doszła do wniosku, że tego problemu nie da się czysto rozwiązać z powodu gita i powinien to obsługiwać package manager. I fetch content bardziej naśladuje właśnie prostego package managera.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *