Ayo pergi secara berurutan. Dan segera sebuah penafian kecil: artikel itu ditulis berdasarkan pidato saya di Ya Subbotnik Pro untuk pengembang front-end. Jika Anda adalah pengembang backend, Anda mungkin tidak menemukan sesuatu yang baru untuk diri Anda sendiri. Di sini saya akan mencoba merangkum pengalaman saya tentang frontend di perusahaan besar, menjelaskan mengapa dan bagaimana kami menggunakan Node.js.
Mari kita definisikan apa yang akan kita pertimbangkan sebagai tampilan depan dalam artikel ini. Mari kita kesampingkan perselisihan tentang tugas dan berkonsentrasi pada esensi.
Frontend adalah bagian dari aplikasi yang bertanggung jawab untuk menampilkan. Ini bisa berbeda: browser, desktop, seluler. Tetapi selalu ada fitur penting - frontend membutuhkan data. Tanpa backend yang menyediakan data ini, itu tidak berguna. Ini perbatasan yang cukup jelas. Backend tahu bagaimana cara pergi ke database, menerapkan aturan bisnis ke data yang diterima dan memberikan hasilnya ke frontend, yang akan menerima data, membuat template dan memberikan keindahan kepada pengguna.
Dapat dikatakan bahwa secara konseptual backend dibutuhkan oleh frontend untuk menerima dan menyimpan data. Contoh: situs modern yang khas dengan arsitektur klien-server. Klien di browser (untuk menyebutnya tipis bahasa tidak akan lagi berputar) mengetuk server tempat backend berjalan. Dan tentu saja ada pengecualian di mana-mana. Ada aplikasi browser yang kompleks yang tidak memerlukan server (kami tidak akan mempertimbangkan kasus ini), dan ada kebutuhan untuk mengeksekusi frontend di server - yang disebut Server Side Rendering atau SSR. Mari kita mulai dengan itu, karena ini adalah kasus yang paling sederhana dan paling mudah dimengerti.
SSR
Dunia ideal untuk backend terlihat seperti ini: Permintaan HTTP dengan data sampai pada masukan aplikasi, dan pada keluaran kita memiliki tanggapan dengan data baru dalam format yang nyaman. Misalnya JSON. API HTTP mudah untuk diuji dan dipahami cara mengembangkannya. Namun, kehidupan membuat penyesuaian: terkadang API saja tidak cukup.
Server harus merespons dengan HTML siap pakai untuk memasukkannya ke crawler mesin pencari, membuat pratinjau dengan tag meta untuk dimasukkan ke jaringan sosial, atau, yang lebih penting, mempercepat respons pada perangkat yang lemah. Sama seperti di zaman kuno ketika kami mengembangkan Web 2.0 di PHP.
Semuanya akrab dan telah dijelaskan untuk waktu yang lama, tetapi klien telah berubah - mesin templat sisi klien yang penting telah datang ke sana. Di web modern, BEJ menguasai bola, pro dan kontranya dapat didiskusikan untuk waktu yang lama, tetapi satu hal tidak dapat disangkal - dalam rendering server Anda tidak dapat melakukannya tanpa kode JavaScript.
Ternyata ketika Anda perlu menerapkan SSR dengan pengembangan back-end:
- Area tanggung jawab beragam. Pemrogram backend mulai bertanggung jawab atas rendering.
- Bahasa dicampur. Pemrogram backend memulai JavaScript.
Jalan keluarnya adalah dengan memisahkan SSR dari backend. Dalam kasus yang paling sederhana, kami mengambil runtime JavaScript, meletakkan solusi yang ditulis sendiri atau kerangka kerja (Berikutnya, Nuxt, dll.) Di atasnya yang berfungsi dengan mesin template JavaScript yang kami butuhkan, dan meneruskan lalu lintas melaluinya. Pola yang familiar di dunia modern.
Jadi kami telah mengizinkan sedikit pengembang front-end ke server. Mari beralih ke masalah yang lebih penting.
Menerima data
Solusi populer adalah membuat API generik. Peran ini paling sering diambil oleh API Gateway, yang dapat memeriksa berbagai layanan mikro. Namun, masalah juga muncul di sini.
Pertama, masalah tim dan area tanggung jawab. Aplikasi besar modern dikembangkan oleh banyak tim. Setiap tim berfokus pada domain bisnisnya, memiliki layanan mikro sendiri (atau bahkan beberapa) di backend dan tampilan sendiri di klien. Kami tidak akan membahas masalah mikrofront dan modularitas, ini adalah topik kompleks yang terpisah. Misalkan tampilan klien benar-benar terpisah dan merupakan SPA mini (Aplikasi Halaman Tunggal) dalam satu situs besar.
Setiap tim memiliki pengembang front-end dan back-end. Setiap orang mengerjakan aplikasi mereka sendiri. API Gateway bisa menjadi batu sandungan. Siapa yang bertanggung jawab? Siapa yang akan menambahkan titik akhir baru? Seorang superteam API khusus yang akan selalu sibuk memecahkan masalah untuk semua orang di proyek? Apa akibat dari kesalahan? Jatuhnya gateway ini akan membuat seluruh sistem mati.
Kedua, masalah data yang mubazir / tidak mencukupi. Mari kita lihat apa yang terjadi ketika dua frontend yang berbeda menggunakan API generik yang sama.
Kedua frontend ini sangat berbeda. Mereka membutuhkan kumpulan data yang berbeda, mereka memiliki siklus rilis yang berbeda. Variabilitas versi frontend seluler adalah maksimum, jadi kami terpaksa mendesain API dengan kompatibilitas mundur maksimum. Variabilitas klien web rendah, sebenarnya kami hanya perlu mendukung satu versi sebelumnya untuk mengurangi jumlah bug pada saat rilis. Namun meskipun API "generik" hanya melayani klien web, kami masih menghadapi masalah data yang berlebihan atau tidak mencukupi.
Setiap pemetaan membutuhkan kumpulan data terpisah, yang dapat diambil dengan satu kueri optimal.
Dalam hal ini, API universal tidak akan berfungsi untuk kami, kami harus memisahkan antarmuka. Ini berarti Anda memerlukan API Gateway Anda sendiri untuk masing - masingpaling depan. Kata "masing-masing" di sini menunjukkan pemetaan unik yang beroperasi pada kumpulan datanya sendiri.
Kami dapat mempercayakan pembuatan API semacam itu kepada pengembang backend yang harus bekerja dengan frontend dan mengimplementasikan keinginannya, atau, yang jauh lebih menarik dan dalam banyak hal lebih efektif, memberikan implementasi API kepada tim frontend. Ini akan menghilangkan sakit kepala karena penerapan SSR: Anda tidak perlu lagi memasang lapisan yang mengetuk API, semuanya akan diintegrasikan ke dalam satu aplikasi server. Selain itu, dengan mengontrol SSR, kita dapat meletakkan semua data primer yang diperlukan di halaman pada saat rendering, tanpa membuat permintaan tambahan ke server.
Arsitektur ini disebut Backend For Frontend atau BFF. Idenya sederhana: aplikasi baru muncul di server yang mendengarkan permintaan klien, mengumpulkan backend, dan mengembalikan respons yang optimal. Dan tentunya aplikasi ini dikendalikan oleh front-end developer.
Lebih dari satu server di backend? Bukan masalah!
Terlepas dari apa yang disukai oleh pengembangan backend protokol komunikasi, kami dapat menggunakan cara apa pun yang nyaman untuk berkomunikasi dengan klien web. REST, RPC, GraphQL - kami memilih sendiri.
Tapi bukankah GraphQL sendiri merupakan solusi untuk masalah mendapatkan data dalam satu kueri? Mungkin Anda tidak perlu memagari layanan perantara?
Sayangnya, pekerjaan yang efektif dengan GraphQL tidak mungkin dilakukan tanpa kerja sama erat dengan pengembang backend yang mengembangkan kueri basis data yang efisien. Dengan memilih solusi seperti itu, kami akan kembali kehilangan kendali atas data dan kembali ke tempat kami memulai.
Itu mungkin, tentu saja, tetapi tidak menarik (untuk frontend)
Baiklah, mari kita terapkan BFF. Tentu saja, di Node.js. Mengapa? Kami membutuhkan satu bahasa pada klien dan server untuk menggunakan kembali pengalaman pengembang front-end dan JavaScript untuk bekerja dengan template. Bagaimana dengan lingkungan runtime lainnya?
GraalVM dan solusi eksotis lainnya lebih rendah dari V8 dalam performa dan terlalu spesifik. Deno masih percobaan dan tidak digunakan dalam produksi.
Dan satu saat. Node.js adalah solusi yang sangat bagus untuk mengimplementasikan API Gateway. Arsitektur Node memungkinkan penerjemah JavaScript berulir tunggal yang dikombinasikan dengan libuv, pustaka I / O asinkron yang pada gilirannya menggunakan kumpulan utas.
Perhitungan panjang di sisi JavaScript menghantam performa sistem. Anda dapat menyiasatinya: jalankan di pekerja terpisah atau bawa mereka ke level modul biner asli.
Namun dalam kasus dasar, Node.js tidak cocok untuk operasi intensif CPU, dan pada saat yang sama, Node.js berfungsi dengan baik dengan I / O asinkron, memberikan kinerja tinggi. Artinya, kami mendapatkan sistem yang selalu dapat merespons pengguna dengan cepat, apa puntentang seberapa sibuk backend. Anda dapat menangani situasi ini dengan langsung memberi tahu pengguna untuk menunggu akhir operasi.
Tempat menyimpan logika bisnis
Sistem kami sekarang memiliki tiga bagian besar: backend, frontend, dan BFF di antaranya. Sebuah pertanyaan yang masuk akal (untuk seorang arsitek) muncul: di mana harus menyimpan logika bisnis?
Tentu saja, seorang arsitek tidak ingin menodai aturan bisnis di semua lapisan sistem; harus ada satu sumber kebenaran. Dan sumber itu adalah backend. Di mana lagi untuk menyimpan kebijakan tingkat tinggi jika bukan di bagian sistem yang paling dekat dengan data?
Namun kenyataannya, ini tidak selalu berhasil. Misalnya, ada masalah bisnis yang dapat diimplementasikan secara efisien dan cepat di tingkat BFF. Desain sistem yang sempurna memang bagus, tetapi waktu adalah uang. Terkadang Anda harus mengorbankan kebersihan arsitektur, dan lapisannya mulai bocor.
Bisakah kita mendapatkan arsitektur yang sempurna dengan membuang BFF dan memilih backend Node.js yang "penuh"? Tampaknya dalam kasus ini tidak akan ada kebocoran.
Bukan fakta. Akan ada aturan bisnis yang transfernya ke server akan mempengaruhi daya tanggap antarmuka. Anda bisa menolak ini sampai akhir, tetapi kemungkinan besar Anda tidak akan bisa menghindarinya sepenuhnya. Logika level aplikasi juga akan menembus klien: dalam SPA modern itu tercoreng antara klien dan server bahkan dalam kasus ketika ada BFF.
Tidak peduli seberapa keras kami mencoba, logika bisnis akan menyusup ke API Gateway di Node.js. Mari perbaiki kesimpulan ini dan lanjutkan ke implementasi yang paling enak!
Bola lumpur besar
Solusi paling populer untuk aplikasi Node.js dalam beberapa tahun terakhir adalah Express. Terbukti, tetapi terlalu rendah dan tidak menawarkan pendekatan arsitektural yang baik. Pola utamanya adalah middleware. Aplikasi khas di Express seperti gumpalan lumpur besar (tidak menyebut nama, dan antipattern ).
const express = require('express');
const app = express();
const {createReadStream} = require('fs');
const path = require('path');
const Joi = require('joi');
app.use(express.json());
const schema = {id: Joi.number().required() };
app.get('/example/:id', (req, res) => {
const result = Joi.validate(req.params, schema);
if (result.error) {
res.status(400).send(result.error.toString()).end();
return;
}
const stream = createReadStream( path.join('..', path.sep, `example${req.params.id}.js`));
stream
.on('open', () => {stream.pipe(res)})
.on('error', (error) => {res.end(error.toString())})
});
Semua lapisan dicampur, dalam satu file ada pengontrol, di mana semuanya ada di sana: logika infrastruktur, validasi, logika bisnis. Sangat menyakitkan untuk bekerja dengan ini, Anda tidak ingin mempertahankan kode seperti itu. Bisakah kita menulis kode tingkat perusahaan di Node.js?
Ini membutuhkan basis kode yang mudah dipelihara dan dikembangkan. Dengan kata lain, Anda membutuhkan arsitektur.
Arsitektur aplikasi Node.js (akhirnya)
"Tujuan dari arsitektur perangkat lunak adalah untuk mengurangi upaya manusia yang terlibat dalam membangun dan memelihara sistem."
Robert "Paman Bob" Martin
Arsitektur terdiri dari dua hal penting: lapisan dan hubungan di antara keduanya. Kita harus membagi aplikasi kita menjadi beberapa lapisan, mencegah kebocoran satu sama lain, mengatur hierarki lapisan dengan benar dan koneksi di antara mereka.
Lapisan
Bagaimana cara membagi aplikasi saya menjadi beberapa lapisan? Ada pendekatan tiga tingkat klasik: data, logika, presentasi.
Pendekatan ini sekarang dianggap usang. Masalahnya adalah bahwa data adalah basis, yang berarti bahwa aplikasi dirancang tergantung pada bagaimana data disajikan dalam database, dan bukan pada proses bisnis apa yang mereka ikuti.
Pendekatan yang lebih modern mengasumsikan bahwa aplikasi memiliki lapisan domain khusus yang bekerja dengan logika bisnis dan merupakan representasi proses bisnis nyata dalam kode. Namun, jika kita beralih ke Desain Berbasis Domain klasik Eric Evans , kita akan menemukan di sana skema lapisan aplikasi berikut:
Ada apa disini? Tampaknya dasar dari aplikasi yang dirancang DDD haruslah domain - kebijakan tingkat tinggi, logika yang paling penting dan berharga. Tetapi di bawah lapisan ini terdapat seluruh infrastruktur: lapisan akses data (DAL), logging, pemantauan, dll. Yakni, kebijakan dengan tingkat yang jauh lebih rendah dan kurang penting.
Infrastruktur berada di pusat aplikasi, dan penggantian logger yang dangkal dapat menyebabkan guncangan semua logika bisnis.
Jika kita beralih ke Robert Martin lagi, kita menemukan bahwa dalam buku Arsitektur Bersih dia mendalilkan hierarki lapisan yang berbeda dalam aplikasi, dengan domain di tengah.
Karenanya, keempat lapisan harus diatur secara berbeda:
Kami telah memilih lapisan dan menentukan hierarki mereka. Sekarang mari beralih ke koneksi.
Koneksi
Mari kembali ke contoh dengan pemanggilan logika pengguna. Bagaimana cara menghilangkan ketergantungan langsung pada infrastruktur untuk memastikan hierarki lapisan yang benar? Ada cara sederhana dan terkenal untuk membalikkan dependensi - antarmuka.
Sekarang UserEntity tingkat tinggi tidak bergantung pada Logger tingkat rendah. Sebaliknya, itu menentukan kontrak yang harus dilaksanakan untuk memasukkan Logger ke dalam sistem. Mengganti logger dalam hal ini berarti menghubungkan implementasi baru yang mengamati kontrak yang sama. Pertanyaan penting adalah bagaimana menghubungkannya?
import {Logger} from β../core/loggerβ;
class UserEntity {
private _logger: Logger;
constructor() {
this._logger = new Logger();
}
...
}
...
const UserEntity = new UserEntity();
Lapisan-lapisan tersebut terhubung secara kaku. Ada keterkaitan dengan struktur file dan implementasi. Kita membutuhkan Dependency Inversion, yang akan kita lakukan menggunakan Dependency Injection.
export class UserEntity {
constructor(private _logger: ILogger) { }
...
}
...
const logger = new Logger();
const UserEntity = new UserEntity(logger);
Sekarang UserEntity "domain" tidak tahu apa-apa lagi tentang implementasi logger. Ini memberikan kontrak dan mengharapkan implementasinya sesuai dengan kontrak itu.
Tentu saja, membuat instance entitas infrastruktur secara manual bukanlah hal yang paling menyenangkan. Kami membutuhkan file root di mana kami akan menyiapkan semuanya, kami harus menyeret instance logger yang dibuat melalui seluruh aplikasi (lebih baik memiliki satu, bukan membuat banyak). Melelahkan. Dan di sinilah wadah IoC berperan dan dapat mengambil alih pekerjaan bollerplate ini.
Seperti apa tampilan wadah itu? Misalnya seperti ini:
export class UserEntity {
constructor(@Inject(LOGGER) private readonly _logger: ILogger){ }
}
Apa yang terjadi di sini? Kami menggunakan keajaiban dekorator dan menulis instruksi: βSaat membuat instance UserEntity, masukkan ke dalam bidang privatnya _logger sebuah instance dari entitas yang terletak di wadah IoC di bawah token LOGGER. Ini diharapkan sesuai dengan antarmuka ILogger. " Dan kemudian wadah IoC akan melakukan semuanya dengan sendirinya.
Kami telah memilih lapisan, memutuskan bagaimana kami akan melepaskannya. Saatnya memilih kerangka kerja.
Kerangka dan arsitektur
Pertanyaannya sederhana: dengan meninggalkan Express untuk kerangka kerja modern, akankah kita mendapatkan arsitektur yang baik? Mari kita lihat Nest:
- ditulis dalam TypeScript,
- dibangun di atas Express / Fastify, ada kompatibilitas di tingkat middleware,
- menyatakan modularitas logika,
- menyediakan wadah IoC.
Tampaknya memiliki semua yang kita butuhkan di sini! Mereka juga meninggalkan konsep aplikasi sebagai middleware chain. Tapi bagaimana dengan arsitektur yang bagus?
Injeksi Ketergantungan di Nest
Ayo coba ikuti instruksinya . Karena di Nest istilah Entitas biasanya diterapkan ke ORM, ganti nama UserEntity menjadi UserService. Logger dipasok oleh kerangka kerja, jadi kami akan memasukkan FooService abstrak sebagai gantinya.
import {FooService} from β../services/foo.serviceβ;
@Injectable()
export class UserService {
constructor(
private readonly _fooService: FooService
){ }
}
Dan ... sepertinya kita mundur selangkah! Ada injeksi, tetapi tidak ada inversi, ketergantungan
ditujukan untuk implementasi, bukan abstraksi.
Mari kita coba memperbaikinya. Opsi nomor satu:
@Injectable()
export class UserService {
constructor(
private _fooService: AbstractFooService
){ } }
Kami mendeskripsikan dan mengekspor layanan abstrak ini di suatu tempat terdekat:
export {AbstractFooService};
FooService sekarang menggunakan AbstractFooService. Karena itu, kami mendaftarkannya secara manual di IoC.
{ provide: AbstractFooService, useClass: FooService }
Opsi kedua. Mari kita coba pendekatan yang dijelaskan sebelumnya dengan antarmuka. Karena tidak ada antarmuka di JavaScript, tidak mungkin lagi untuk menarik entitas yang diperlukan dari IoC saat runtime menggunakan refleksi. Kami harus secara eksplisit menyatakan apa yang kami butuhkan. Kami akan menggunakan dekorator @ Inject untuk ini.
@Injectable()
export class UserService {
constructor(
@Inject(FOO_SERVICE) private readonly _fooService: IFooService
){ } }
Dan daftar dengan token:
{ provide: FOO_SERVICE, useClass: FooService }
Kami telah memenangkan kerangka kerja! Tapi berapa biayanya? Kami mematikan sedikit gula. Ini mencurigakan dan menyarankan agar Anda tidak menggabungkan seluruh aplikasi ke dalam kerangka kerja. Jika saya belum meyakinkan Anda, ada masalah lain.
Pengecualian
Nest di-flash dengan pengecualian. Selain itu, dia menyarankan penggunaan pengecualian untuk mendeskripsikan logika perilaku aplikasi.
Apakah semuanya baik-baik saja di sini dalam hal arsitektur? Mari kita kembali ke tokoh-tokoh terkenal:
"Jika kesalahan adalah perilaku yang diharapkan, maka Anda tidak boleh menggunakan pengecualian."Pengecualian menunjukkan situasi luar biasa. Saat menulis logika bisnis, kita harus menghindari pelemparan pengecualian. Jika hanya karena alasan JavaScript maupun TypeScript tidak menjamin bahwa pengecualian akan ditangani. Selain itu, ini mengaburkan aliran eksekusi, kami memulai pemrograman dengan gaya GOTO, yang berarti bahwa saat memeriksa perilaku kode, pembaca harus beralih ke seluruh program.
Martin Fowler
Ada aturan praktis sederhana untuk membantu Anda memahami apakah menggunakan pengecualian itu legal:
"Apakah kode akan berfungsi jika saya menghapus semua penangan pengecualian?" Jika jawabannya tidak, maka mungkin pengecualian digunakan dalam keadaan non-pengecualian. "Apakah mungkin untuk menghindari ini dalam logika bisnis? Iya! Penting untuk meminimalkan pelemparan pengecualian, dan untuk mengembalikan hasil operasi kompleks dengan mudah, gunakan monad Either , yang menyediakan container dalam keadaan berhasil atau error (konsep yang sangat mirip dengan Promise).
Programmer Pragmatis
const successResult = Result.ok(false);
const failResult = Result.fail(new ConnectionError())
Sayangnya, di dalam entitas yang disediakan oleh Nest, kami sering tidak dapat bertindak sebaliknya - kami harus memberikan pengecualian. Beginilah cara kerja framework, dan ini adalah fitur yang sangat tidak menyenangkan. Dan lagi-lagi muncul pertanyaan: mungkin Anda sebaiknya tidak mem-flash aplikasi dengan framework? Mungkinkah mungkin untuk memisahkan kerangka kerja dan logika bisnis ke dalam lapisan arsitektur yang berbeda?
Mari kita periksa.
Entitas sarang dan lapisan arsitektur
Kebenaran hidup yang pahit: semua yang kita tulis dengan Nest dapat ditumpuk dalam satu lapisan. Ini adalah Application Layer.
Kami tidak ingin kerangka kerja masuk lebih dalam ke logika bisnis, sehingga tidak berkembang ke dalamnya dengan pengecualian, dekorator, dan wadah IoC. Penulis kerangka kerja akan menjelaskan betapa hebatnya menulis logika bisnis menggunakan gula, tetapi tugas mereka adalah mengikat Anda dengan diri mereka sendiri selamanya. Ingatlah bahwa kerangka kerja hanyalah cara untuk mengatur logika tingkat aplikasi dengan mudah, menghubungkan infrastruktur dan UI ke sana.
"Kerangka adalah detail."
Robert "Paman Bob" Martin
Lebih baik merancang aplikasi sebagai konstruktor, di mana mudah untuk mengganti komponen. Salah satu contoh implementasi seperti itu adalah arsitektur heksagonal (arsitektur port dan adaptor ). Idenya menarik: inti domain dengan semua logika bisnis menyediakan port untuk berkomunikasi dengan dunia luar. Segala sesuatu yang diperlukan terhubung secara eksternal melalui adaptor.
Apakah realistis untuk mengimplementasikan arsitektur seperti itu di Node.js menggunakan Nest sebagai kerangka kerja? Cukup. Saya membuat pelajaran dengan contoh, jika Anda tertarik - Anda dapat menemukannya di sini .
Mari kita simpulkan
- Node.js bagus untuk BFF. Anda bisa tinggal bersamanya.
- Tidak ada solusi yang siap pakai.
- Kerangka tidak penting.
- Jika arsitektur Anda menjadi terlalu rumit, jika Anda mengetik, Anda mungkin telah memilih alat yang salah.
Saya merekomendasikan buku-buku ini:
- Robert Martin, "Arsitektur Bersih",
- Vaughn Vernon, Domain-Driven Design Distilled,
- Khalil Stemmler, khalilstemmler.com,
- Martin Fowler, martinfowler.com/architecture.