Rekayasa Ulang Perangkat Lunak: Dari Spaghetti hingga Desain Bersih
Diterbitkan: 2022-03-11Bisakah Anda melihat sistem kami? Orang yang menulis perangkat lunak itu sudah tidak ada lagi dan kami mengalami sejumlah masalah. Kami membutuhkan seseorang untuk memeriksanya dan membersihkannya untuk kami.
Siapa pun yang telah berkecimpung dalam rekayasa perangkat lunak untuk waktu yang wajar tahu bahwa permintaan yang tampaknya tidak bersalah ini sering kali merupakan awal dari sebuah proyek yang "memiliki bencana yang tertulis di atasnya". Mewarisi kode orang lain bisa menjadi mimpi buruk, terutama ketika kode tersebut dirancang dengan buruk dan tidak memiliki dokumentasi.
Jadi ketika saya baru-baru ini menerima permintaan dari salah satu pelanggan kami untuk memeriksa aplikasi server obrolan socket.io yang ada (ditulis dalam Node.js) dan memperbaikinya, saya sangat waspada. Tetapi sebelum berlari ke bukit, saya memutuskan untuk setidaknya setuju untuk melihat kodenya.
Sayangnya, melihat kode hanya menegaskan kembali kekhawatiran saya. Server obrolan ini telah diimplementasikan sebagai file JavaScript tunggal yang besar. Merekayasa ulang file monolitik tunggal ini menjadi perangkat lunak yang dirancang dengan rapi dan mudah dipelihara memang akan menjadi tantangan. Tapi saya menikmati tantangan, jadi saya setuju.
Titik Awal - Bersiaplah Untuk Rekayasa Ulang
Perangkat lunak yang ada terdiri dari satu file yang berisi 1.200 baris kode tidak berdokumen. Astaga. Selain itu, diketahui mengandung beberapa bug dan memiliki beberapa masalah kinerja.
Selain itu, pemeriksaan file log (selalu merupakan tempat yang baik untuk memulai ketika mewarisi kode orang lain) mengungkapkan potensi masalah kebocoran memori. Di beberapa titik, proses itu dilaporkan menggunakan lebih dari 1GB RAM.
Mengingat masalah ini, segera menjadi jelas bahwa kode tersebut perlu diatur ulang dan dimodulasi bahkan sebelum mencoba untuk men-debug atau meningkatkan logika bisnis. Untuk itu, beberapa masalah awal yang perlu ditangani antara lain:
- Struktur Kode. Kode tidak memiliki struktur nyata sama sekali, sehingga sulit untuk membedakan konfigurasi dari infrastruktur dari logika bisnis. Pada dasarnya tidak ada modularisasi atau pemisahan masalah.
- Kode yang berlebihan. Beberapa bagian kode (seperti kode penanganan error untuk setiap event handler, kode untuk membuat permintaan web, dll.) diduplikasi beberapa kali. Kode yang direplikasi bukanlah hal yang baik, membuat kode secara signifikan lebih sulit untuk dipelihara dan lebih rentan terhadap kesalahan (ketika kode yang berlebihan diperbaiki atau diperbarui di satu tempat tetapi tidak di tempat lain).
- Nilai yang di-hardcode. Kode berisi sejumlah nilai hardcoded (jarang hal yang baik). Mampu memodifikasi nilai-nilai ini melalui parameter konfigurasi (daripada memerlukan perubahan nilai hardcoded dalam kode) akan meningkatkan fleksibilitas dan juga dapat membantu memfasilitasi pengujian dan debugging.
- Masuk. Sistem logging sangat mendasar. Itu akan menghasilkan satu file log raksasa yang sulit dan canggung untuk dianalisis atau diuraikan.
Tujuan Arsitektur Utama
Dalam proses mulai merestrukturisasi kode, selain mengatasi masalah spesifik yang diidentifikasi di atas, saya ingin mulai membahas beberapa tujuan arsitektur utama yang (atau setidaknya, harus) umum untuk desain sistem perangkat lunak apa pun . Ini termasuk:
- Pemeliharaan. Jangan pernah menulis perangkat lunak dengan harapan menjadi satu-satunya orang yang perlu memeliharanya. Selalu pertimbangkan betapa mudahnya memahami kode Anda bagi orang lain, dan betapa mudahnya mereka memodifikasi atau men-debug.
- Kemungkinan diperpanjang. Jangan pernah berasumsi bahwa fungsionalitas yang Anda terapkan hari ini adalah semua yang dibutuhkan. Rancang perangkat lunak Anda dengan cara yang mudah diperluas.
- Modularitas. Pisahkan fungsionalitas menjadi modul logis dan berbeda, masing-masing dengan tujuan dan fungsinya yang jelas.
- Skalabilitas. Pengguna saat ini semakin tidak sabar, mengharapkan waktu respons segera (atau setidaknya mendekati segera). Performa buruk dan latensi tinggi dapat menyebabkan aplikasi yang paling berguna pun gagal di pasar. Bagaimana kinerja perangkat lunak Anda saat jumlah pengguna bersamaan dan kebutuhan bandwidth meningkat? Teknik seperti paralelisasi, pengoptimalan database, dan pemrosesan asinkron dapat membantu meningkatkan kemampuan sistem Anda untuk tetap responsif, meskipun beban dan permintaan sumber daya meningkat.
Restrukturisasi Kode
Tujuan kami adalah beralih dari satu file kode sumber mongo monolitik ke satu set komponen termodulasi yang dirancang dengan rapi. Kode yang dihasilkan harus jauh lebih mudah untuk dipelihara, ditingkatkan, dan di-debug.
Untuk aplikasi ini, saya telah memutuskan untuk mengatur kode ke dalam komponen arsitektur yang berbeda berikut:
- app.js - ini adalah titik masuk kami, kode kami akan dijalankan dari sini
- config - ini tempat pengaturan konfigurasi kami akan berada
- ioW - "pembungkus IO" yang akan berisi semua logika IO (dan bisnis)
- logging - semua kode terkait logging (perhatikan bahwa struktur direktori juga akan menyertakan folder
logs
baru yang akan berisi semua file log) - package.json - daftar dependensi paket untuk Node.js
- node_modules - semua modul yang dibutuhkan oleh Node.js
Tidak ada yang ajaib tentang pendekatan khusus ini; mungkin ada banyak cara berbeda untuk merestrukturisasi kode. Saya pribadi hanya merasa bahwa organisasi ini cukup bersih dan terorganisir dengan baik tanpa terlalu rumit.
Direktori dan organisasi file yang dihasilkan ditunjukkan di bawah ini.
Pencatatan
Paket logging telah dikembangkan untuk sebagian besar lingkungan dan bahasa pengembangan saat ini, jadi saat ini jarang Anda perlu "menggulung sendiri" kemampuan logging.
Karena kita bekerja dengan Node.js, saya memilih log4js-node, yang pada dasarnya adalah versi library log4js untuk digunakan dengan Node.js. Library ini memiliki beberapa fitur keren seperti kemampuan untuk mencatat beberapa level pesan (PERINGATAN, ERROR, dll.) dan kita dapat memiliki file bergulir yang dapat dibagi, misalnya, setiap hari, jadi kita tidak perlu menangani file besar yang akan memakan banyak waktu untuk dibuka dan sulit untuk dianalisis dan diuraikan.
Untuk tujuan kita, saya telah membuat pembungkus kecil di sekitar log4js-node untuk menambahkan beberapa kemampuan tambahan yang diinginkan. Perhatikan bahwa saya telah memilih untuk membuat pembungkus di sekitar log4js-node yang kemudian akan saya gunakan di seluruh kode saya. Ini melokalisasi implementasi kemampuan logging yang diperluas ini di satu lokasi sehingga menghindari redundansi dan kompleksitas yang tidak dibutuhkan di seluruh kode saya ketika saya memanggil logging.
Karena kami bekerja dengan I/O, dan kami akan memiliki beberapa klien (pengguna) yang akan menelurkan beberapa koneksi (soket), saya ingin dapat melacak aktivitas pengguna tertentu dalam file log, dan juga ingin tahu sumber setiap entri log. Oleh karena itu saya mengharapkan untuk memiliki beberapa entri log mengenai status aplikasi, dan beberapa yang khusus untuk aktivitas pengguna.
Dalam kode pembungkus logging saya, saya dapat memetakan ID pengguna dan soket, yang memungkinkan saya melacak tindakan yang dilakukan sebelum dan sesudah peristiwa ERROR. Pembungkus logging juga akan memungkinkan saya untuk membuat logger yang berbeda dengan informasi kontekstual berbeda yang dapat saya berikan ke event handler sehingga saya tahu sumber entri log.
Kode untuk pembungkus logging tersedia di sini.
Konfigurasi
Seringkali diperlukan untuk mendukung konfigurasi yang berbeda untuk suatu sistem. Perbedaan ini dapat berupa perbedaan antara lingkungan pengembangan dan produksi, atau bahkan berdasarkan kebutuhan untuk menampilkan lingkungan pelanggan dan skenario penggunaan yang berbeda.
Daripada memerlukan perubahan kode untuk mendukung ini, praktik umum adalah mengontrol perbedaan perilaku ini melalui parameter konfigurasi. Dalam kasus saya, saya membutuhkan kemampuan untuk memiliki lingkungan eksekusi yang berbeda (pementasan dan produksi), yang mungkin memiliki pengaturan yang berbeda. Saya juga ingin memastikan bahwa kode yang diuji bekerja dengan baik dalam staging dan produksi, dan seandainya saya perlu mengubah kode untuk tujuan ini, itu akan membatalkan proses pengujian.
Menggunakan variabel lingkungan Node.js, saya dapat menentukan file konfigurasi mana yang ingin saya gunakan untuk eksekusi tertentu. Oleh karena itu saya memindahkan semua parameter konfigurasi hardcoded sebelumnya ke dalam file konfigurasi, dan membuat modul konfigurasi sederhana yang memuat file konfigurasi yang tepat dengan pengaturan yang diinginkan. Saya juga mengkategorikan semua pengaturan untuk menegakkan beberapa tingkat organisasi pada file konfigurasi dan membuatnya lebih mudah dinavigasi.

Berikut ini contoh file konfigurasi yang dihasilkan:
{ "app": { "port": 8889, "invRepeatInterval":1000, "invTimeOut":300000, "chatLogInterval":60000, "updateUsersInterval":600000, "dbgCurrentStatusInterval":3600000, "roomDelimiter":"_", "roomPrefix":"/" }, "webSite":{ "host": "mysite.com", "port": 80, "friendListHandler":"/MyMethods.aspx/FriendsList", "userCanChatHandler":"/MyMethods.aspx/UserCanChat", "chatLogsHandler":"/MyMethods.aspx/SaveLogs" }, "logging": { "appenders": [ { "type": "dateFile", "filename": "logs/chat-server", "pattern": "-yyyy-MM-dd", "alwaysIncludePattern": false } ], "level": "DEBUG" } }
Alur Kode
Sejauh ini kita telah membuat struktur folder untuk meng-host modul yang berbeda, kita telah menyiapkan cara untuk memuat informasi spesifik lingkungan, dan membuat sistem logging, jadi mari kita lihat bagaimana kita dapat menyatukan semua bagian tanpa mengubah kode khusus bisnis.
Berkat struktur kode modular kami yang baru, titik masuk app.js
kami cukup sederhana, hanya berisi kode inisialisasi:
var config = require('./config'); var logging = require('./logging'); var ioW = require('./ioW'); var obj = config.getCurrent(); logging.initialize(obj.logging); ioW.initialize(config);
Ketika kami mendefinisikan struktur kode kami, kami mengatakan bahwa folder ioW
akan menyimpan kode terkait bisnis dan socket.io. Secara khusus, ini akan berisi file-file berikut (perhatikan bahwa Anda dapat mengklik salah satu nama file yang terdaftar untuk melihat kode sumber yang sesuai):
-
index.js
– menangani inisialisasi dan koneksi socket.io serta langganan acara, ditambah penangan kesalahan terpusat untuk acara -
eventManager.js
– menghosting semua logika terkait bisnis (penangan acara) -
webHelper.js
– metode pembantu untuk melakukan permintaan web. -
linkedList.js
– kelas utilitas daftar tertaut
Kami memfaktorkan ulang kode yang membuat permintaan web dan memindahkannya ke file terpisah, dan kami berhasil menjaga logika bisnis kami di tempat yang sama dan tidak dimodifikasi.
Satu catatan penting: Pada tahap ini, eventManager.js
masih berisi beberapa fungsi pembantu yang benar-benar harus diekstraksi ke dalam modul terpisah. Namun, karena tujuan kami dalam langkah pertama ini adalah untuk mengatur ulang kode sambil meminimalkan dampak pada logika bisnis, dan fungsi pembantu ini terlalu terkait erat dengan logika bisnis, kami memilih untuk menunda ini ke langkah berikutnya untuk meningkatkan organisasi kode.
Karena Node.js tidak sinkron menurut definisinya, kita sering menemukan sedikit "callback hell" yang membuat kode sangat sulit untuk dinavigasi dan di-debug. Untuk menghindari perangkap ini, dalam implementasi baru saya, saya telah menggunakan pola janji dan secara khusus memanfaatkan bluebird yang merupakan perpustakaan janji yang sangat bagus dan cepat. Janji akan memungkinkan kita untuk dapat mengikuti kode seolah-olah itu sinkron dan juga menyediakan manajemen kesalahan dan cara yang bersih untuk menstandardisasi tanggapan antar panggilan. Ada kontrak implisit dalam kode kami bahwa setiap event handler harus mengembalikan janji sehingga kami dapat mengelola penanganan kesalahan dan pencatatan terpusat.
Semua event handler akan mengembalikan janji (apakah mereka melakukan panggilan asinkron atau tidak). Dengan ini di tempat, kami dapat memusatkan penanganan kesalahan dan logging dan kami memastikan bahwa, jika kami memiliki kesalahan yang tidak tertangani di dalam event handler, kesalahan itu ditangkap.
function execEventHandler(socket, eventName, eventHandler, data){ var sLogger = logging.createLogger(socket.id + ' - ' + eventName); sLogger.info(''); eventHandler(socket, data, sLogger).then(null, function(err){ sLogger.error(err.stack); }); };
Dalam diskusi kami tentang logging, kami menyebutkan bahwa setiap koneksi akan memiliki logger sendiri dengan informasi kontekstual di dalamnya. Secara khusus, kami mengikat id soket dan nama acara ke logger saat kami membuatnya, jadi ketika kami meneruskan logger itu ke event handler, setiap baris log akan memiliki informasi itu di dalamnya:
var sLogger = logging.createLogger(socket.id + ' - ' + eventName);
Satu hal lain yang layak disebutkan sehubungan dengan penanganan acara: Dalam file asli, kami memiliki panggilan fungsi setInterval
yang ada di dalam pengendali acara dari acara koneksi socket.io, dan kami telah mengidentifikasi fungsi ini sebagai masalah.
io.on('connection', function (socket) { ... Several event handlers .... setInterval(function() { try { var date = Date.now(); var tmp = []; while (0 < messageHub.count() && messageHub.head().date < date) { var item = messageHub.remove(); tmp.push(item); } ... Post Data to an external web service... } catch (e) { log('ERROR: ex: ' + e); } }, CHAT_LOGS_INTERVAL); });
Kode ini membuat timer dengan interval tertentu (dalam kasus kami ini adalah 1 menit) untuk setiap permintaan koneksi yang kami dapatkan. Jadi, misalnya, jika pada waktu tertentu kita memiliki 300 soket online, maka kita akan memiliki 300 timer yang dieksekusi setiap menit. Masalah dengan ini, seperti yang Anda lihat dalam kode di atas, adalah bahwa tidak ada penggunaan soket atau variabel apa pun yang didefinisikan dalam lingkup event handler. Satu-satunya variabel yang digunakan adalah variabel messageHub
yang dideklarasikan pada level modul yang artinya sama untuk semua koneksi. Oleh karena itu sama sekali tidak perlu untuk timer terpisah per koneksi. Jadi kami telah menghapus ini dari event handler koneksi dan memasukkannya ke dalam kode inisialisasi umum kami, yang dalam hal ini adalah fungsi initialize
.
Terakhir, dalam pemrosesan respons kami Di webHelper.js
, kami menambahkan pemrosesan untuk respons yang tidak dikenal yang akan mencatat informasi yang kemudian akan membantu proses debug:
if (!res || !res.d || !res.d.IsValid){ logger.debug(sendData); logger.debug(data); reject(new Error('Request failed. Path ' + params.path + ' . Invalid return data.')); return; }
Langkah terakhir adalah menyiapkan file logging untuk kesalahan standar Node.js. File ini akan berisi kesalahan yang tidak tertangani yang mungkin terlewatkan. Untuk mengatur proses node di Windows (tidak ideal tetapi Anda tahu ...) sebagai layanan, kami menggunakan alat yang disebut nssm yang memiliki UI visual yang memungkinkan Anda untuk menentukan file output standar, file kesalahan standar, dan variabel lingkungan.
Tentang Kinerja Node.js
Node.js adalah bahasa pemrograman single-threaded. Untuk meningkatkan skalabilitas, ada beberapa alternatif yang bisa kita terapkan. Ada modul cluster node atau cukup tambahkan lebih banyak proses node dan letakkan nginx di atasnya untuk melakukan penerusan dan penyeimbangan beban.
Namun, dalam kasus kami, mengingat bahwa setiap subproses cluster node atau proses node akan memiliki ruang memorinya sendiri, kami tidak akan dapat berbagi informasi di antara proses-proses tersebut dengan mudah. Jadi untuk kasus khusus ini, kita perlu menggunakan penyimpanan data eksternal (seperti redis) agar soket online tetap tersedia untuk proses yang berbeda.
Kesimpulan
Dengan semua ini, kami telah mencapai pembersihan signifikan dari kode yang awalnya diserahkan kepada kami. Ini bukan tentang membuat kode menjadi sempurna, melainkan tentang merekayasa ulang untuk menciptakan fondasi arsitektur yang bersih yang akan lebih mudah untuk didukung dan dipelihara, dan yang akan memfasilitasi dan menyederhanakan proses debug.
Mengikuti prinsip-prinsip desain perangkat lunak utama yang disebutkan sebelumnya – pemeliharaan, ekstensibilitas, modularitas, dan skalabilitas – kami membuat modul dan struktur kode yang secara jelas dan jelas mengidentifikasi tanggung jawab modul yang berbeda. Kami juga telah mengidentifikasi beberapa masalah dalam implementasi awal yang menyebabkan konsumsi memori tinggi yang menurunkan kinerja.
Semoga Anda menikmati artikelnya, beri tahu saya jika Anda memiliki komentar atau pertanyaan lebih lanjut.