iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
佛心分享-IT 人自學之術

欸欸!! 這是我的學習筆記系列 第 7

Day7 - Node.js 建立 HTTP API

  • 分享至 

  • xImage
  •  

前言

上一篇整理了一些基礎知識,今天就把重點放在應用的部分,建立簡單的伺服器以及設計API

第一個 HTTP 伺服器

其實前面第四天已經寫過一次範例了

const http = require('node:http');

const server = http.createServer((req, res) => {
  const now = new Date();
  const greeting = now.getHours() < 12 ? '早安' : 
                  now.getHours() < 18 ? '午安' : '晚安';
  
  res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
  res.end(`${greeting}!現在是 ${now.toLocaleString('zh-TW')}`);
});

const PORT = 3000;
server.listen(PORT, () => {
  console.log(`伺服器啟動了!快去 http://localhost:${PORT} 看看吧`);
});

https://ithelp.ithome.com.tw/upload/images/20250921/20177951QYWLIBzlgE.png

  • createServer() 會建立一個新的 HTTP 伺服器
  • 回調函數會在每次有請求時被呼叫
  • req 包含了請求的所有資訊
  • res 用來發送回應給客戶端
  • listen() 讓伺服器開始監聽指定端口

讀取請求資訊

接下來看看如何獲取請求的詳細資訊:

const http = require('node:http');
const url = require('node:url');

const server = http.createServer((req, res) => {
  // 解析 URL
  const parsedUrl = url.parse(req.url, true);
  
  // 取得各種請求資訊
  const method = req.method;           // GET, POST, PUT, DELETE...
  const pathname = parsedUrl.pathname; // URL 路徑
  const query = parsedUrl.query;       // 查詢參數
  const headers = req.headers;         // 請求標頭
  
  // 回應請求資訊
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify({
    method,
    pathname,
    query,
    userAgent: headers['user-agent']
  }, null, 2));
});

server.listen(3000, () => {
  console.log('伺服器啟動,請訪問 http://localhost:3000/test?name=John');
});

處理不同的 HTTP 方法

處理 GET 請求

const http = require('node:http');
const url = require('node:url');

function handleGet(req, res, pathname, query) {
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
  
  if (pathname === '/users') {
    // 模擬用戶資料
    const users = [
      { id: 1, name: '張三' },
      { id: 2, name: '李四' },
      { id: 3, name: '王五' }
    ];
    
    res.writeHead(200);
    res.end(JSON.stringify({ users, query }));
  } 
  else if (pathname === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(`
      <h1>API 測試頁面</h1>
      <p>試試看這些連結:</p>
      <ul>
        <li><a href="/users">取得用戶列表</a></li>
        <li><a href="/users?page=1&limit=2">分頁查詢</a></li>
      </ul>
    `);
  } 
  else {
    res.writeHead(404);
    res.end(JSON.stringify({ error: '找不到頁面' }));
  }
}

const server = http.createServer((req, res) => {
  const parsedUrl = url.parse(req.url, true);
  
  if (req.method === 'GET') {
    handleGet(req, res, parsedUrl.pathname, parsedUrl.query);
  } else {
    res.writeHead(405);
    res.end(JSON.stringify({ error: '不支援的請求方法' }));
  }
});

server.listen(3000);

處理 POST 請求

POST 請求比較複雜,因為需要接收請求主體的資料:

const http = require('node:http');

function handlePost(req, res, pathname) {
  if (pathname === '/users') {
    let body = '';
    
    // 資料可能會分批傳送,需要逐步收集
    req.on('data', chunk => {
      body += chunk.toString();
      
      // 防止過大的資料攻擊
      if (body.length > 1000000) { // 1MB 限制
        res.writeHead(413);
        res.end(JSON.stringify({ error: '資料過大' }));
        req.destroy();
        return;
      }
    });
    
    // 資料接收完成
    req.on('end', () => {
      try {
        const userData = JSON.parse(body);
        
        // 簡單驗證
        if (!userData.name || !userData.email) {
          res.writeHead(400, { 'Content-Type': 'application/json' });
          res.end(JSON.stringify({ error: '姓名和 email 為必填' }));
          return;
        }
        
        // 模擬創建用戶
        const newUser = {
          id: Date.now(),
          ...userData,
          createdAt: new Date().toISOString()
        };
        
        res.writeHead(201, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({
          message: '用戶創建成功',
          user: newUser
        }));
      } catch (err) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'JSON 格式錯誤' }));
      }
    });
    
    // 處理請求錯誤
    req.on('error', (err) => {
      console.error('請求錯誤:', err);
      res.writeHead(500);
      res.end(JSON.stringify({ error: '伺服器錯誤' }));
    });
  } else {
    res.writeHead(404);
    res.end(JSON.stringify({ error: '找不到 API 端點' }));
  }
}

const server = http.createServer((req, res) => {
  if (req.method === 'POST') {
    const pathname = require('node:url').parse(req.url).pathname;
    handlePost(req, res, pathname);
  } else {
    res.writeHead(405);
    res.end('只支援 POST 請求');
  }
});

server.listen(3000);

建立 HTTP 客戶端

有時候我們的伺服器需要向其他 API 發送請求,這時候就需要用到 HTTP 客戶端:

發送 GET 請求

const http = require('node:http');

function sendGetRequest(url) {
  return new Promise((resolve, reject) => {
    console.log('發送 GET 請求到:', url);
    
    http.get(url, (res) => {
      let data = '';
      
      console.log('回應狀態碼:', res.statusCode);
      
      // 收集回應資料
      res.on('data', (chunk) => {
        data += chunk;
      });
      
      // 接收完成
      res.on('end', () => {
        try {
          const parsedData = JSON.parse(data);
          resolve({
            statusCode: res.statusCode,
            headers: res.headers,
            data: parsedData
          });
        } catch (err) {
          // 如果不是 JSON,就回傳原始文字
          resolve({
            statusCode: res.statusCode,
            headers: res.headers,
            data: data
          });
        }
      });
    }).on('error', (err) => {
      reject(err);
    });
  });
}

// 使用範例
async function testGetRequest() {
  try {
    const result = await sendGetRequest('http://jsonplaceholder.typicode.com/posts/1');
    console.log('請求成功:', result.data.title);
  } catch (err) {
    console.error('請求失敗:', err.message);
  }
}

// testGetRequest();

發送 POST 請求

const http = require('node:http');

function sendPostRequest(hostname, port, path, data) {
  return new Promise((resolve, reject) => {
    const postData = JSON.stringify(data);
    
    const options = {
      hostname: hostname,
      port: port,
      path: path,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(postData)
      }
    };
    
    console.log('發送 POST 請求到:', `${hostname}:${port}${path}`);
    
    const req = http.request(options, (res) => {
      let responseData = '';
      
      res.on('data', (chunk) => {
        responseData += chunk;
      });
      
      res.on('end', () => {
        resolve({
          statusCode: res.statusCode,
          headers: res.headers,
          data: responseData
        });
      });
    });
    
    req.on('error', (err) => {
      reject(err);
    });
    
    // 設定超時
    req.setTimeout(5000, () => {
      req.destroy();
      reject(new Error('請求超時'));
    });
    
    // 發送資料
    req.write(postData);
    req.end();
  });
}

// 使用範例
async function testPostRequest() {
  try {
    const result = await sendPostRequest(
      'jsonplaceholder.typicode.com', 
      80, 
      '/posts', 
      {
        title: '測試文章',
        body: '這是測試內容',
        userId: 1
      }
    );
    
    console.log('POST 請求成功:', result.statusCode);
    console.log('回應資料:', JSON.parse(result.data));
  } catch (err) {
    console.error('POST 請求失敗:', err.message);
  }
}

// testPostRequest();

靜態檔案服務

const http = require('node:http');
const fs = require('node:fs/promises');
const path = require('node:path');

// 檔案類型對應表
const mimeTypes = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css',
  '.js': 'application/javascript',
  '.json': 'application/json',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.gif': 'image/gif',
  '.txt': 'text/plain; charset=utf-8'
};

async function serveStaticFile(filePath, publicDir = './public') {
  // 安全檢查:防止目錄遍歷攻擊
  const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
  const fullPath = path.join(publicDir, safePath);
  
  // 確保請求的檔案在允許的目錄內
  if (!fullPath.startsWith(path.resolve(publicDir))) {
    throw new Error('禁止存取');
  }
  
  try {
    const stats = await fs.stat(fullPath);
    
    if (stats.isDirectory()) {
      // 如果是目錄,嘗試提供 index.html
      const indexPath = path.join(fullPath, 'index.html');
      try {
        await fs.access(indexPath);
        return await serveStaticFile(path.join(safePath, 'index.html'), publicDir);
      } catch {
        throw new Error('目錄沒有 index.html');
      }
    }
    
    // 讀取檔案
    const fileContent = await fs.readFile(fullPath);
    
    // 確定檔案類型
    const ext = path.extname(fullPath).toLowerCase();
    const contentType = mimeTypes[ext] || 'application/octet-stream';
    
    return {
      content: fileContent,
      contentType: contentType,
      size: stats.size,
      lastModified: stats.mtime.toUTCString()
    };
  } catch (err) {
    if (err.code === 'ENOENT') {
      throw new Error('檔案不存在');
    }
    throw err;
  }
}

const server = http.createServer(async (req, res) => {
  const pathname = require('node:url').parse(req.url).pathname;
  
  console.log('請求檔案:', pathname);
  
  try {
    const fileData = await serveStaticFile(pathname);
    
    res.writeHead(200, {
      'Content-Type': fileData.contentType,
      'Content-Length': fileData.size,
      'Last-Modified': fileData.lastModified,
      'Cache-Control': 'public, max-age=3600' // 1小時快取
    });
    
    res.end(fileData.content);
  } catch (err) {
    console.log('檔案服務錯誤:', err.message);
    
    if (err.message === '檔案不存在') {
      res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(`
        <h1>404 - 找不到檔案</h1>
        <p>你要找的檔案 <code>${pathname}</code> 不存在</p>
        <a href="/">回首頁</a>
      `);
    } else {
      res.writeHead(500, { 'Content-Type': 'text/plain' });
      res.end('伺服器錯誤');
    }
  }
});

server.listen(3000, async () => {
  console.log('靜態檔案伺服器啟動在 http://localhost:3000');
  console.log('請將檔案放在 ./public 目錄');
  
  // 自動建立範例檔案
  try {
    await fs.mkdir('./public', { recursive: true });
    const indexContent = `
<!DOCTYPE html>
<html>
<head>
    <title>靜態檔案測試</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 40px; }
        h1 { color: #333; }
    </style>
</head>
<body>
    <h1>靜態檔案伺服器測試</h1>
    <p>這個檔案位於 ./public/index.html</p>
    <p>你可以在 public 目錄中添加更多檔案</p>
</body>
</html>`;
    
    await fs.writeFile('./public/index.html', indexContent);
    console.log('已建立範例 index.html');
  } catch (err) {
    console.log('請手動建立 public 目錄');
  }
});

錯誤處理與監控

const http = require('node:http');

class HttpServer {
  constructor() {
    this.server = null;
    this.isShuttingDown = false;
  }
  
  createServer() {
    this.server = http.createServer((req, res) => {
      this.handleRequest(req, res).catch(err => {
        console.error('請求處理錯誤:', err);
        if (!res.headersSent) {
          res.writeHead(500, { 'Content-Type': 'application/json' });
          res.end(JSON.stringify({ error: '伺服器內部錯誤' }));
        }
      });
    });
    
    // 伺服器錯誤處理
    this.server.on('error', (err) => {
      console.error('伺服器錯誤:', err);
      if (err.code === 'EADDRINUSE') {
        console.error('端口已被使用');
        process.exit(1);
      }
    });
    
    // 客戶端錯誤處理
    this.server.on('clientError', (err, socket) => {
      console.error('客戶端錯誤:', err.message);
      if (!socket.destroyed) {
        socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
      }
    });
    
    return this.server;
  }
  
  async handleRequest(req, res) {
    const startTime = Date.now();
    const clientIP = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
    
    console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} from ${clientIP}`);
    
    // 設定請求超時
    req.setTimeout(30000, () => {
      if (!res.headersSent) {
        res.writeHead(408);
        res.end('請求超時');
      }
    });
    
    // 檢查伺服器狀態
    if (this.isShuttingDown) {
      res.writeHead(503, { 'Connection': 'close' });
      res.end('伺服器正在關閉');
      return;
    }
    
    try {
      const url = new URL(req.url, `http://${req.headers.host}`);
      
      if (url.pathname === '/health') {
        await this.handleHealthCheck(res);
      } else if (url.pathname === '/') {
        await this.handleHomePage(res);
      } else {
        res.writeHead(404);
        res.end('找不到頁面');
      }
      
      // 記錄回應時間
      const responseTime = Date.now() - startTime;
      console.log(`請求完成,耗時 ${responseTime}ms`);
      
    } catch (err) {
      throw err;
    }
  }
  
  async handleHealthCheck(res) {
    const uptime = process.uptime();
    const memUsage = process.memoryUsage();
    
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ 
      status: 'healthy',
      uptime: Math.floor(uptime),
      memory: Math.round(memUsage.heapUsed / 1024 / 1024),
      timestamp: new Date().toISOString()
    }));
  }
  
  async handleHomePage(res) {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(`
      <h1>HTTP 伺服器</h1>
      <p>伺服器運行正常</p>
      <ul>
        <li><a href="/health">健康檢查</a></li>
      </ul>
    `);
  }
  
  listen(port) {
    return new Promise((resolve, reject) => {
      if (!this.server) {
        this.createServer();
      }
      
      this.server.listen(port, (err) => {
        if (err) {
          reject(err);
        } else {
          console.log(`伺服器啟動在端口 ${port}`);
          resolve();
        }
      });
    });
  }
  
  // 關閉
  async gracefulShutdown() {
    this.isShuttingDown = true;
    
    return new Promise((resolve) => {
      if (!this.server) {
        resolve();
        return;
      }
      
      this.server.close((err) => {
        if (err) {
          console.error('關閉伺服器時出錯:', err);
        } else {
          console.log('伺服器已關閉');
        }
        resolve();
      });
      
      // 10秒後強制關閉
      setTimeout(() => {
        console.log('強制關閉伺服器');
        resolve();
      }, 10000);
    });
  }
}

// 使用範例
const server = new HttpServer();

server.listen(3000).catch(err => {
  console.error('伺服器啟動失敗:', err);
  process.exit(1);
});

// 關閉處理
process.on('SIGTERM', async () => {
  await server.gracefulShutdown();
  process.exit(0);
});

process.on('SIGINT', async () => {
  await server.gracefulShutdown();
  process.exit(0);
});

整理

請求生命週期

// 一個完整的請求處理流程
const server = http.createServer((req, res) => {
  // 1. 解析請求
  const method = req.method;
  const url = req.url;
  
  // 2. 處理請求主體(如果有的話)
  let body = '';
  req.on('data', chunk => {
    body += chunk;
  });
  
  req.on('end', () => {
    // 3. 處理業務邏輯
    // 4. 發送回應
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('處理完成');
  });
});

明天再來整理 RESTful API,也再多整理其他 PUT、DELETE、PATCH 等 API 範例,
後續就可以開始往框架前進了。


上一篇
Day6 - Node.js HTTP 模組
下一篇
Day8 - Node.js RESTful API
系列文
欸欸!! 這是我的學習筆記8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言