Pengelolaan Dependensi

Laporkan masalah Lihat sumber

Saat melihat halaman sebelumnya, satu tema berulang: mengelola kode Anda sendiri cukup mudah, tetapi mengelola dependensinya jauh lebih sulit. Ada berbagai 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 versi terbaru library computer vision untuk membuat kode saya”). Terkadang, Anda memiliki dependensi internal di bagian lain codebase, dan terkadang memiliki dependensi eksternal pada kode atau data yang dimiliki oleh organisasi lain atau pihak ketiga). Namun, bagaimanapun, ide “Saya perlu agar hal ini dapat saya lakukan” adalah sesuatu yang berulang berulang kali 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 dengan benar dapat berdampak besar pada performa sistem build dan jumlah pekerjaan yang diperlukan untuk memeliharanya.

Menggunakan Modul terperinci dan Aturan 1:1:1

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

Sebagian besar project berada di antara ekstrem, dan pilihannya adalah kompromi antara performa dan pemeliharaan. Menggunakan satu modul untuk seluruh project mungkin berarti Anda tidak perlu menyentuh file BUILD kecuali saat menambahkan dependensi eksternal, tetapi itu berarti sistem build harus selalu mem-build seluruh project sekaligus. Artinya, build tidak akan dapat menyelaraskan atau mendistribusikan bagian-bagian build, dan juga tidak akan dapat meng-cache bagian yang telah di-build. Satu modul per file adalah kebalikannya: sistem build memiliki fleksibilitas maksimum dalam caching dan menjadwalkan langkah-langkah build, tetapi engineer harus mengeluarkan lebih banyak upaya untuk mempertahankan daftar dependensi setiap kali mereka mengubah file yang mereferensikan.

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

Manfaat target build yang lebih kecil sebenarnya mulai ditampilkan dalam skala besar karena menghasilkan build terdistribusi yang lebih cepat dan kebutuhan build ulang yang lebih jarang. Keuntungan menjadi lebih menarik setelah pengujian diterapkan, karena target yang lebih mendetail berarti bahwa sistem build dapat menjadi jauh lebih cerdas dalam menjalankan subset pengujian terbatas yang dapat terpengaruh oleh perubahan tertentu. Karena Google percaya pada manfaat sistemik dari penggunaan target yang lebih kecil, kami telah membuat beberapa langkah dalam memitigasi kelemahan dengan berinvestasi dalam alat untuk mengelola file BUILD secara otomatis guna menghindari beban 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 dari daftar file BUILD yang ditentukan secara eksplisit, atau, dalam hal visibilitas publik, ke setiap target di ruang kerja.

Seperti kebanyakan bahasa pemrograman, sebaiknya minimalkan visibilitas sebanyak mungkin. Umumnya, tim di Google akan membuat target bersifat publik hanya jika target tersebut merepresentasikan library yang digunakan secara luas untuk tim di Google. Tim yang mewajibkan orang lain untuk berkoordinasi dengan mereka sebelum menggunakan kode mereka akan mempertahankan daftar target pelanggan yang diizinkan sebagai visibilitas target mereka. Setiap target implementasi internal setiap tim akan dibatasi hanya ke direktori yang dimiliki oleh tim, dan sebagian besar file BUILD hanya akan memiliki satu target yang tidak bersifat pribadi.

Mengelola Dependensi

Modul harus dapat saling merujuk. Kelemahan memecah codebase menjadi modul yang terperinci adalah Anda perlu mengelola dependensi di antara modul tersebut (meskipun alat dapat membantu mengotomatiskannya). Menyatakan dependensi ini biasanya akan berupa konten dalam file BUILD.

Dependensi internal

Dalam project besar yang dipecah menjadi modul terperinci, sebagian besar dependensi kemungkinan akan bersifat internal; yaitu, pada target lain yang ditentukan dan di-build di repositori sumber yang sama. Dependensi internal berbeda dengan dependensi eksternal karena dependensi tersebut dibuat dari sumber, bukan didownload sebagai artefak bawaan saat menjalankan build. Ini juga berarti bahwa tidak ada konsep “versi” untuk dependensi internal—target dan semua dependensi internalnya selalu dibuat pada commit/revisi yang sama di repositori. Salah satu masalah yang harus ditangani dengan cermat sehubungan dengan 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 ini; baik B dan C akan ditautkan ke target A saat dibuat, sehingga simbol apa pun yang ditentukan di C diketahui A. Bazel mengizinkannya selama bertahun-tahun, tetapi seiring Google berkembang, kami mulai mengalami masalah. Misalkan B difaktorkan ulang sehingga tidak perlu lagi bergantung pada C. Jika dependensi B pada C dihapus, A dan target lainnya 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 dari waktu ke waktu dan build di Google akan melambat.

Google akhirnya mengatasi masalah ini dengan memperkenalkan “mode dependensi transitif yang ketat” di Bazel. Dalam mode ini, Bazel mendeteksi apakah target mencoba mereferensikan simbol tanpa bergantung padanya secara langsung dan, jika ya, gagal dengan error dan perintah shell yang dapat digunakan untuk memasukkan dependensi secara otomatis. Meluncurkan perubahan ini ke seluruh codebase Google dan memfaktorkan ulang setiap satu dari jutaan target build untuk mencantumkan dependensinya secara eksplisit adalah upaya yang berlangsung selama beberapa tahun, tetapi hasilnya sepadan. Build kami kini jauh lebih cepat mengingat target memiliki lebih sedikit dependensi yang tidak diperlukan, dan engineer diberdayakan untuk menghapus dependensi yang tidak diperlukan tanpa mengkhawatirkan pelanggaran target yang bergantung pada dependensi tersebut.

Seperti biasa, penerapan dependensi transitif yang ketat memerlukan kompromi. Ini membuat file build lebih panjang, karena library yang sering digunakan sekarang harus dicantumkan secara eksplisit di banyak tempat daripada ditarik secara kebetulan, dan engineer perlu menghabiskan lebih banyak upaya untuk menambahkan dependensi ke file BUILD. Kami telah mengembangkan alat yang mengurangi toil ini dengan mendeteksi banyak dependensi yang hilang secara otomatis dan menambahkannya ke file BUILD tanpa intervensi developer. Namun, bahkan tanpa alat semacam itu, kami merasa konsekuensinya sepadan dengan skala codebase: menambahkan dependensi ke file BUILD secara eksplisit merupakan biaya satu kali, tetapi menangani dependensi transitif implisit dapat menyebabkan masalah yang sedang terjadi 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 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 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 dependensi manual

Sistem build dapat mengizinkan versi dependensi eksternal untuk 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 akan selalu mendownload versi terbaru. Misalnya, Gradle mengizinkan versi dependensi untuk dideklarasikan sebagai “1.+” untuk menetapkan bahwa versi minor atau patch dari dependensi dapat diterima selama versi utamanya adalah 1.

Dependensi yang dikelola secara otomatis dapat digunakan untuk project kecil, tetapi biasanya merupakan resep untuk bencana pada project berukuran non-trivial 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 pengguna mengklaim menggunakan pembuatan versi semantik). Jadi, build yang berfungsi satu hari mungkin akan rusak di hari berikutnya tanpa ada cara yang mudah untuk mendeteksi apa yang telah berubah, atau melakukan roll back ke status kerja. Meskipun build tidak rusak, mungkin ada perubahan perilaku atau performa yang samar 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, serta Anda dapat melihat repositori versi lama untuk mem-build dengan dependensi yang lebih lama. Bazel mengharuskan versi semua dependensi ditentukan secara manual. Pada skala yang lebih sedang, overhead pengelolaan versi manual sangat berharga untuk stabilitas yang diberikannya.

Aturan Satu Versi

Berbagai versi library biasanya diwakili oleh artefak yang berbeda, sehingga 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 dalam codebase kami.

Masalah terbesar saat mengizinkan beberapa versi adalah masalah dependensi intan. Misalkan target A bergantung pada target B dan pada v1 library eksternal. Jika target B difaktorkan ulang untuk menambahkan dependensi pada v2 dari library eksternal yang sama, target A akan rusak karena sekarang secara implisit bergantung pada dua versi library yang sama. Oleh karena itu, menambahkan dependensi baru dari target ke library pihak ketiga dengan beberapa versi tidaklah aman, karena salah satu pengguna target tersebut mungkin bergantung pada versi yang berbeda. Dengan mengikuti Aturan Satu Versi, konflik ini tidak mungkin terjadi—jika target menambahkan dependensi pada library pihak ketiga, dependensi apa pun yang sudah ada akan berada pada versi yang sama, sehingga dapat beroperasi berdampingan dengan senang hati.

Dependensi eksternal transitif

Menangani dependensi transitif dari dependensi eksternal bisa jadi sangat sulit. Banyak repositori artefak seperti Maven Central, sehingga artefak dapat menentukan dependensi pada versi artefak lain di repositori. Alat build seperti Maven atau Gradle sering mendownload setiap dependensi transitif 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 sulit untuk 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 tentu 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, Anda tidak perlu mengetahui versi mana yang akan Anda dapatkan. Ini juga berarti bahwa mengupdate dependensi eksternal dapat menyebabkan kegagalan yang tampaknya tidak berkaitan di seluruh codebase jika versi baru mulai menarik versi yang bertentangan dari beberapa dependensinya.

Karena alasan ini, Bazel tidak otomatis mendownload dependensi transitif. Sayangnya, tidak ada pelindung ampuh. Alternatif Bazel adalah mewajibkan file global yang mencantumkan semua dependensi eksternal repositori dan versi eksplisit yang digunakan untuk dependensi tersebut di seluruh repositori. Untungnya, Bazel menyediakan alat yang dapat secara otomatis membuat file tersebut yang berisi dependensi transitif dari serangkaian artefak Maven. Alat ini dapat dijalankan satu kali untuk membuat file WORKSPACE awal untuk sebuah project, dan file tersebut kemudian dapat diupdate secara manual untuk menyesuaikan versi setiap dependensi.

Sekali lagi, pilihan di sini adalah antara kemudahan dan skalabilitas. Project kecil mungkin tidak perlu mengkhawatirkan pengelolaan dependensi transitif sendiri dan mungkin dapat menghindari penggunaan dependensi transitif otomatis. Strategi ini menjadi semakin kurang menarik seiring dengan berkembangnya organisasi dan codebase, serta konflik dan hasil yang tidak terduga menjadi semakin sering muncul. 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 hasil build ke dalam cache menggunakan dependensi eksternal

Dependensi eksternal paling sering disediakan oleh pihak ketiga yang merilis versi library yang stabil, mungkin tanpa memberikan kode sumber. Beberapa organisasi juga dapat memilih untuk menyediakan beberapa kodenya sendiri sebagai artefak, yang memungkinkan potongan kode lainnya bergantung padanya sebagai pihak ketiga, bukan dependensi internal. Hal ini secara teori dapat mempercepat build jika artefak lambat saat di-build, 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 mereka selalu mendapatkan versi terbaru. Proses debug juga menjadi jauh lebih sulit karena bagian sistem yang berbeda akan dibuat dari titik yang berbeda di repositori, dan tidak ada lagi tampilan yang konsisten dari hierarki sumber.

Cara yang lebih baik untuk menyelesaikan 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 tersebut menyimpan artefak yang dihasilkan dari setiap build ke lokasi yang dibagikan kepada seluruh engineer, sehingga jika developer bergantung pada artefak yang baru dibuat oleh orang lain, sistem build akan otomatis mendownloadnya bukan mem-build-nya. Hal ini memberikan semua manfaat performa, bergantung pada artefak secara langsung, sambil tetap memastikan bahwa build konsisten seolah-olah build tersebut 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 sangat berisiko. Ada risiko ketersediaan jika sumber pihak ketiga (seperti repositori artefak) berhenti berfungsi, karena seluruh build Anda mungkin terhenti jika tidak dapat mendownload dependensi eksternal. Ada juga risiko keamanan: jika sistem pihak ketiga dibobol oleh penyerang, penyerang dapat mengganti artefak yang direferensikan dengan salah satu desainnya sendiri, sehingga dapat memasukkan kode arbitrer ke dalam build Anda. Kedua masalah dapat dimitigasi dengan mencerminkan artefak yang bergantung pada server yang dikontrol dan memblokir sistem build agar tidak mengakses repositori artefak pihak ketiga seperti Maven Central. Namun, cerminan ini memerlukan upaya dan sumber daya untuk dipelihara, sehingga pilihan apakah akan menggunakannya sering kali bergantung pada skala project. Masalah keamanan juga dapat dicegah sepenuhnya dengan hanya sedikit overhead dengan mengharuskan hash setiap artefak pihak ketiga ditentukan dalam repositori sumber, yang menyebabkan build gagal jika artefak dirusak. Alternatif lain yang sepenuhnya mengatasi masalah ini adalah vendor dependensi project Anda. Saat vendor dependensinya bergantung pada project, project akan memeriksa kontrol sumber tersebut bersama dengan kode sumber project, baik sebagai sumber maupun biner. 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 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 tidak menjadi opsi untuk semua organisasi.