Routing secara manual

Pencocokan pola dan penyaluran method tanpa framework.

Daftar isi

Routing secara manual adalah pencocokan jalur dan metode HTTP dari permintaan terhadap daftar rute yang didefinisikan pada kode, tanpa memakai framework seperti Express. Modul http hanya memberikan callback (req, res) per permintaan dan tidak menyediakan pencocokan pola, sehingga rute perlu disusun sendiri. Pola yang umum dipakai adalah pencabangan if sederhana untuk jumlah rute kecil, lalu berkembang menjadi tabel rute terpusat dengan penyaluran method dan pencocokan pola dinamis seperti /catatan/:id. Setelah membaca topik ini, pembaca dapat membangun router manual yang menangani rute statis, metode yang tidak diizinkan, serta parameter jalur dinamis.

Routing dengan pencabangan if sederhana#

Untuk aplikasi dengan jumlah rute sedikit, pencabangan if pada url.pathname dan req.method sudah cukup. Properti url.pathname didapat dengan mengurai req.url memakai kelas URL, dan req.method dibandingkan dalam huruf kapital. Setiap cabang memanggil res.writeHead dan res.end sendiri, dan cabang terakhir menjadi tangkapan untuk rute yang tidak dikenali dengan respons 404 Not Found.

js router-if.js
const http = require('node:http');

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  if (url.pathname === '/' && req.method === 'GET') {
    res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
    res.end('beranda');
  } else if (url.pathname === '/status' && req.method === 'GET') {
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ status: 'aktif' }));
  } else {
    res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
    res.end('tidak ditemukan');
  }
});

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const r1 = await fetch(`http://127.0.0.1:${port}/`);
  const r2 = await fetch(`http://127.0.0.1:${port}/status`);
  const r3 = await fetch(`http://127.0.0.1:${port}/lain`);
  console.log('/ status:', r1.status, '| body:', await r1.text());
  console.log('/status status:', r2.status, '| body:', await r2.text());
  console.log('/lain status:', r3.status, '| body:', await r3.text());
  server.close();
});
text Keluaran
/ status: 200 | body: beranda
/status status: 200 | body: {"status":"aktif"}
/lain status: 404 | body: tidak ditemukan

Pendekatan ini membosankan ketika rute bertambah banyak karena cabang if menjadi panjang dan duplikasi pengaturan header menyebar. Pemakaian pencabangan if juga menyulitkan penambahan middleware yang berlaku untuk seluruh rute, sebab setiap cabang harus memanggil middleware tersebut sendiri. Untuk jumlah rute lebih dari sepuluh, tabel rute terpusat menjadi pilihan yang lebih dapat dirawat.

Penyaluran method dan status 405#

HTTP membedakan sumber daya dari metode yang diizinkan padanya, sehingga respons yang tepat ketika metode tidak didukung adalah 405 Method Not Allowed, bukan 404 Not Found. Header Allow wajib disertakan pada respons 405 agar klien tahu metode apa saja yang diterima sumber daya tersebut. Penyaluran method dapat dilakukan dengan tabel rute berbentuk objek dua tingkat, di mana kunci tingkat pertama adalah jalur dan kunci tingkat kedua adalah metode.

js router-method.js
const http = require('node:http');

const rute = {
  '/catatan': {
    GET: (req, res) => {
      res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
      res.end('daftar catatan');
    },
    POST: (req, res) => {
      res.writeHead(201, { 'content-type': 'text/plain; charset=utf-8' });
      res.end('catatan dibuat');
    },
  },
};

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  const ruteUntukJalur = rute[url.pathname];
  if (!ruteUntukJalur) {
    res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
    res.end('rute tidak ditemukan');
    return;
  }
  const handler = ruteUntukJalur[req.method];
  if (!handler) {
    res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', 'allow': Object.keys(ruteUntukJalur).join(', ') });
    res.end('metode tidak diizinkan');
    return;
  }
  handler(req, res);
});

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const uji = [
    ['GET', '/catatan'],
    ['POST', '/catatan'],
    ['DELETE', '/catatan'],
    ['GET', '/lain'],
  ];
  for (const [metode, jalur] of uji) {
    const r = await fetch(`http://127.0.0.1:${port}${jalur}`, { method: metode });
    console.log(`${metode} ${jalur} -> ${r.status} allow=${r.headers.get('allow') || '-'} body=${await r.text()}`);
  }
  server.close();
});
text Keluaran
GET /catatan -> 200 allow=- body=daftar catatan
POST /catatan -> 201 allow=- body=catatan dibuat
DELETE /catatan -> 405 allow=GET, POST body=metode tidak diizinkan
GET /lain -> 404 allow=- body=rute tidak ditemukan

Pengujian di atas menunjukkan perbedaan dua jenis kegagalan rute. Permintaan DELETE /catatan menemukan rute untuk jalur /catatan tetapi tidak ada handler untuk metode DELETE, sehingga respons 405 dengan header Allow: GET, POST dikembalikan. Permintaan GET /lain tidak menemukan rute untuk jalur tersebut sama sekali, sehingga respons 404 yang dikembalikan tidak menyertakan header Allow. Pembedaan ini berguna bagi klien yang ingin tahu apakah sumber daya ada tetapi metode salah, atau sumber daya memang tidak ada.

Pencocokan pola jalur dinamis#

Jalur dengan parameter dinamis seperti /catatan/12 perlu dicocokkan terhadap pola /catatan/:id. Pola ini tidak dapat dicocokkan dengan pembandingan string biasa, melainkan dengan pemisahan segmen per / dan pembandingan segmen demi segmen. Segmen yang diawali : pada pola dianggap parameter, dan nilai segmen yang sesuai pada jalur aktual menjadi nilai parameter tersebut. Fungsi pencocok mengembalikan null bila jumlah segmen berbeda atau bila ada segmen literal yang tidak sama.

js router-pola.js
const http = require('node:http');

function cocokPola(pola, jalur) {
  const segmenPola = pola.split('/').filter(Boolean);
  const segmenJalur = jalur.split('/').filter(Boolean);
  if (segmenPola.length !== segmenJalur.length) return null;
  const params = {};
  for (let i = 0; i < segmenPola.length; i++) {
    if (segmenPola[i].startsWith(':')) {
      params[segmenPola[i].slice(1)] = decodeURIComponent(segmenJalur[i]);
    } else if (segmenPola[i] !== segmenJalur[i]) {
      return null;
    }
  }
  return params;
}

const daftarRute = [
  { pola: '/catatan', method: 'GET', handler: (req, res) => {
    res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
    res.end('daftar catatan');
  }},
  { pola: '/catatan/:id', method: 'GET', handler: (req, res, params) => {
    res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
    res.end(`catatan id=${params.id}`);
  }},
  { pola: '/catatan/:id/komentar/:no', method: 'GET', handler: (req, res, params) => {
    res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
    res.end(`komentar catatan=${params.id} no=${params.no}`);
  }},
];

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  for (const rute of daftarRute) {
    if (rute.method !== req.method) continue;
    const params = cocokPola(rute.pola, url.pathname);
    if (params) {
      rute.handler(req, res, params);
      return;
    }
  }
  res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
  res.end('tidak cocok');
});

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const uji = ['/catatan', '/catatan/12', '/catatan/12/komentar/3', '/lain'];
  for (const jalur of uji) {
    const r = await fetch(`http://127.0.0.1:${port}${jalur}`);
    console.log(`${jalur} -> ${r.status} ${await r.text()}`);
  }
  server.close();
});
text Keluaran
/catatan -> 200 daftar catatan
/catatan/12 -> 200 catatan id=12
/catatan/12/komentar/3 -> 200 komentar catatan=12 no=3
/lain -> 404 tidak cocok

Pemakaian decodeURIComponent pada nilai segmen dinamis menjaga karakter seperti spasi atau huruf non-ASCII yang dikodekan klien tetap dapat dibaca. Pencocokan pola pada contoh ini memerlukan jumlah segmen persis sama, sehingga /catatan/ yang diakhiri garis miring dianggap sama dengan /catatan karena segmen kosong dibuang oleh filter(Boolean). Perilaku ini perlu disesuaikan bila aplikasi ingin memperlakukan garis miring penutup sebagai bagian penting dari jalur.

Pencocokan pola yang lebih kompleks, seperti segmen opsional atau wildcard, tidak didukung oleh fungsi sederhana di atas. Segmen opsional biasanya ditempuh dengan mendaftarkan dua pola terpisah, misalnya /catatan dan /catatan/:id, keduanya mengarah ke handler yang sama atau handler berbeda sesuai kebutuhan. Wildcard yang menangkap sisa jalur, misalnya /berkas/*path, memerlukan pencocokan yang mengumpulkan sisa segmen sebagai satu string. Untuk kebutuhan ini, pemakaian pustaka seperti path-to-regexp atau migrasi ke framework seperti Express menjadi pertimbangan yang lebih ekonomis dibanding memperluas fungsi pencocokan sendiri.

Kesalahan umum#

Mengembalikan 404 untuk metode yang tidak didukung pada jalur yang ada menyembunyikan informasi bahwa sumber daya memang ada. Penggantian dengan 405 dan header Allow memberi sinyal yang benar kepada klien dan menjadi praktik standar pada API yang baik. Klien yang menerima 405 dapat mencoba ulang dengan metode yang tercantum pada header Allow.

Membandingkan url.pathname dengan string tanpa membuang garis miring penutup dapat menyebabkan rute /catatan dan /catatan/ dianggap berbeda. Normalisasi pathname sebelum pencocokan, misalnya dengan membuang garis miring penutup kecuali untuk jalur akar, menjaga rute tetap konsisten. Kebijakan normalisasi sebaiknya diterapkan sebelum pencarian rute agar seluruh handler menerima pathname yang sudah dinormalkan.

Lupa memanggil res.end() pada handler yang baru ditambahkan sering tidak disadari sampai klien mengeluhkan timeout. Pemakaian tabel rute terpusat membantu, sebab struktur yang seragam membuat peninjauan handler lebih mudah dan kebiasaan lupa end lebih cepat terlihat. Penambahan tes sederhana yang melakukan permintaan ke tiap rute dan memeriksa respons mencegah keluhan ini mencapai produksi.

Ringkasan#

  • Pencabangan if pada url.pathname dan req.method cukup untuk jumlah rute sedikit, tetapi tabel rute terpusat lebih dapat dirawat ketika rute bertambah.
  • Tabel rute dua tingkat dengan kunci jalur dan metode memungkinkan respons 405 Method Not Allowed disertai header Allow yang benar.
  • Pencocokan pola /catatan/:id dilakukan dengan memisahkan segmen per / dan mengumpulkan segmen berawalan : sebagai parameter.
  • Urutan rute pada daftar memengaruhi rute mana yang dicocokkan lebih dulu, dan rute statis sebaiknya diletakkan sebelum rute dinamis.

Referensi#