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 “push dokumentasi sebelum saya menandai rilis sebagai selesai”), dan terkadang ada dependensi pada artefak (seperti “Saya perlu memiliki library computer vision versi terbaru untuk mem-build kode saya”). Terkadang, Anda memiliki dependensi internal pada bagian lain codebase, dan terkadang Anda memiliki dependensi eksternal pada kode atau data yang dimiliki oleh tim lain (baik di organisasi Anda maupun pihak ketiga). Namun, ide “Saya memerlukannya sebelum saya bisa memiliki ini” adalah sesuatu yang berulang kali muncul dalam desain sistem build, dan mengelola dependensi mungkin adalah tugas yang paling mendasar dari sistem build.
Menangani Modul dan Dependensi
Project yang menggunakan sistem build berbasis artefak seperti Bazel dibagi menjadi kumpulan
modul, dengan modul yang mengekspresikan dependensi satu sama lain melalui file
BUILD
. Pengaturan modul dan dependensi ini yang tepat dapat memberikan dampak besar
pada performa sistem build dan jumlah pekerjaan yang diperlukan untuk
mempertahankannya.
Menggunakan Modul yang Lebih Mendetail dan Aturan 1:1:1
Pertanyaan pertama yang muncul saat menyusun build berbasis artefak adalah
menentukan jumlah 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
. Di satu sisi ekstrem, seluruh project dapat
berisi dalam satu modul dengan menempatkan satu file BUILD
di root dan
menggabungkan semua file sumber project tersebut secara rekursif. Di sisi lain, hampir setiap file sumber dapat dibuat menjadi modulnya sendiri, yang secara efektif
memerlukan setiap file untuk mencantumkan dalam file BUILD
setiap file lain yang menjadi dependensinya.
Sebagian besar project berada di antara ekstrem ini, dan pilihannya melibatkan
kompromi antara performa dan kemampuan pemeliharaan. Menggunakan satu modul untuk
seluruh project mungkin berarti Anda tidak perlu menyentuh file BUILD
kecuali
saat menambahkan dependensi eksternal, tetapi ini berarti sistem build harus
selalu mem-build seluruh project sekaligus. Artinya, build tidak akan dapat
melakukan paralelisasi atau mendistribusikan bagian build, dan tidak akan dapat meng-cache bagian
yang sudah 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 mengelola daftar dependensi setiap kali
mereka mengubah file yang mereferensikan file mana.
Meskipun tingkat perincian 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 standar 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
berdasarkan Bazel, menyebutnya sebagai 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
mengarah ke 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 terperinci berarti sistem build dapat menjadi jauh lebih cerdas dalam
hanya menjalankan subset pengujian terbatas yang dapat terpengaruh oleh perubahan
tertentu. Karena Google percaya pada manfaat sistemis dari penggunaan target yang lebih kecil, kami telah melakukan beberapa langkah untuk mengurangi kelemahan dengan berinvestasi dalam alat untuk mengelola file BUILD
secara otomatis agar tidak membebani developer.
Beberapa alat ini, seperti buildifier
dan buildozer
, tersedia dengan
Bazel di
direktori buildtools
.
Meminimalkan Visibilitas Modul
Bazel dan sistem build lainnya memungkinkan setiap target menentukan visibilitas —
properti yang menentukan target lain yang mungkin bergantung padanya. Target pribadi
hanya dapat direferensikan dalam file BUILD
-nya sendiri. Target dapat memberikan visibilitas
yang lebih luas ke target daftar file BUILD
yang ditentukan secara eksplisit, atau, dalam
hal visibilitas publik, ke setiap target di ruang kerja.
Seperti sebagian besar bahasa pemrograman, sebaiknya minimalkan visibilitas
sebanyak mungkin. Umumnya, tim di Google hanya akan membuat target bersifat publik jika
target tersebut mewakili library yang banyak digunakan dan tersedia untuk tim mana pun di Google.
Tim yang mewajibkan orang lain untuk berkoordinasi dengan mereka sebelum menggunakan kode mereka akan
mempertahankan daftar yang diizinkan untuk 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
pribadi.
Mengelola dependensi
Modul harus dapat saling merujuk. Kelemahan membagi
codebase menjadi modul terperinci 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 dibagi menjadi modul terperinci, sebagian besar dependensi kemungkinan bersifat internal; yaitu, pada target lain yang ditentukan dan dibuat di repositori sumber yang sama. Dependensi internal berbeda dengan dependensi eksternal karena dependensi internal dibuat dari sumber, bukan didownload sebagai artefak bawaan saat menjalankan build. Hal ini juga berarti 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 cermat terkait dependensi internal adalah cara memperlakukan dependensi transitif (Gambar 1). Misalkan target A bergantung pada target B, yang bergantung pada target library umum C. Apakah target A dapat menggunakan class yang ditentukan di target C?
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 setiap simbol yang ditentukan di C diketahui oleh A. Bazel mengizinkan hal ini selama bertahun-tahun, tetapi seiring berkembangnya Google, kami mulai melihat masalah. Misalkan B difaktorkan ulang sehingga tidak lagi harus 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 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 transitip ketat” di Bazel. Dalam mode ini, Bazel mendeteksi apakah target mencoba mereferensikan simbol tanpa bergantung padanya secara langsung dan, jika demikian, gagal dengan error dan perintah shell yang dapat digunakan untuk menyisipkan dependensi secara otomatis. Meluncurkan perubahan ini di seluruh codebase Google dan memfaktorkan ulang setiap jutaan target build kami untuk mencantumkan dependensinya secara eksplisit adalah upaya yang memerlukan waktu bertahun-tahun, tetapi hasilnya sangat sepadan. Build kami kini jauh lebih cepat karena target memiliki lebih sedikit dependensi yang tidak perlu, dan engineer diberi kemampuan untuk menghapus dependensi yang tidak mereka perlukan tanpa khawatir akan merusak target yang bergantung padanya.
Seperti biasa, menerapkan dependensi transitif yang ketat melibatkan kompromi. Hal ini membuat
file build lebih panjang, karena library yang sering digunakan kini perlu dicantumkan
secara eksplisit di banyak tempat, bukan ditarik secara insidental, dan engineer
perlu menghabiskan lebih banyak upaya untuk menambahkan dependensi ke file BUILD
. Sejak itu, kami
telah mengembangkan alat yang mengurangi beban ini dengan mendeteksi secara otomatis banyak dependensi
yang hilang dan menambahkannya ke file BUILD
tanpa intervensi
developer. Namun, meskipun tanpa alat tersebut, kami mendapati bahwa kompromi tersebut sangat
bernilai saat 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 bukan internal, dependensi harus 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 apa adanya, bukan dibuat dari sumber. Salah satu perbedaan terbesar antara dependensi eksternal dan internal adalah dependensi eksternal memiliki versi, dan versi tersebut ada secara independen dari kode sumber project.
Manajemen dependensi otomatis versus manual
Sistem build dapat mengizinkan versi dependensi eksternal dikelola
secara manual atau otomatis. Jika dikelola secara manual, buildfile
secara eksplisit mencantumkan 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 selalu mendownload versi terbaru. Misalnya, Gradle memungkinkan versi dependensi dideklarasikan sebagai “1.+” untuk menentukan
bahwa versi minor atau patch dependensi dapat diterima selama
versi utamanya adalah 1.
Dependensi yang dikelola secara otomatis dapat memudahkan project kecil, tetapi biasanya menjadi penyebab masalah pada project dengan ukuran yang tidak biasa atau yang dikerjakan oleh lebih dari satu engineer. Masalah dengan dependensi yang dikelola secara otomatis adalah Anda tidak dapat mengontrol kapan versi tersebut diperbarui. Tidak ada cara untuk menjamin bahwa pihak eksternal tidak akan membuat update yang merusak (meskipun mereka mengklaim menggunakan pembuatan versi semantik), sehingga build yang berfungsi pada suatu hari mungkin rusak pada hari berikutnya tanpa cara mudah untuk mendeteksi perubahan atau untuk mengembalikannya ke status yang berfungsi. Meskipun build tidak rusak, mungkin ada perubahan perilaku atau performa halus yang tidak dapat dilacak.
Sebaliknya, karena dependensi yang dikelola secara manual memerlukan perubahan kontrol sumber, dependensi tersebut dapat ditemukan dan di-roll back dengan mudah, dan Anda dapat mengambil versi lama repositori untuk mem-build dengan dependensi lama. Bazel mewajibkan versi semua dependensi ditentukan secara manual. Bahkan pada skala sedang, overhead pengelolaan versi manual sangat sepadan dengan stabilitas yang diberikannya.
Aturan Satu Versi
Versi library yang berbeda biasanya diwakili oleh artefak yang berbeda, jadi 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 demikian, 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 di codebase kami.
Masalah terbesar dengan mengizinkan beberapa versi adalah masalah dependensi berlian. Misalkan target A bergantung pada target B dan v1 library eksternal. Jika target B kemudian difaktorkan ulang untuk menambahkan dependensi pada v2 library eksternal yang sama, target A akan rusak karena kini bergantung secara implisit pada dua versi berbeda dari library yang sama. Secara efektif, tidak pernah aman untuk menambahkan dependensi baru dari target ke library pihak ketiga dengan beberapa versi, karena pengguna target tersebut mungkin sudah bergantung pada versi yang berbeda. Dengan mengikuti Aturan Satu Versi, konflik ini tidak akan terjadi—jika target menambahkan dependensi pada library pihak ketiga, dependensi yang ada akan sudah berada di versi yang sama, sehingga keduanya dapat 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 artefak lain tertentu 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 menambahkan semuanya secara manual. Namun, ada juga kelemahan besar: karena library yang berbeda dapat bergantung pada versi yang berbeda dari library pihak ketiga yang sama, strategi ini pasti melanggar Aturan Satu Versi dan menyebabkan masalah dependensi diamond. Jika target Anda bergantung pada dua library eksternal yang menggunakan versi berbeda dari dependensi yang sama, tidak ada yang tahu 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.
Oleh karena itu, Bazel tidak otomatis mendownload dependensi transitif.
Sayangnya, tidak ada solusi praktis—alternatif Bazel adalah mewajibkan
file global yang mencantumkan setiap dependensi eksternal
repositori dan versi eksplisit yang digunakan untuk dependensi tersebut di seluruh
repositori. Untungnya, Bazel menyediakan alat yang dapat otomatis
membuat file tersebut yang berisi dependensi transitif dari kumpulan artefak
Maven. Alat ini dapat dijalankan sekali untuk membuat file WORKSPACE
awal
untuk project, dan file tersebut kemudian dapat diperbarui secara manual untuk menyesuaikan versi
setiap dependensi.
Sekali lagi, pilihan di sini adalah antara kemudahan dan skalabilitas. Project kecil mungkin lebih memilih untuk tidak perlu khawatir mengelola dependensi transitif sendiri dan mungkin dapat menggunakan dependensi transitif otomatis. Strategi ini menjadi semakin tidak menarik seiring berkembangnya organisasi dan codebase, serta konflik dan hasil yang tidak terduga menjadi semakin sering. Pada skala yang lebih besar, biaya pengelolaan dependensi secara manual jauh lebih kecil daripada biaya untuk 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 library versi stabil, mungkin tanpa menyediakan kode sumber. Beberapa organisasi mungkin juga memilih untuk menyediakan beberapa kode mereka sendiri sebagai artefak, sehingga memungkinkan bagian kode lain bergantung pada kode tersebut sebagai pihak ketiga, bukan dependensi internal. Secara teoritis, hal ini dapat mempercepat build jika artefak lambat di-build, tetapi cepat didownload.
Namun, hal ini juga menyebabkan 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 menggunakan 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 hierarki sumber yang konsisten.
Cara yang lebih baik untuk mengatasi masalah artefak yang memerlukan waktu lama untuk di-build adalah dengan menggunakan sistem build yang mendukung penyimpanan cache jarak jauh, seperti yang dijelaskan sebelumnya. Sistem build tersebut menyimpan artefak yang dihasilkan dari setiap build ke lokasi yang dibagikan ke seluruh engineer, sehingga jika developer bergantung pada artefak yang baru-baru ini dibuat oleh orang lain, sistem build akan otomatis mendownloadnya bukan membangunnya. Hal ini memberikan semua manfaat performa yang bergantung langsung pada artefak sekaligus memastikan bahwa build konsisten seolah-olah selalu dibuat 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 tersedia, karena seluruh build Anda mungkin berhenti jika tidak dapat mendownload
dependensi eksternal. Ada juga risiko keamanan: jika sistem pihak ketiga
disusupi oleh penyerang, penyerang dapat mengganti artefak
yang dirujuk dengan salah satu desainnya sendiri, sehingga memungkinkan mereka memasukkan kode arbitrer
ke dalam build Anda. Kedua masalah tersebut dapat dimitigasi dengan mencerminkan artefak apa pun yang Anda
butuhkan ke server yang Anda kontrol dan memblokir sistem build agar tidak mengakses
repositori artefak pihak ketiga seperti Maven Central. Namun, mirror ini memerlukan upaya dan resource untuk dikelola, sehingga pilihan untuk menggunakannya sering kali bergantung pada skala project. Masalah keamanan juga dapat
dicegah sepenuhnya dengan sedikit overhead dengan mewajibkan hash setiap
artefak pihak ketiga untuk ditentukan di repositori sumber, sehingga menyebabkan build
gagal jika artefak dirusak. Alternatif lain yang sepenuhnya
menghindari masalah ini adalah dengan membeli dependensi project Anda. Saat project
menjual dependensinya, project akan memeriksanya ke dalam kontrol sumber bersama
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 dirujuk di seluruh Google ke dalam direktori third_party
di root
hierarki sumber Google. Namun, hal ini hanya berfungsi di Google karena sistem kontrol sumber Google
dibuat secara khusus untuk menangani monorepo yang sangat besar, sehingga
vendoring mungkin bukan opsi untuk semua organisasi.