戰(zhàn):從零搭建天氣查詢 API 服務(wù))
1. 項(xiàng)目緣起與整體設(shè)計(jì)思路1.1 為什么選這個(gè)題目練手我一直覺(jué)得學(xué)一門技術(shù)最快的路徑不是看文檔而是動(dòng)手做一個(gè)能跑起來(lái)的東西。API 服務(wù)就是這樣一個(gè)絕佳的練手項(xiàng)目——它足夠小小到一個(gè)人一兩天就能搞定又足夠完整完整到能覆蓋后端開(kāi)發(fā)的核心鏈路接收請(qǐng)求、處理邏輯、返回響應(yīng)、錯(cuò)誤處理、日志記錄。這次我選的技術(shù)棧是Node.js Express。原因很直接JavaScript 一門語(yǔ)言從前端寫到后端不用切換思維Express 的生態(tài)成熟到幾乎任何需求都能找到現(xiàn)成的中間件再加上現(xiàn)在有 AI 輔助編碼很多樣板代碼可以直接生成省下來(lái)的時(shí)間可以花在真正需要思考的架構(gòu)設(shè)計(jì)上。這個(gè)項(xiàng)目適合誰(shuí)如果你已經(jīng)會(huì)一點(diǎn) JavaScript 基礎(chǔ)語(yǔ)法知道什么是函數(shù)、什么是對(duì)象但從來(lái)沒(méi)自己從零搭過(guò)一個(gè)后端服務(wù)那這篇內(nèi)容就是寫給你的。如果你已經(jīng)寫過(guò) Express 但一直是復(fù)制粘貼別人的代碼不清楚每一行在干什么那這篇也能幫你把知識(shí)串起來(lái)。1.2 這個(gè) API 服務(wù)到底要做什么我給自己定的目標(biāo)很明確做一個(gè)天氣查詢 API 服務(wù)。用戶傳一個(gè)城市名服務(wù)返回這個(gè)城市的天氣信息。聽(tīng)起來(lái)簡(jiǎn)單但麻雀雖小五臟俱全需要一個(gè) HTTP 服務(wù)器接收請(qǐng)求需要路由來(lái)區(qū)分不同的接口需要參數(shù)校驗(yàn)防止用戶傳亂七八糟的東西需要調(diào)用外部數(shù)據(jù)源獲取天氣需要統(tǒng)一的響應(yīng)格式需要錯(cuò)誤處理不能一報(bào)錯(cuò)就崩需要日志方便排查問(wèn)題這七個(gè)需求基本上就是一個(gè)生產(chǎn)級(jí) API 服務(wù)的骨架。把這個(gè)項(xiàng)目吃透以后換任何業(yè)務(wù)場(chǎng)景套路都是一樣的。1.3 技術(shù)選型的幾個(gè)關(guān)鍵決策為什么用 Express 而不是 Fastify這個(gè)問(wèn)題我被問(wèn)過(guò)很多次。Fastify 性能確實(shí)更好基準(zhǔn)測(cè)試數(shù)據(jù)擺在那里。但對(duì)于小項(xiàng)目來(lái)說(shuō)Express 的優(yōu)勢(shì)在于中間件生態(tài)最豐富、文檔最全、遇到問(wèn)題搜一下就有答案。Fastify 的插件體系雖然設(shè)計(jì)得更現(xiàn)代但學(xué)習(xí)曲線更陡。我的建議是先把 Express 用熟理解 HTTP 服務(wù)的本質(zhì)再去嘗試 Fastify 不遲。為什么不用 TypeScript小項(xiàng)目實(shí)戰(zhàn)的目的是快速驗(yàn)證想法TypeScript 的類型定義在項(xiàng)目初期反而是一種負(fù)擔(dān)。等你把業(yè)務(wù)邏輯跑通了再遷移到 TypeScript 也不遲。當(dāng)然如果你已經(jīng)熟悉 TypeScript直接用也沒(méi)問(wèn)題。AI 在這個(gè)項(xiàng)目里扮演什么角色我的用法是讓 AI 生成樣板代碼和重復(fù)性邏輯比如路由注冊(cè)、錯(cuò)誤處理中間件、參數(shù)校驗(yàn)規(guī)則。但核心的業(yè)務(wù)邏輯和架構(gòu)決策必須自己來(lái)。AI 生成的代碼你要能看懂、能改、能調(diào)試否則出了問(wèn)題你連從哪下手都不知道。2. 環(huán)境搭建與項(xiàng)目初始化2.1 Node.js 安裝的坑與正確姿勢(shì)Node.js 的安裝看起來(lái)簡(jiǎn)單但版本選擇有講究。我推薦用LTS 版本長(zhǎng)期支持版不要追最新的 Current 版本。LTS 版本經(jīng)過(guò)充分測(cè)試生態(tài)兼容性最好。截至我寫這篇內(nèi)容的時(shí)候Node.js 20.x 和 22.x 都是 LTS選哪個(gè)都行。安裝方式我強(qiáng)烈建議用nvmNode Version Manager而不是直接去官網(wǎng)下載安裝包。原因很簡(jiǎn)單不同項(xiàng)目可能依賴不同的 Node.js 版本nvm 讓你可以在版本之間一鍵切換。Windows 用戶可以用 nvm-windowsMac 和 Linux 用戶直接用官方的 nvm 腳本。安裝完成后打開(kāi)終端驗(yàn)證一下node -v npm -v兩個(gè)命令都能輸出版本號(hào)說(shuō)明安裝成功。如果提示“command not found”大概率是環(huán)境變量沒(méi)配好檢查一下 nvm 的安裝路徑是否加到了 PATH 里。注意不要用 sudo 安裝全局 npm 包這會(huì)導(dǎo)致權(quán)限問(wèn)題。如果遇到權(quán)限報(bào)錯(cuò)正確做法是配置 npm 的全局目錄到用戶目錄下而不是加 sudo。2.2 項(xiàng)目目錄結(jié)構(gòu)設(shè)計(jì)很多人寫小項(xiàng)目習(xí)慣把所有代碼塞進(jìn)一個(gè)index.js一開(kāi)始確實(shí)爽但改到第三天就痛苦了。我建議從一開(kāi)始就按職責(zé)分目錄weather-api/ ├── src/ │ ├── routes/ # 路由定義 │ │ └── weather.js │ ├── controllers/ # 業(yè)務(wù)邏輯 │ │ └── weatherController.js │ ├── services/ # 外部服務(wù)調(diào)用 │ │ └── weatherService.js │ ├── middlewares/ # 中間件 │ │ ├── errorHandler.js │ │ └── requestLogger.js │ ├── utils/ # 工具函數(shù) │ │ └── response.js │ └── app.js # Express 應(yīng)用配置 ├── .env # 環(huán)境變量 ├── .gitignore ├── package.json └── server.js # 入口文件這個(gè)結(jié)構(gòu)的好處是路由只管 URL 和 HTTP 方法的映射控制器管業(yè)務(wù)邏輯服務(wù)層管數(shù)據(jù)獲取。三層各司其職以后要換數(shù)據(jù)源只改服務(wù)層要加新接口只加路由和控制器。2.3 初始化項(xiàng)目與依賴安裝mkdir weather-api cd weather-api npm init -y npm install express dotenv axios npm install -D nodemon這里解釋一下每個(gè)依賴的作用expressWeb 框架處理 HTTP 請(qǐng)求的核心dotenv讀取.env文件里的環(huán)境變量比如端口號(hào)、API 密鑰axios發(fā) HTTP 請(qǐng)求用來(lái)調(diào)用外部天氣數(shù)據(jù)接口nodemon開(kāi)發(fā)依賴監(jiān)聽(tīng)文件變化自動(dòng)重啟服務(wù)開(kāi)發(fā)時(shí)必備在package.json里加兩個(gè)腳本{ scripts: { start: node server.js, dev: nodemon server.js } }開(kāi)發(fā)時(shí)用npm run dev部署時(shí)用npm start。3. 核心代碼實(shí)現(xiàn)與關(guān)鍵細(xì)節(jié)3.1 入口文件與 Express 應(yīng)用分離很多教程把a(bǔ)pp.listen()直接寫在app.js里我不推薦這種做法。把應(yīng)用配置和啟動(dòng)邏輯分開(kāi)好處是測(cè)試的時(shí)候可以直接導(dǎo)入 app 而不啟動(dòng)服務(wù)器。server.js只做一件事——啟動(dòng)服務(wù)const app require(./src/app); const PORT process.env.PORT || 3000; app.listen(PORT, () { console.log(服務(wù)已啟動(dòng)監(jiān)聽(tīng)端口 ${PORT}); });src/app.js負(fù)責(zé)組裝中間件和路由const express require(express); const requestLogger require(./middlewares/requestLogger); const errorHandler require(./middlewares/errorHandler); const weatherRoutes require(./routes/weather); const app express(); app.use(express.json()); app.use(requestLogger); app.use(/api/weather, weatherRoutes); app.use(errorHandler); module.exports app;注意中間件的順序express.json()必須在路由之前否則req.body拿不到數(shù)據(jù)錯(cuò)誤處理中間件必須在所有路由之后否則捕獲不到路由里拋出的錯(cuò)誤。3.2 路由層只做映射不寫邏輯路由層的職責(zé)非常單一把 URL 和 HTTP 方法映射到對(duì)應(yīng)的控制器函數(shù)。不要在路由里寫業(yè)務(wù)邏輯這是新手最容易犯的錯(cuò)誤。const express require(express); const router express.Router(); const weatherController require(../controllers/weatherController); router.get(/:city, weatherController.getWeatherByCity); router.get(/:city/forecast, weatherController.getForecast); module.exports router;這里定義了兩個(gè)接口GET /api/weather/:city查當(dāng)前天氣GET /api/weather/:city/forecast查未來(lái)幾天預(yù)報(bào)。:city是路徑參數(shù)Express 會(huì)自動(dòng)把它解析到req.params.city。3.3 控制器層參數(shù)校驗(yàn)與響應(yīng)組裝控制器是業(yè)務(wù)邏輯的入口它要做三件事校驗(yàn)參數(shù)、調(diào)用服務(wù)層、組裝響應(yīng)。const weatherService require(../services/weatherService); const { success, error } require(../utils/response); async function getWeatherByCity(req, res, next) { try { const { city } req.params; if (!city || city.trim().length 0) { return res.status(400).json(error(城市名不能為空)); } if (city.length 50) { return res.status(400).json(error(城市名過(guò)長(zhǎng))); } const weatherData await weatherService.fetchWeather(city); res.json(success(weatherData)); } catch (err) { next(err); } } module.exports { getWeatherByCity, getForecast };幾個(gè)關(guān)鍵點(diǎn)參數(shù)校驗(yàn)要前置。不要等到調(diào)用外部服務(wù)了才發(fā)現(xiàn)參數(shù)不對(duì)那樣浪費(fèi)一次網(wǎng)絡(luò)請(qǐng)求。校驗(yàn)規(guī)則要具體比如城市名長(zhǎng)度限制、特殊字符過(guò)濾。用next(err)傳遞錯(cuò)誤。在 async 函數(shù)里throw的錯(cuò)誤不會(huì)自動(dòng)被 Express 捕獲必須手動(dòng)傳給next()。這是 Express 的一個(gè)經(jīng)典坑很多人在這里栽過(guò)跟頭。響應(yīng)格式要統(tǒng)一。我定義了一個(gè)response.js工具function success(data, message ok) { return { code: 0, message, data }; } function error(message, code 1) { return { code, message, data: null }; }這樣前端拿到響應(yīng)后只需要判斷code是否為 0不用去猜每個(gè)接口的返回結(jié)構(gòu)。3.4 服務(wù)層外部數(shù)據(jù)獲取與容錯(cuò)服務(wù)層負(fù)責(zé)真正去拿數(shù)據(jù)。我用的是一個(gè)公開(kāi)的天氣數(shù)據(jù)接口通過(guò) axios 調(diào)用const axios require(axios); const API_BASE process.env.WEATHER_API_BASE; const API_KEY process.env.WEATHER_API_KEY; async function fetchWeather(city) { const url ${API_BASE}/current.json; const params { key: API_KEY, q: city, lang: zh }; const response await axios.get(url, { params, timeout: 5000 }); return { city: response.data.location.name, temperature: response.data.current.temp_c, condition: response.data.current.condition.text, humidity: response.data.current.humidity, windSpeed: response.data.current.wind_kph, updatedAt: response.data.current.last_updated }; } module.exports { fetchWeather };這里有幾個(gè)實(shí)戰(zhàn)經(jīng)驗(yàn)一定要設(shè) timeout。不設(shè)超時(shí)的話外部接口掛了你的服務(wù)也會(huì)跟著掛請(qǐng)求會(huì)一直掛在那里直到客戶端超時(shí)。5 秒是個(gè)合理的值。返回?cái)?shù)據(jù)要裁剪。外部接口返回的字段可能幾十個(gè)但你只需要其中幾個(gè)。在服務(wù)層就把數(shù)據(jù)裁剪成你需要的結(jié)構(gòu)控制器和前端都不用關(guān)心原始數(shù)據(jù)結(jié)構(gòu)。API 密鑰放環(huán)境變量。絕對(duì)不要把密鑰硬編碼在代碼里然后提交到代碼倉(cāng)庫(kù)。.env文件要加到.gitignore里。3.5 中間件日志與錯(cuò)誤處理請(qǐng)求日志中間件記錄每個(gè)請(qǐng)求的方法、路徑、耗時(shí)function requestLogger(req, res, next) { const start Date.now(); res.on(finish, () { const duration Date.now() - start; console.log(${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms); }); next(); }用res.on(finish)而不是直接在next()前打印是因?yàn)橐软憫?yīng)完成才能拿到狀態(tài)碼和耗時(shí)。全局錯(cuò)誤處理中間件是最后一道防線function errorHandler(err, req, res, next) { console.error(未捕獲錯(cuò)誤:, err.message); if (err.code ECONNABORTED) { return res.status(504).json({ code: 1, message: 外部服務(wù)超時(shí) }); } if (err.response err.response.status 404) { return res.status(404).json({ code: 1, message: 城市不存在 }); } res.status(500).json({ code: 1, message: 服務(wù)器內(nèi)部錯(cuò)誤 }); }錯(cuò)誤處理中間件必須接收四個(gè)參數(shù)(err, req, res, next)少一個(gè) Express 就不會(huì)把它當(dāng)作錯(cuò)誤處理中間件。這個(gè)細(xì)節(jié)很多人不知道。4. 常見(jiàn)問(wèn)題排查與避坑指南4.1 端口被占用怎么辦開(kāi)發(fā)時(shí)經(jīng)常遇到EADDRINUSE錯(cuò)誤意思是端口已經(jīng)被別的程序占了。兩個(gè)解決辦法# Mac/Linux 查看誰(shuí)占了 3000 端口 lsof -i :3000 # Windows netstat -ano | findstr :3000找到進(jìn)程號(hào)后 kill 掉或者直接換個(gè)端口。我習(xí)慣在.env里配PORT3001避免和常用端口沖突。4.2 async 錯(cuò)誤沒(méi)被捕獲這是 Express 最經(jīng)典的坑??催@段代碼app.get(/test, async (req, res) { throw new Error(出錯(cuò)了); // 這個(gè)錯(cuò)誤不會(huì)被錯(cuò)誤處理中間件捕獲 });async 函數(shù)返回的是一個(gè) PromiseExpress 4.x 不會(huì)自動(dòng)捕獲 Promise 的 rejection。解決辦法有三種手動(dòng) try-catch 然后next(err)、用express-async-errors這個(gè)包、或者升級(jí)到 Express 5Express 5 原生支持 async 錯(cuò)誤捕獲。我推薦第一種最可控。4.3 跨域問(wèn)題前端調(diào)用接口時(shí)報(bào) CORS 錯(cuò)誤解決辦法是加cors中間件npm install corsconst cors require(cors); app.use(cors());開(kāi)發(fā)階段可以允許所有來(lái)源生產(chǎn)環(huán)境要配置白名單只允許你自己的域名訪問(wèn)。4.4 常見(jiàn)問(wèn)題速查表問(wèn)題現(xiàn)象可能原因解決方法Cannot GET /api/weather路由路徑不匹配檢查路由注冊(cè)的前綴和請(qǐng)求路徑req.body為 undefined沒(méi)加express.json()在路由之前注冊(cè) body 解析中間件錯(cuò)誤處理中間件不生效參數(shù)不是四個(gè)確保是(err, req, res, next)外部接口調(diào)用超時(shí)沒(méi)設(shè) timeoutaxios 配置里加timeout: 5000環(huán)境變量讀不到.env沒(méi)加載入口文件頂部加require(dotenv).config()修改代碼不生效沒(méi)重啟服務(wù)用 nodemon 啟動(dòng)或手動(dòng)重啟4.5 幾個(gè)我踩過(guò)的坑dotenv 的加載時(shí)機(jī)。require(dotenv).config()必須放在最頂部在所有其他 require 之前。因?yàn)槠渌K可能在加載時(shí)就會(huì)讀取環(huán)境變量如果 dotenv 還沒(méi)執(zhí)行讀到的就是 undefined。路徑參數(shù)的編碼問(wèn)題。城市名如果包含中文或空格URL 里會(huì)被編碼。Express 會(huì)自動(dòng)解碼req.params但如果你手動(dòng)拼接 URL 去調(diào)外部接口記得用encodeURIComponent()處理。JSON 響應(yīng)里的中文。Express 默認(rèn)的res.json()會(huì)正確設(shè)置Content-Type: application/json; charsetutf-8中文不會(huì)亂碼。但如果你用res.send()返回對(duì)象Express 也會(huì)自動(dòng)轉(zhuǎn) JSON效果一樣。5. 用 AI 輔助開(kāi)發(fā)的正確姿勢(shì)5.1 AI 能幫你做什么在這個(gè)項(xiàng)目里我用 AI 做了這些事生成路由和控制器的樣板代碼我只需要改業(yè)務(wù)邏輯寫參數(shù)校驗(yàn)的正則表達(dá)式比如城市名只允許中文、英文和空格生成錯(cuò)誤處理的分類邏輯把不同的錯(cuò)誤碼映射到不同的 HTTP 狀態(tài)碼寫單元測(cè)試的用例覆蓋正常和異常場(chǎng)景AI 生成的代碼質(zhì)量參差不齊關(guān)鍵是要能看懂。看不懂的代碼不要用讓 AI 解釋一遍理解了再?zèng)Q定要不要。5.2 AI 不能替你做什么架構(gòu)決策、錯(cuò)誤處理的邊界條件、業(yè)務(wù)邏輯的細(xì)節(jié)這些必須自己來(lái)。比如“城市名傳空字符串應(yīng)該返回 400 還是 404”這種問(wèn)題沒(méi)有標(biāo)準(zhǔn)答案取決于你的 API 設(shè)計(jì)約定。AI 會(huì)給你一個(gè)答案但不一定是你想要的。還有一個(gè)重要的點(diǎn)AI 生成的代碼可能有安全漏洞。比如它可能會(huì)把用戶輸入直接拼接到 SQL 里或者忘記做輸入過(guò)濾。安全相關(guān)的代碼一定要自己審查。5.3 我的 AI 協(xié)作流程我的習(xí)慣是先自己想清楚要做什么用注釋把邏輯寫出來(lái)然后讓 AI 把注釋翻譯成代碼。這樣 AI 是在執(zhí)行我的設(shè)計(jì)而不是替我做設(shè)計(jì)。代碼生成后我會(huì)逐行審查改掉不合理的部分加上自己的錯(cuò)誤處理和日志。這個(gè)流程的好處是AI 提高了編碼速度但架構(gòu)和邏輯仍然在我掌控之中。出了問(wèn)題我知道去哪找因?yàn)榇a是我設(shè)計(jì)的。6. 部署與后續(xù)擴(kuò)展6.1 本地驗(yàn)證清單部署之前我會(huì)用 curl 把每個(gè)接口都過(guò)一遍# 正常請(qǐng)求 curl http://localhost:3000/api/weather/beijing # 空城市名 curl http://localhost:3000/api/weather/ # 不存在的城市 curl http://localhost:3000/api/weather/notacity # 超長(zhǎng)城市名 curl http://localhost:3000/api/weather/aaaaaaaaaa...每個(gè)接口的正常和異常路徑都要覆蓋確認(rèn)返回的狀態(tài)碼和響應(yīng)格式符合預(yù)期。6.2 可以繼續(xù)擴(kuò)展的方向這個(gè)項(xiàng)目跑通之后可以往幾個(gè)方向擴(kuò)展加緩存。天氣數(shù)據(jù)不需要每次請(qǐng)求都去調(diào)外部接口可以加一層內(nèi)存緩存比如 10 分鐘內(nèi)同一個(gè)城市的請(qǐng)求直接返回緩存結(jié)果。用node-cache這個(gè)包幾行代碼就能搞定。加限流。防止有人惡意刷接口用express-rate-limit限制每個(gè) IP 的請(qǐng)求頻率。加接口文檔。用 Swagger 自動(dòng)生成 API 文檔前端同事不用問(wèn)你接口怎么調(diào)。加健康檢查接口。GET /health返回服務(wù)狀態(tài)部署到云平臺(tái)后負(fù)載均衡器會(huì)定期檢查這個(gè)接口。遷移到 TypeScript。業(yè)務(wù)邏輯穩(wěn)定后加上類型定義重構(gòu)時(shí)更有底氣。6.3 我個(gè)人的體會(huì)搭這個(gè) API 服務(wù)最大的收獲不是學(xué)會(huì)了 Express 的 API而是理解了一個(gè)后端服務(wù)的完整生命周期請(qǐng)求進(jìn)來(lái)、經(jīng)過(guò)中間件、到達(dá)路由、執(zhí)行邏輯、調(diào)用外部服務(wù)、組裝響應(yīng)、返回給客戶端、記錄日志。這個(gè)流程走一遍以后看任何后端框架的文檔都能快速上手因?yàn)楦拍钍窍嗤ǖ?。另外AI 輔助編碼確實(shí)能提速但前提是你自己得有判斷力。AI 給的代碼對(duì)不對(duì)、好不好、安不安全你得能看出來(lái)。這個(gè)判斷力來(lái)自你親手寫過(guò)的代碼量沒(méi)有捷徑。最后分享一個(gè)小技巧每次改完代碼不要只測(cè)你改的那個(gè)功能把相關(guān)的接口都跑一遍。我遇到過(guò)好幾次改 A 功能把 B 功能改壞的情況都是因?yàn)橹粶y(cè)了改動(dòng)點(diǎn)。養(yǎng)成回歸測(cè)試的習(xí)慣能省掉很多線上排查的時(shí)間。