Manajemen Ketergantungan

Laporkan masalah Lihat sumber {/18/}{/1/}

Saat melihat halaman sebelumnya, satu tema akan berulang: mengelola kode Anda sendiri cukup mudah, tetapi mengelola dependensinya jauh lebih sulit. Ada segala macam 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 Anda, dan terkadang Anda memiliki organisasi eksternal pada kode atau data yang dimiliki oleh tim lain (terkadang Anda memiliki organisasi eksternal pada kode atau data yang dimiliki oleh tim Anda). Namun, dalam situasi apa pun, ide "Saya perlu memiliki ini sebelum dapat memiliki ini" adalah sesuatu yang berulang berulang kali dalam desain sistem build, dan mengelola dependensi mungkin merupakan tugas yang paling dasar dari sistem build.

Menangani Modul dan Dependensi

Project yang menggunakan sistem build berbasis artefak seperti Bazel dipecah menjadi serangkaian modul, dengan modul yang menyatakan dependensi satu sama lain melalui file BUILD. Pengaturan yang tepat dari modul dan dependensi ini dapat berdampak besar terhadap performa sistem build dan banyaknya pekerjaan yang diperlukan untuk memelihara.

Menggunakan Modul Berbutir Halus dan Aturan 1:1:1

Pertanyaan pertama yang muncul saat menyusun build berbasis artefak adalah menentukan seberapa banyak fungsi yang harus disertakan dalam setiap modul. Pada Bazel, modul direpresentasikan oleh target yang menentukan unit yang dapat dibangun seperti java_library atau go_binary. Pada satu ekstrem, seluruh project dapat ditampung dalam satu modul dengan menempatkan satu file BUILD di root dan menggabung-gabungkan semua file sumber project tersebut secara rekursif. Di ekstrem lainnya, hampir setiap file sumber dapat dibuat menjadi modulnya sendiri, secara efektif mengharuskan setiap file dicantumkan dalam file BUILD untuk setiap file lain yang menjadi tempat bergantungnya.

Sebagian besar project berada di antara tingkat ekstrem ini, dan pilihannya melibatkan kompromi antara performa dan kemudahan pengelolaan. Menggunakan satu modul untuk seluruh project dapat berarti bahwa Anda tidak perlu menyentuh file BUILD kecuali saat menambahkan dependensi eksternal. Namun, ini berarti sistem build harus selalu membuat seluruh project sekaligus. Artinya, aplikasi tidak akan dapat memparalelkan atau mendistribusikan bagian build, serta tidak dapat menyimpan bagian-bagian yang sudah dibuat ke cache. Satu modul per file adalah kebalikannya: sistem build memiliki fleksibilitas maksimum dalam cache dan penjadwalan langkah-langkah build, tetapi engineer perlu mengerahkan lebih banyak upaya untuk mengelola daftar dependensi setiap kali mereka mengubah file referensi yang mana.

Meskipun perincian persisnya bervariasi menurut bahasa (dan sering kali bahkan dalam bahasa), Google cenderung mendukung 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 pengemasan bawaan yang kuat, setiap direktori biasanya berisi satu paket, target, dan file BUILD (Celana, sistem build lain yang berbasis pada Bazel, menyebutnya aturan 1:1:1). Bahasa dengan konvensi pengemasan yang lebih lemah sering menentukan beberapa target per file BUILD.

Manfaat target build yang lebih kecil benar-benar mulai terlihat dalam skala besar karena menghasilkan build yang terdistribusi lebih cepat dan mengurangi kebutuhan untuk membangun ulang target. Kelebihannya akan menjadi lebih menarik setelah pengujian memasuki gambaran, karena target yang lebih halus berarti bahwa sistem build dapat menjadi jauh lebih cerdas dalam menjalankan hanya subset pengujian terbatas yang dapat terpengaruh oleh perubahan tertentu. Karena Google meyakini manfaat sistemik dari penggunaan target yang lebih kecil, kami telah mengambil beberapa langkah dalam mengurangi kekurangannya dengan berinvestasi dalam alat untuk mengelola file BUILD secara otomatis agar tidak membebani developer.

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

Meminimalkan Visibilitas Modul

Bazel dan sistem build lainnya memungkinkan setiap target menentukan visibilitas, yaitu 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 kasus visibilitas publik, ke setiap target di ruang kerja.

Seperti kebanyakan bahasa pemrograman, sebaiknya minimalkan visibilitas sesering mungkin. Umumnya, tim di Google akan membuat target bersifat publik hanya jika target tersebut mewakili library yang banyak digunakan, yang tersedia untuk tim mana pun di Google. Tim yang mengharuskan orang lain berkoordinasi dengan mereka sebelum menggunakan kode mereka akan mempertahankan daftar target pelanggan yang diizinkan 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 satu sama lain. Kelemahan dari memecah codebase menjadi modul terperinci adalah Anda perlu mengelola dependensi di antara modul-modul tersebut (meskipun alat dapat membantu mengotomatiskannya). Mengekspresikan dependensi ini biasanya pada akhirnya menjadi sebagian besar konten dalam file BUILD.

Dependensi internal

Dalam project besar yang dipecah menjadi modul terperinci, sebagian besar dependensi kemungkinan bersifat internal; yaitu, pada target lain yang ditentukan dan dibangun dalam repositori sumber yang sama. Dependensi internal berbeda dengan dependensi eksternal karena di-build dari sumber, bukan didownload sebagai artefak bawaan saat menjalankan build. Ini juga berarti bahwa tidak ada gagasan tentang “versi” untuk dependensi internal—target dan semua dependensi internalnya selalu di-build pada commit/revisi yang sama dalam repositori. Satu masalah yang harus ditangani dengan hati-hati sehubungan dengan dependensi internal adalah cara menangani dependensi transitif (Gambar 1). Misalkan 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 alat yang mendasarinya, tidak ada masalah dengan hal ini; baik B dan C akan ditautkan ke target A saat dibuat, sehingga setiap simbol yang ditentukan dalam C diketahui oleh A. Bazel mengizinkannya selama bertahun-tahun, tetapi seiring dengan berkembangnya Google, kami mulai melihat adanya masalah. Misalkan B telah difaktorkan ulang sehingga tidak perlu lagi 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. Ini berarti dependensi yang terakumulasi seiring waktu dan dibangun di Google mulai melambat.

Google akhirnya mengatasi masalah ini dengan memperkenalkan “mode dependensi transitif ketat” pada Bazel. Dalam mode ini, Bazel mendeteksi apakah target mencoba mereferensikan simbol tanpa bergantung pada simbol tersebut secara langsung dan, jika ya, akan gagal dengan error dan perintah shell yang dapat digunakan untuk menyisipkan dependensi secara otomatis. Meluncurkan perubahan ini di seluruh codebase Google dan melakukan pemfaktoran ulang setiap satu dari jutaan target build kami untuk mencantumkan secara eksplisit dependensinya adalah upaya yang berlangsung selama beberapa tahun, tetapi hal ini sangat bermanfaat. Build kami sekarang jauh lebih cepat mengingat target memiliki lebih sedikit dependensi yang tidak diperlukan, dan engineer didukung untuk menghapus dependensi yang tidak diperlukan tanpa khawatir akan merusak target yang bergantung padanya.

Seperti biasa, penerapan dependensi transitif yang ketat melibatkan kompromi. Hal ini membuat file build lebih panjang, karena library yang sering digunakan sekarang harus dicantumkan secara eksplisit di banyak tempat, bukan ditarik secara tidak sengaja, dan engineer perlu lebih berusaha menambahkan dependensi ke file BUILD. Sejak itu, kami telah mengembangkan alat yang mengurangi toil ini dengan mendeteksi secara otomatis banyak dependensi yang hilang dan menambahkannya ke file BUILD tanpa intervensi developer apa pun. Namun, meskipun tanpa alat tersebut, kami mendapati bahwa komprominya akan sangat bermanfaat karena skala codebase: menambahkan dependensi secara eksplisit ke file BUILD memerlukan biaya satu kali, tetapi menangani dependensi transitif implisit dapat menyebabkan masalah yang berkelanjutan selama target build ada. Bazel menerapkan dependensi transitif yang ketat pada kode Java secara default.

Dependensi eksternal

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

Pengelolaan 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 mengizinkan versi dependensi dideklarasikan sebagai “1.+” untuk menentukan bahwa versi minor atau patch dependensi dapat diterima asalkan versi utamanya adalah 1.

Dependensi yang dikelola secara otomatis mungkin praktis untuk project kecil, tetapi dependensi tersebut biasanya merupakan resep bencana pada project dengan ukuran yang tidak signifikan atau yang sedang dikerjakan oleh lebih dari satu engineer. Masalah pada dependensi yang dikelola secara otomatis adalah Anda tidak memiliki kontrol atas kapan versi diupdate. Tidak ada cara untuk menjamin bahwa pihak eksternal tidak akan melakukan update yang dapat menyebabkan gangguan (bahkan saat mereka mengklaim menggunakan pembuatan versi semantik), sehingga build yang berfungsi pada satu hari mungkin akan rusak di hari berikutnya tanpa ada cara mudah untuk mendeteksi apa yang berubah atau melakukan roll back ke status kerja. Meskipun build tidak rusak, mungkin ada perubahan perilaku atau performa halus yang tidak mungkin dilacak.

Sebaliknya, karena dependensi yang dikelola secara manual memerlukan perubahan dalam kontrol sumber, dependensi tersebut dapat dengan mudah ditemukan dan di-roll back. Anda juga dapat mencoba repositori versi lama untuk mem-build dengan dependensi yang lebih lama. Bazel mengharuskan versi semua dependensi ditentukan secara manual. Pada skala bahkan sedang, overhead pengelolaan versi manual akan sangat bermanfaat untuk stabilitas yang diberikannya.

Aturan Satu Versi

Versi library yang berbeda biasanya direpresentasikan oleh artefak yang berbeda, jadi secara teori tidak ada alasan bahwa 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, jadi Google menerapkan Aturan Satu Versi yang ketat untuk semua dependensi pihak ketiga di codebase kami.

Masalah terbesar dalam mengizinkan beberapa versi adalah masalah dependensi diamond. Misalkan target A bergantung pada target B dan pada v1 library eksternal. Jika target B kemudian difaktorkan ulang untuk menambahkan dependensi pada v2 library eksternal yang sama, target A akan rusak karena target A sekarang bergantung secara implisit pada dua versi berbeda dari library yang sama. Secara efektif, menambahkan dependensi baru dari target ke library pihak ketiga dengan beberapa versi tidaklah aman, karena setiap 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 apa pun yang ada akan berada pada versi yang sama, sehingga keduanya dapat digunakan berdampingan.

Dependensi eksternal transitif

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

Cara ini sangat praktis: saat menambahkan dependensi pada library baru, akan sangat sulit jika Anda 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 library pihak ketiga yang berbeda, strategi ini pasti melanggar Aturan Satu Versi dan menyebabkan masalah dependensi berlian. 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. Ini juga berarti bahwa mengupdate dependensi eksternal dapat menyebabkan kegagalan yang tampaknya tidak terkait di seluruh codebase jika versi baru mulai mengambil versi yang bertentangan dari beberapa dependensinya.

Karena alasan ini, Bazel tidak secara otomatis mengunduh dependensi transitif. Sayangnya, sayangnya tidak ada solusi alternatif. 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 sekumpulan artefak Maven. Alat ini dapat dijalankan sekali untuk membuat file WORKSPACE awal untuk sebuah project, dan file tersebut kemudian dapat diupdate secara manual untuk menyesuaikan versi setiap dependensi.

Sekali lagi, pilihannya adalah antara kenyamanan dan skalabilitas. Project kecil mungkin tidak perlu memikirkan pengelolaan dependensi transitif sendiri dan mungkin dapat menghindari penggunaan dependensi transitif otomatis. Strategi ini menjadi kurang menarik seiring dengan berkembangnya organisasi dan codebase, serta konflik dan hasil yang tidak terduga makin sering terjadi. Pada skala yang lebih besar, biaya pengelolaan dependensi secara manual jauh lebih sedikit daripada biaya untuk menangani masalah yang disebabkan oleh pengelolaan dependensi otomatis.

Menyimpan cache hasil build menggunakan dependensi eksternal

Dependensi eksternal paling sering disediakan oleh pihak ketiga yang merilis versi library stabil, mungkin tanpa menyediakan kode sumber. Beberapa organisasi mungkin juga memilih untuk menyediakan beberapa kodenya sendiri sebagai artefak, sehingga kode lain dapat bergantung pada kode tersebut sebagai pihak ketiga, bukan dependensi internal. Secara teoretis, hal ini dapat mempercepat build jika artefak lambat dibuat, tetapi cepat didownload.

Namun, hal ini juga menimbulkan banyak overhead dan kompleksitas: seseorang harus bertanggung jawab untuk membuat setiap artefak tersebut dan menguploadnya ke repositori artefak, dan klien harus memastikan bahwa klien terus mendapatkan versi terbaru. Proses debug juga menjadi jauh lebih sulit karena bagian sistem yang berbeda akan dibangun dari titik yang berbeda 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 caching jarak jauh, seperti yang dijelaskan sebelumnya. Sistem build seperti itu menyimpan artefak yang dihasilkan dari setiap build ke lokasi yang dibagikan kepada seluruh engineer, jadi jika developer bergantung pada artefak yang baru-baru ini dibuat oleh orang lain, sistem build akan otomatis mendownloadnya, bukan mem-build artefak tersebut. Hal ini memberikan semua manfaat performa karena bergantung langsung pada artefak sekaligus tetap memastikan bahwa build tetap konsisten seolah-olah selalu dibangun 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, berisiko secara inheren. Ada risiko ketersediaan jika sumber pihak ketiga (seperti repositori artefak) tidak aktif, karena seluruh build 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 salah satu desainnya, sehingga memungkinkan mereka memasukkan kode arbitrer ke dalam build Anda. Kedua masalah tersebut dapat dimitigasi dengan mencerminkan artefak apa pun yang Anda andalkan ke server yang Anda kontrol dan memblokir sistem build agar tidak mengakses repositori artefak pihak ketiga seperti Maven Central. Konsekuensinya, pencerminan ini memerlukan upaya dan resource untuk dipelihara, sehingga pilihan apakah akan menggunakannya sering kali bergantung pada skala project. Masalah keamanan juga dapat dicegah sepenuhnya dengan sedikit overhead dengan meminta hash setiap artefak pihak ketiga ditentukan di repositori sumber, yang menyebabkan build gagal jika artefak dirusak. Alternatif lain yang sepenuhnya menyampingkan masalah adalah dengan mem-vendor dependensi project Anda. Saat project vendor dependensinya, project tersebut memeriksanya ke dalam kontrol sumber bersama kode sumber project, baik sebagai sumber maupun biner. Hal ini secara efektif berarti bahwa semua dependensi eksternal project dikonversi menjadi dependensi internal. Google menggunakan pendekatan ini secara internal, dengan memeriksa setiap library pihak ketiga yang dirujuk di seluruh Google ke dalam direktori third_party di root hierarki sumber Google. Namun, ini hanya berfungsi di Google karena sistem kontrol sumber Google dibuat khusus untuk menangani monorepo yang sangat besar, sehingga vendor mungkin bukan pilihan untuk semua organisasi.