Manajemen Ketergantungan

Saat melihat halaman sebelumnya, satu tema berulang kali muncul: mengelola kode Anda sendiri cukup mudah, tetapi mengelola dependensinya jauh lebih sulit. Ada berbagai jenis dependensi: terkadang ada dependensi pada tugas (seperti "kirim dokumentasi sebelum saya menandai rilis sebagai selesai"), dan terkadang ada dependensi pada artefak (seperti "Saya harus memiliki versi terbaru library visi komputer untuk mem-build kode saya"). Terkadang, Anda memiliki dependensi internal pada bagian lain dari codebase Anda, dan terkadang Anda memiliki dependensi eksternal pada kode atau data yang dimiliki oleh tim lain (baik di organisasi Anda maupun pihak ketiga). Namun, dalam kasus apa pun, gagasan "Saya memerlukan hal itu sebelum saya dapat memiliki hal ini" adalah sesuatu yang berulang kali muncul dalam desain sistem build, dan mengelola dependensi mungkin merupakan tugas paling mendasar dari sistem build.

Menangani Modul dan Dependensi

Project yang menggunakan sistem build berbasis artefak seperti Bazel dibagi menjadi sekumpulan modul, dengan modul yang menyatakan dependensi satu sama lain melalui file BUILD. Pengorganisasian modul dan dependensi ini yang tepat dapat memberikan pengaruh besar pada performa sistem build dan jumlah pekerjaan yang diperlukan untuk pemeliharaan.

Menggunakan Modul yang Lebih Detail dan Aturan 1:1:1

Pertanyaan pertama yang muncul saat menyusun build berbasis artefak adalah memutuskan seberapa banyak fungsi yang harus dicakup oleh setiap modul. Di Bazel, modul direpresentasikan oleh target yang menentukan unit yang dapat di-build seperti java_library atau go_binary. Pada satu ekstrem, seluruh project dapat dimuat dalam satu modul dengan menempatkan satu file BUILD di root dan secara rekursif menggabungkan semua file sumber project tersebut. Di ekstrem lainnya, hampir setiap file sumber dapat dibuat menjadi modulnya sendiri, yang secara efektif mengharuskan setiap file mencantumkan setiap file lain yang menjadi dependensinya dalam file BUILD.

Sebagian besar project berada di antara kedua ekstrem ini, dan pilihan tersebut melibatkan pertukaran antara performa dan pemeliharaan. Menggunakan satu modul untuk seluruh project mungkin berarti Anda tidak perlu menyentuh file BUILD kecuali saat menambahkan dependensi eksternal, tetapi hal ini berarti sistem build harus selalu mem-build seluruh project sekaligus. Artinya, sistem build tidak akan dapat melakukan paralel atau mendistribusikan bagian dari build, dan tidak akan dapat menyimpan dalam cache bagian yang telah di-build. Satu modul per file adalah kebalikannya: sistem build memiliki fleksibilitas maksimum dalam menyimpan dalam cache dan menjadwalkan langkah-langkah build, tetapi engineer perlu mengeluarkan lebih banyak upaya untuk mempertahankan daftar dependensi setiap kali mereka mengubah file mana yang direferensikan.

Meskipun granularitas yang tepat bervariasi menurut bahasa (dan sering kali bahkan dalam bahasa), Google cenderung lebih menyukai modul yang jauh lebih kecil daripada yang biasanya ditulis dalam sistem build berbasis tugas. Biner produksi umum di Google sering kali bergantung pada puluhan ribu target, dan bahkan tim berukuran sedang dapat memiliki beberapa ratus target dalam codebase-nya. Untuk bahasa seperti Java yang memiliki konsep bawaan pengemasan yang kuat, setiap direktori biasanya berisi satu paket, target, dan file BUILD (Pants, sistem build lain yang berbasis Bazel, menyebutnya aturan 1:1:1). Bahasa dengan konvensi pengemasan yang lebih lemah sering kali menentukan beberapa target per file BUILD.

Manfaat target build yang lebih kecil benar-benar mulai terlihat dalam skala besar karena target tersebut menghasilkan build terdistribusi yang lebih cepat dan kebutuhan untuk mem-build ulang target yang lebih jarang. Keuntungannya menjadi lebih menarik setelah pengujian masuk ke dalam gambar, karena target yang lebih detail berarti sistem build dapat jauh lebih cerdas dalam menjalankan hanya subkumpulan pengujian terbatas yang dapat terpengaruh oleh perubahan tertentu. Karena Google percaya pada manfaat sistemik penggunaan target yang lebih kecil, kami telah membuat beberapa kemajuan dalam mengurangi kelemahannya dengan berinvestasi dalam alat untuk mengelola file BUILD secara otomatis guna menghindari beban pada developer.

Beberapa alat ini, seperti buildifier dan buildozer, tersedia dengan Bazel di buildtools direktori.

Meminimalkan Visibilitas Modul

Bazel dan sistem build lainnya memungkinkan setiap target menentukan visibilitas—properti yang menentukan target lain mana yang dapat bergantung padanya. Target pribadi hanya dapat direferensikan dalam file BUILD-nya sendiri. Target dapat memberikan visibilitas yang lebih luas ke target dari daftar file BUILD yang ditentukan secara eksplisit, atau, dalam kasus visibilitas publik, ke setiap target di ruang kerja.

Seperti halnya sebagian besar bahasa pemrograman, biasanya sebaiknya minimalkan visibilitas sebanyak mungkin. Umumnya, tim di Google hanya akan membuat target publik jika target tersebut mewakili library yang banyak digunakan dan tersedia untuk tim mana pun di Google. Tim yang mengharuskan orang lain berkoordinasi dengan mereka sebelum menggunakan kode mereka akan mempertahankan daftar yang diizinkan dari target pelanggan sebagai visibilitas target mereka. Target implementasi internal setiap tim akan dibatasi hanya untuk direktori yang dimiliki oleh tim, dan sebagian besar file BUILD hanya akan memiliki satu target yang tidak bersifat pribadi.

Mengelola dependensi

Modul harus dapat merujuk satu sama lain. Kelemahan memecah codebase menjadi modul yang lebih detail adalah Anda perlu mengelola dependensi di antara modul tersebut (meskipun alat dapat membantu mengotomatiskan hal ini). Menyatakan dependensi ini biasanya menjadi sebagian besar konten dalam file BUILD.

Dependensi internal

Dalam project besar yang dipecah menjadi modul yang lebih detail, sebagian besar dependensi kemungkinan bersifat internal; yaitu, pada target lain yang ditentukan dan di-build di repositori sumber yang sama. Dependensi internal berbeda dengan dependensi eksternal karena dependensi internal di-build dari sumber, bukan didownload sebagai artefak yang telah di-build sebelumnya saat menjalankan build. Hal ini juga berarti bahwa tidak ada konsep "versi" untuk dependensi internal—target dan semua dependensi internalnya selalu di-build pada commit/revisi yang sama di repositori. Salah satu masalah yang harus ditangani dengan hati-hati terkait dependensi internal adalah cara menangani dependensi transitif (Gambar 1). Misalnya, target A bergantung pada target B, yang bergantung pada target library umum C. Haruskah target A dapat menggunakan class yang ditentukan dalam target C?

Dependensi
transitif

Gambar 1. Dependensi transitif

Sejauh menyangkut alat yang mendasarinya, tidak ada masalah dengan hal ini; B dan C akan ditautkan ke target A saat di-build, sehingga simbol apa pun yang ditentukan di C diketahui oleh A. Bazel mengizinkan hal ini selama bertahun-tahun, tetapi seiring pertumbuhan Google, kami mulai melihat masalah. Misalnya, B di-refaktor sehingga tidak lagi perlu bergantung pada C. Jika dependensi B pada C kemudian dihapus, A dan target lain yang menggunakan C melalui dependensi pada B akan rusak. Secara efektif, dependensi target menjadi bagian dari kontrak publiknya dan tidak pernah dapat diubah dengan aman. Artinya, dependensi terakumulasi dari waktu ke waktu dan build di Google mulai melambat.

Google akhirnya menyelesaikan masalah ini dengan memperkenalkan "mode dependensi transitif yang ketat" di Bazel. Dalam mode ini, Bazel mendeteksi apakah target mencoba mereferensikan simbol tanpa bergantung langsung padanya dan, jika demikian, akan gagal dengan error dan perintah shell yang dapat digunakan untuk menyisipkan dependensi secara otomatis. Meluncurkan perubahan ini di seluruh codebase Google dan me-refaktor setiap satu juta target build kami untuk mencantumkan dependensinya secara eksplisit adalah upaya multi-tahun, tetapi hal ini sangat bermanfaat. Build kami kini jauh lebih cepat karena target memiliki lebih sedikit dependensi yang tidak diperlukan, dan engineer dapat menghapus dependensi yang tidak mereka butuhkan tanpa khawatir akan merusak target yang bergantung padanya.

Seperti biasa, penerapan dependensi transitif yang ketat melibatkan pertukaran. Hal ini membuat file build lebih verbose, karena library yang sering digunakan kini perlu dicantumkan secara eksplisit di banyak tempat, bukan ditarik secara kebetulan, dan engineer perlu mengeluarkan lebih banyak upaya untuk menambahkan dependensi ke file BUILD. Sejak saat itu, kami telah mengembangkan alat yang mengurangi pekerjaan ini dengan mendeteksi banyak dependensi yang hilang secara otomatis dan menambahkannya ke file BUILD tanpa intervensi developer. Namun, bahkan tanpa alat tersebut, kami menemukan bahwa pertukaran tersebut sangat bermanfaat karena codebase diskalakan: menambahkan dependensi secara eksplisit ke file BUILD adalah biaya satu kali, tetapi menangani dependensi transitif implisit dapat menyebabkan masalah berkelanjutan selama target build ada. Bazel menerapkan dependensi transitif yang ketat pada kode Java secara default.

Dependensi eksternal

Jika dependensi tidak bersifat internal, dependensi tersebut harus bersifat eksternal. Dependensi eksternal adalah dependensi pada artefak yang di-build dan disimpan di luar sistem build. Dependensi diimpor langsung dari repositori artefak (biasanya diakses melalui internet) dan digunakan sebagaimana adanya, bukan di-build dari sumber. Salah satu perbedaan terbesar antara dependensi eksternal dan internal adalah bahwa dependensi eksternal memiliki versi, dan versi tersebut ada secara independen dari kode sumber project.

Pengelolaan dependensi otomatis versus manual

Sistem build dapat memungkinkan versi dependensi eksternal dikelola secara manual atau otomatis. Jika dikelola secara manual, buildfile akan mencantumkan secara eksplisit versi yang ingin didownload dari repositori artefak, sering kali menggunakan string versi semantik seperti 1.1.4. Jika dikelola secara otomatis, file sumber akan menentukan rentang versi yang dapat diterima, dan sistem build akan selalu mendownload versi terbaru. Misalnya, Gradle memungkinkan versi dependensi dideklarasikan sebagai "1.+" untuk menentukan bahwa versi minor atau patch dependensi dapat diterima selama versi utama adalah 1.

Dependensi yang dikelola secara otomatis dapat memudahkan project kecil, tetapi biasanya merupakan resep untuk bencana pada project berukuran tidak sepele atau yang dikerjakan oleh lebih dari satu engineer. Masalah dengan dependensi yang dikelola secara otomatis adalah Anda tidak memiliki kontrol atas kapan versi diperbarui. Tidak ada cara untuk menjamin bahwa pihak eksternal tidak akan membuat pembaruan yang merusak (bahkan saat mereka mengklaim menggunakan versi semantik), sehingga build yang berfungsi pada suatu hari mungkin rusak pada hari berikutnya tanpa cara mudah untuk mendeteksi apa yang berubah atau mengembalikannya ke status yang berfungsi. Meskipun build tidak rusak, mungkin ada perubahan perilaku atau performa yang tidak terlihat dan tidak mungkin dilacak.

Sebaliknya, karena dependensi yang dikelola secara manual memerlukan perubahan dalam kontrol sumber, dependensi tersebut dapat ditemukan dan dikembalikan dengan mudah, dan Anda dapat memeriksa versi repositori yang lebih lama untuk di-build dengan dependensi yang lebih lama. Bazel mengharuskan versi semua dependensi ditentukan secara manual. Bahkan dalam skala sedang, overhead pengelolaan versi manual sangat bermanfaat untuk stabilitas yang diberikannya.

Aturan Satu Versi

Versi library yang berbeda biasanya direpresentasikan oleh artefak yang berbeda, sehingga secara teori tidak ada alasan mengapa versi yang berbeda dari dependensi eksternal yang sama tidak dapat dideklarasikan dalam sistem build dengan nama yang berbeda. Dengan begitu, setiap target dapat memilih versi dependensi yang ingin digunakan. Hal ini menyebabkan banyak masalah dalam praktiknya, sehingga Google menerapkan Aturan Satu Versi yang ketat untuk semua dependensi pihak ketiga dalam codebase kami.

Masalah terbesar dengan mengizinkan beberapa versi adalah masalah dependensi berlian. Misalnya, target A bergantung pada target B dan pada v1 library eksternal. Jika target B kemudian di-refaktor untuk menambahkan dependensi pada v2 library eksternal yang sama, target A akan rusak karena sekarang bergantung secara implisit pada dua versi library yang sama. Secara efektif, tidak pernah aman untuk menambahkan dependensi baru dari target ke library pihak ketiga mana pun dengan beberapa versi, karena pengguna target tersebut mungkin sudah bergantung pada versi yang berbeda. Mengikuti Aturan Satu Versi membuat konflik ini tidak mungkin terjadi—jika target menambahkan dependensi pada library pihak ketiga, dependensi yang ada akan berada pada versi yang sama, sehingga dapat hidup berdampingan dengan baik.

Dependensi eksternal transitif

Menangani dependensi transitif dari dependensi eksternal dapat sangat sulit. Banyak repositori artefak seperti Maven Central, memungkinkan artefak menentukan dependensi pada versi tertentu dari artefak lain di repositori. Alat build seperti Maven atau Gradle sering kali mendownload setiap dependensi transitif secara rekursif secara default, yang berarti bahwa menambahkan satu dependensi dalam project Anda berpotensi menyebabkan puluhan artefak didownload secara total.

Hal ini sangat praktis: saat menambahkan dependensi pada library baru, akan sangat merepotkan jika harus melacak setiap dependensi transitif library tersebut dan menambahkannya secara manual. Namun, ada juga kelemahan besar: karena library yang berbeda dapat bergantung pada versi library pihak ketiga yang sama, strategi ini pasti melanggar Aturan Satu Versi dan menyebabkan masalah dependensi berlian. Jika target Anda bergantung pada dua library eksternal yang menggunakan versi dependensi yang sama, tidak ada cara untuk mengetahui mana yang akan Anda dapatkan. Hal ini juga berarti bahwa mengupdate dependensi eksternal dapat menyebabkan kegagalan yang tampaknya tidak terkait di seluruh codebase jika versi baru mulai menarik versi yang bertentangan dari beberapa dependensinya.

Bazel tidak menggunakan untuk mendownload dependensi transitif secara otomatis. Bazel dulu menggunakan file WORKSPACE yang mengharuskan semua dependensi transitif dicantumkan, yang menyebabkan banyak kesulitan saat mengelola dependensi eksternal. Bazel sejak saat itu telah menambahkan dukungan untuk pengelolaan dependensi eksternal transitif otomatis dalam bentuk file MODULE.bazel. Lihat ringkasan dependensi eksternal untuk mengetahui detail selengkapnya.

Sekali lagi, pilihan di sini adalah antara kemudahan dan skalabilitas. Project kecil mungkin lebih suka tidak perlu khawatir mengelola dependensi transitif sendiri dan mungkin dapat menggunakan dependensi transitif otomatis. Strategi ini menjadi semakin tidak menarik seiring pertumbuhan organisasi dan codebase, serta konflik dan hasil yang tidak terduga menjadi semakin sering terjadi. Dalam skala yang lebih besar, biaya pengelolaan dependensi secara manual jauh lebih rendah daripada biaya menangani masalah yang disebabkan oleh pengelolaan dependensi otomatis.

Menyimpan hasil build dalam cache menggunakan dependensi eksternal

Dependensi eksternal paling sering disediakan oleh pihak ketiga yang merilis versi library yang stabil, mungkin tanpa menyediakan kode sumber. Beberapa organisasi mungkin juga memilih untuk menyediakan beberapa kode mereka sendiri sebagai artefak, sehingga bagian kode lainnya dapat bergantung padanya sebagai pihak ketiga, bukan dependensi internal. Secara teori, hal ini dapat mempercepat build jika artefak lambat di-build, tetapi cepat didownload.

Namun, hal ini juga menimbulkan banyak overhead dan kompleksitas: seseorang harus bertanggung jawab untuk mem-build setiap artefak tersebut dan menguploadnya ke repositori artefak, dan klien harus memastikan bahwa mereka selalu mendapatkan versi terbaru. Proses debug juga menjadi jauh lebih sulit karena berbagai bagian sistem akan di-build dari berbagai titik di repositori, dan tidak ada lagi tampilan pohon sumber yang konsisten.

Cara yang lebih baik untuk mengatasi masalah artefak yang membutuhkan waktu lama untuk di-build adalah menggunakan sistem build yang mendukung penyimpanan dalam cache jarak jauh, seperti yang dijelaskan sebelumnya. Sistem build tersebut menyimpan artefak yang dihasilkan dari setiap build ke lokasi yang dibagikan di antara engineer, sehingga jika developer bergantung pada artefak yang baru saja di-build oleh orang lain, sistem build akan otomatis mendownloadnya, bukan mem-build-nya. Hal ini memberikan semua manfaat performa yang bergantung langsung pada artefak sekaligus memastikan bahwa build sekonsisten jika selalu di-build dari sumber yang sama. Ini adalah strategi yang digunakan secara internal oleh Google, dan Bazel dapat dikonfigurasi untuk menggunakan cache jarak jauh.

Keamanan dan keandalan dependensi eksternal

Bergantung pada artefak dari sumber pihak ketiga pada dasarnya berisiko. Ada risiko ketersediaan jika sumber pihak ketiga (seperti repositori artefak) tidak berfungsi, karena seluruh build Anda mungkin akan terhenti jika tidak dapat mendownload dependensi eksternal. Ada juga risiko keamanan: jika sistem pihak ketiga disusupi oleh penyerang, penyerang dapat mengganti artefak yang direferensikan dengan desainnya sendiri, sehingga mereka dapat menyuntikkan kode arbitrer ke dalam build Anda. Kedua masalah ini dapat dikurangi dengan membuat cermin artefak yang Anda andalkan ke server yang Anda kontrol dan memblokir sistem build Anda agar tidak mengakses repositori artefak pihak ketiga seperti Maven Central. Pertukarannya adalah cermin ini memerlukan upaya dan resource untuk dipertahankan, sehingga pilihan apakah akan menggunakannya sering kali bergantung pada skala project. Masalah keamanan juga dapat dicegah sepenuhnya dengan sedikit overhead dengan mewajibkan hash setiap artefak pihak ketiga ditentukan di repositori sumber, sehingga menyebabkan build gagal jika artefak dirusak. Alternatif lain yang sepenuhnya menghindari masalah ini adalah dengan menyediakan dependensi project Anda. Saat project menyediakan dependensinya, project akan memeriksanya ke kontrol sumber bersama dengan kode sumber project, baik sebagai sumber maupun sebagai biner. Hal ini secara efektif berarti bahwa semua dependensi eksternal project dikonversi menjadi dependensi internal. Google menggunakan pendekatan ini secara internal, memeriksa setiap library pihak ketiga yang direferensikan di seluruh Google ke direktori third_party di root pohon sumber Google. Namun, hal ini hanya berfungsi di Google karena sistem kontrol sumber Google dibuat khusus untuk menangani monorepo yang sangat besar, sehingga penyediaan mungkin bukan opsi untuk semua organisasi.