一开始,用一个朴素的待办清单就很好:添加一些任务,完成后勾掉即可。但随着任务越来越多,使用体验会变得糟糕。一个能改善体验的做法是引入分类,这样每个任务都可以归属到 Work、Study、Personal 等类别,您也能按类别筛选列表。
在本教程中,您将学习如何使用 Node.js 和 MongoDB 构建一个可按类别组织任务的 REST API。完成后,您将拥有一个包含五个端点(创建、列表、获取、更新、删除)以及类别筛选功能的可用 Node.js 服务器。您还将看到如何在不依赖 Mongoose 这类 ODM 的情况下,使用原生 MongoDB 驱动来实施类别校验。
您将学到什么
如何使用 Node.js、Express 和 MongoDB 构建 REST API,包括完整的 CRUD 端点
如何验证输入、强制类别约束,并安全高效地处理 ObjectId 值
如何组织并测试 API,然后将其连接到前端
如果您更倾向于克隆代码并边读边看,可在 GitHub 上找到本教程的完整代码。
前置条件
开始之前,您应已具备:
已安装 Node.js 18+(默认包含 npm )
本地运行的 MongoDB,或免费的 MongoDB Atlas 集群
Postman 或其他任意 HTTP 客户端
JavaScript 与 REST 基础知识
步骤 1:设置项目
接下来开始构建我们的简单任务管理器。第一步是在终端中运行以下命令来创建新文件夹并初始化一个 Node 项目:
mkdir task-manager-categories
cd task-manager-categories
npm init -y 技术支持
然后安装应用运行所需的四个运行时依赖:用于路由的 express 、官方 MongoDB 驱动、用于加载环境变量的 dotenv ,以及用于安全响应头的 helmet 。
npm install express mongodb dotenv helmet技术支持
在开发阶段,使用下面的命令将 nodemon 安装为开发依赖,这样文件变更后服务器会自动重载:
npm install --save-dev nodemon技术支持
接着,在 IDE 中打开项目文件夹,更新 package.json 中的 scripts ,如下所示。之后您可以用 npm start 启动服务器,或用 npm run dev 进入开发模式运行:
"scripts" : {
"start" : "node server.js" ,
"dev" : "nodemon server.js"
}
技术支持
下面是我们将逐步搭建的结构。各文件夹职责清晰: db/ 负责数据库连接, lib/ 放置辅助函数, middleware/ 存放 Express 中间件, routes/ 定义请求处理器。稍后我们会在 public/ 中加入一个小型前端。即便是小项目,这样的结构也能让脉络清晰,便于定位新代码应放何处。您可以现在就创建这些文件夹并边走边填充,也可以跳过这步,按后续步骤逐个添加文件:
task-manager-categories/
├── db/
│ └── connect.js
├── lib/
│ └── taskDocument.js
├── middleware/
│ └── parseObjectId.js
├── routes/
│ └── tasks.js
├── public/
│ ├── index.html
│ ├── styles.css
│ └── app.js
├── .env
├── .env.example
├── .gitignore
├── package.json
└── server.js技术支持
步骤 2:连接 MongoDB
为了存取任务,我们首先需要将应用连接到 MongoDB。这需要使用连接字符串,它告诉 MongoDB 驱动如何连接到您的数据库。与其将其硬编码在代码中,不如放到环境变量文件中。这样既能避免敏感信息进入代码库,也便于在不同环境间切换。
创建一个 .env.example 文件来记录所需变量,再创建一个 .env 文件填写您的实际值,如下所示:
MONGO_URI = mongodb://127.0.0.1:27017
DB_NAME = taskmanager
PORT = 3000 技术支持
现在我们已有连接字符串,可以用它来设置 MongoDB 连接。新建一个 db/connect.js 文件。在这里初始化 MongoDB 客户端,并让应用其他部分可以复用:
const { MongoClient } = require ( "mongodb" ) ;
let client;
let db;
async function connectDB ( ) {
if ( ! process. env. MONGO_URI ) {
throw new Error ( "MONGO_URI is not set. Check your .env file." ) ;
}
client = new MongoClient ( process. env. MONGO_URI , {
appName : "devrel-tutorial-javascript-crud-geeksforgeeks" ,
} ) ;
await client. connect ( ) ;
db = client. db ( process. env. DB_NAME || "taskmanager" ) ;
await db. collection ( "tasks" ) . createIndex ( { category : 1 } ) ;
console. log ( ` MongoDB connected (db: ${ db. databaseName} ) ` ) ;
}
function getTasksCollection ( ) {
if ( ! db) {
throw new Error ( "Database not initialized. Call connectDB() first." ) ;
}
return db. collection ( "tasks" ) ;
}
async function closeDB ( ) {
if ( client) await client. close ( ) ;
}
module. exports = { connectDB, getTasksCollection, closeDB } ;
技术支持
该文件设置了一个可在整个应用中复用的 MongoDB 单例客户端。您只需创建一次,因为驱动已在内部处理了连接池。如果每次请求都新建客户端,一开始看似可行,但很快就会产生性能问题。
步骤 3:定义任务结构与校验
此时应用已能连接到 MongoDB。接下来在开始保存数据之前,需要先定义任务的具体结构。
在这里使用原生 MongoDB 驱动会和以往有些不同:不像 Mongoose 那样有独立的 schema 文件。相反,“schema” 就是您插入到数据库中的对象形状。一开始可能显得有些松散,但这其实很有帮助,因为您更贴近 MongoDB 的实际工作方式,逻辑也不被抽象层遮蔽。
但我们仍需要校验。因此我们不将逻辑分散在各个路由中,而是集中在一个地方,让所有操作遵循同一规则。新建一个名为 lib/taskDocument.js 的文件,并加入以下代码:
class ValidationError extends Error {
constructor ( message ) {
super ( message) ;
this . name = 'ValidationError' ;
}
}
const ALLOWED_CATEGORIES = [ 'Work' , 'Personal' , 'Study' , 'Other' ] ;
function assertValidCategory ( category ) {
if ( ! ALLOWED_CATEGORIES . includes ( category) ) {
throw new ValidationError (
` category must be one of: ${ ALLOWED_CATEGORIES . join ( ', ' ) } `
) ;
}
}
function buildTaskDocument ( body = { } ) {
if ( ! body. title || typeof body. title !== 'string' || ! body. title. trim ( ) ) {
throw new ValidationError ( 'title is required and must be a non-empty string' ) ;
}
if ( body. category != null ) {
assertValidCategory ( body. category) ;
}
const now = new Date ( ) ;
return {
title : body. title. trim ( ) ,
description : typeof body. description === 'string' ? body. description. trim ( ) : '' ,
category : body. category != null ? body. category : 'Other' ,
completed : Boolean ( body. completed) ,
createdAt : now,
updatedAt : now
} ;
}
function buildTaskUpdate ( body = { } ) {
const updates = { } ;
if ( typeof body. title === 'string' && body. title. trim ( ) ) {
updates. title = body. title. trim ( ) ;
}
if ( typeof body. description === 'string' ) {
updates. description = body. description. trim ( ) ;
}
if ( body. category != null ) {
assertValidCategory ( body. category) ;
updates. category = body. category;
}
if ( body. completed != null ) {
updates. completed = Boolean ( body. completed) ;
}
if ( Object. keys ( updates) . length === 0 ) {
throw new ValidationError ( 'no valid fields provided for update' ) ;
}
updates. updatedAt = new Date ( ) ;
return updates;
}
module. exports = {
ALLOWED_CATEGORIES ,
buildTaskDocument,
buildTaskUpdate,
ValidationError
} ; 技术支持
该文件相当于写入数据库的守门人。所有创建或更新请求都会经过这里,从而在一个位置统一实施规则。
它确保任务的结构始终正确、类别一致,并避免在查询数据时出现意外。自定义的 ValidationError 还能将无效输入与真实的服务器问题区分开,让您的 API 能做出恰当响应。有了这层保障,其余部分就能保持简洁。各路由只需专注于自身职责,因为它们接收到的数据已经是有效的。接下来我们将把路由接起来,并开始把任务保存到 MongoDB。
步骤 4:处理 ObjectId 与请求校验
既然已经知道任务的结构,接下来处理引用任务的方式。每当路由包含 :id 参数时,它会以普通字符串形式传入;但 MongoDB 需要的是 ObjectId 。如果字符串格式错误,驱动会抛出不太友好的错误。我们不在每个路由重复处理,而是用中间件统一处理,这样所有端点行为一致。
为此,新建一个名为 middleware/parseObjectId.js 的文件,并加入以下内容:
const { ObjectId } = require ( 'mongodb' ) ;
function parseObjectId ( req, res, next ) {
const { id } = req. params;
if ( ! ObjectId. isValid ( id) ) {
return res. status ( 400 ) . json ( { error : 'invalid task id' } ) ;
}
req. taskId = new ObjectId ( id) ;
next ( ) ;
}
module. exports = parseObjectId;
技术支持
该中间件在路由处理器之前运行。它会校验 id 是否有效、将其转换为 ObjectId ,并附加到 req.taskId 。这样到达路由逻辑时,您始终在使用正确的 ObjectId ,而无效输入会被尽早以清晰的 400 响应拒绝。接下来我们会将它接入路由,把所有部分串联起来。
步骤 5:构建任务路由(CRUD + 筛选)
到这一步,大部分繁重工作已完成。我们定义了有效任务的样子,也处理了 ID 的解析与校验。这意味着路由处理器只需专注做一件事:与数据库交互。
现在创建名为 routes/tasks.js 的文件,并加入以下代码,来搭建实际的 API 路由:
const express = require ( 'express' ) ;
const { getTasksCollection } = require ( '../db/connect' ) ;
const {
ALLOWED_CATEGORIES ,
buildTaskDocument,
buildTaskUpdate,
ValidationError
} = require ( '../lib/taskDocument' ) ;
const parseObjectId = require ( '../middleware/parseObjectId' ) ;
const router = express. Router ( ) ;
router. post ( '/' , async ( req, res, next ) => {
try {
const doc = buildTaskDocument ( req. body) ;
const result = await getTasksCollection ( ) . insertOne ( doc) ;
res. status ( 201 ) . json ( { _id : result. insertedId, ... doc } ) ;
} catch ( err) {
if ( err instanceof ValidationError ) {
return res. status ( 400 ) . json ( { error : err. message } ) ;
}
next ( err) ;
}
} ) ;
router. get ( '/' , async ( req, res, next ) => {
try {
const { category } = req. query;
if ( category && ! ALLOWED_CATEGORIES . includes ( category) ) {
return res. status ( 400 ) . json ( {
error : ` category must be one of: ${ ALLOWED_CATEGORIES . join ( ', ' ) } `
} ) ;
}
const filter = category ? { category } : { } ;
const tasks = await getTasksCollection ( )
. find ( filter)
. sort ( { createdAt : - 1 } )
. toArray ( ) ;
res. json ( tasks) ;
} catch ( err) { next ( err) ; }
} ) ;
router. get ( '/:id' , parseObjectId, async ( req, res, next ) => {
try {
const task = await getTasksCollection ( ) . findOne ( { _id : req. taskId } ) ;
if ( ! task) return res. status ( 404 ) . json ( { error : 'task not found' } ) ;
res. json ( task) ;
} catch ( err) { next ( err) ; }
} ) ;
router. put ( '/:id' , parseObjectId, async ( req, res, next ) => {
try {
const updates = buildTaskUpdate ( req. body) ;
const result = await getTasksCollection ( ) . findOneAndUpdate (
{ _id : req. taskId } ,
{ $set : updates } ,
{ returnDocument : 'after' }
) ;
if ( ! result) return res. status ( 404 ) . json ( { error : 'task not found' } ) ;
res. json ( result) ;
} catch ( err) {
if ( err instanceof ValidationError ) {
return res. status ( 400 ) . json ( { error : err. message } ) ;
}
next ( err) ;
}
} ) ;
router. delete ( '/:id' , parseObjectId, async ( req, res, next ) => {
try {
const result = await getTasksCollection ( ) . deleteOne ( { _id : req. taskId } ) ;
if ( result. deletedCount === 0 ) {
return res. status ( 404 ) . json ( { error : 'task not found' } ) ;
}
res. status ( 204 ) . end ( ) ;
} catch ( err) { next ( err) ; }
} ) ;
module. exports = router;
技术支持
上面的每个路由都遵循同样的模式:从请求中获取输入,交给我们之前编写的辅助函数处理,再调用 MongoDB,并返回响应。由于校验与 ID 解析已就绪,这里的代码保持小而可预期。实际表现如下:
在插入前, POST 路由会使用 buildTaskDocument 构建新任务
GET 路由可按类别筛选,并按最新优先排序返回结果
PUT 路由使用 buildTaskUpdate ,从而安全一致地进行部分字段更新
DELETE 路由按已解析的 ObjectId 删除任务
此外,还有一些值得注意的细节:
find() 返回的是游标而非数组,因此我们在排序后再链式调用 .toArray() 来获取结果
findOneAndUpdate 配合 returnDocument: 'after' 能直接返回更新后的文档
返回合适的 HTTP 状态码:创建用 201 ,删除用 204 ,必要时返回 400 或 404
任何意外错误都会传给 next(err) ,从而在一个地方统一处理,而不是在每个路由中重复
步骤 6:在服务器中将所有部分连线
此时各个部件都已就位:有了校验、整洁的路由与可用的数据库连接。现在只需把它们连在一起并启动服务器。创建一个 server.js 文件,作为应用入口:
require ( 'dotenv' ) . config ( ) ;
const path = require ( 'path' ) ;
const express = require ( 'express' ) ;
const helmet = require ( 'helmet' ) ;
const { connectDB, closeDB } = require ( './db/connect' ) ;
const tasksRouter = require ( './routes/tasks' ) ;
if ( ! process. env. MONGO_URI ) {
console. error ( 'Fatal: MONGO_URI is not set. Copy .env.example to .env and fill it in.' ) ;
process. exit ( 1 ) ;
}
const PORT = Number ( process. env. PORT ) || 3000 ;
const app = express ( ) ;
app. use ( helmet ( ) ) ;
app. use ( express. json ( { limit : '100kb' } ) ) ;
app. use ( express. static ( path. join ( __dirname, 'public' ) ) ) ;
app. get ( '/health' , ( req, res ) => {
res. json ( { status : 'ok' , service : 'task-manager-with-categories' } ) ;
} ) ;
app. use ( '/tasks' , tasksRouter) ;
app. use ( ( req, res ) => {
res. status ( 404 ) . json ( { error : 'not found' } ) ;
} ) ;
app. use ( ( err, req, res, next ) => {
if ( err. type === 'entity.too.large' ) {
return res. status ( 413 ) . json ( { error : 'payload too large' } ) ;
}
if ( err. type === 'entity.parse.failed' ) {
return res. status ( 400 ) . json ( { error : 'invalid JSON body' } ) ;
}
console. error ( err) ;
res. status ( 500 ) . json ( { error : 'internal server error' } ) ;
} ) ;
async function start ( ) {
try {
await connectDB ( ) ;
const server = app. listen ( PORT , ( ) => {
console. log ( ` Server running on http://localhost: ${ PORT } ` ) ;
} ) ;
const shutdown = ( ) => {
console. log ( '\nShutting down gracefully...' ) ;
server. close ( async ( ) => {
await closeDB ( ) ;
process. exit ( 0 ) ;
} ) ;
setTimeout ( ( ) => process. exit ( 1 ) , 10_000 ) . unref ( ) ;
} ;
process. on ( 'SIGINT' , shutdown) ;
process. on ( 'SIGTERM' , shutdown) ;
} catch ( err) {
console. error ( 'Failed to start server:' , err) ;
process. exit ( 1 ) ;
}
}
if ( require. main === module) {
start ( ) ;
}
module. exports = { app, start } ;
技术支持
该文件以整洁的方式把所有内容串起来。它会在运行前检查环境变量是否已设置,使用 helmet 提供基础安全防护,并限制传入的 JSON 体积,避免误接收巨大的负载。它还会服务于 public/ 文件夹,这样前端可以与后端共存于同一应用,无需额外服务器。
所有路由都挂载在 /tasks 下,未命中的请求会返回干净的 404 。错误在一个地方统一处理,而不是在每个路由中重复,这让维护更容易。数据库连接会在服务器开始监听之前建立,因此不会在尚未就绪时接收请求;关闭流程也很优雅,能正确地关闭连接。
现在运行 npm run dev ,确认一切正常。如果配置正确,您会看到类似输出:
MongoDB connected ( db: taskmanager)
Server running on http://localhost:3000
技术支持
至此,一切已连通并运行!我们已经有一个完整可用的 API,接下来进行测试。
步骤 7:测试 API 端点
在构建 UI 之前,先逐个确认每个端点能单独工作。这能大大简化调试,因为您能快速判断问题来自 API 还是前端。我们将使用 Postman(或任意 HTTP 客户端),按顺序运行几次请求,每一步都基于上一步创建的数据继续进行。
创建任务: 发送一个包含标题、描述和类别的 POST 请求。如果成功,API 将返回 201 Created ,连同新任务及其生成的 _id 。
POST http://localhost:3000/tasks
Content-Type: application/json
{
"title" : "Write GeeksForGeeks article" ,
"description" : "First draft by Friday" ,
"category" : "Work"
}
技术支持
再创建几个: 用不同的请求体重复发送 POST 请求,以便有足够数据用于测试列表与筛选。完成后,您会有一个可查询的小数据集。
{ "title" : "Go for a run" , "category" : "Personal" }
{ "title" : "Read MongoDB docs" , "category" : "Study" }
{ "title" : "Buy groceries" }
技术支持
列出所有任务: 返回全部任务,并按最新优先排序。
GET http://localhost:3000/tasks技术支持
按类别筛选: 这将仅返回 Work 类别的任务。先前添加的索引能在数据增长时保持该查询的速度。
GET http://localhost:3000/tasks?category= Work技术支持
更新任务: 使用刚刚创建的某个任务的 _id (可从 POST 响应或列表端点复制)。这会返回更新后的任务。您只需发送想要更改的字段,因此部分更新很简单。
PUT http://localhost:3000/tasks/< paste-task-id-here>
Content-Type: application/json
{ "completed" : true }
技术支持
删除任务: 使用欲删除任务的 _id 。该请求返回 204 No Content 。若再次尝试获取该任务,会得到 404 并显示 "task not found" 。
DELETE http://localhost:3000/tasks/< paste-task-id-here> 技术支持
至此,您已确认所有路由均按预期运行。API 的职责已完成,接下来我们在其之上构建一个 UI。
步骤 8:添加一个与 API 交互的前端
Postman 很适合验证 API,但在实际网页上体验会更直观,因此我们为应用加入一些前端代码。
为避免在此粘贴大量 HTML 和 CSS,我已将代码放到 GitHub 仓库。前往那里,将 index.html 、 styles.css 和 favicon.svg 的内容复制到项目根目录的 public/ 文件夹。保存后访问 localhost:3000 。然后将下面的代码粘贴到 app.js 文件:
const form = document. getElementById ( 'task-form' ) ;
const titleEl = document. getElementById ( 'title' ) ;
const descEl = document. getElementById ( 'description' ) ;
const catEl = document. getElementById ( 'category' ) ;
const errEl = document. getElementById ( 'form-error' ) ;
const listEl = document. getElementById ( 'task-list' ) ;
const emptyEl = document. getElementById ( 'empty' ) ;
const filterEl = document. getElementById ( 'filter' ) ;
const countEl = document. getElementById ( 'task-count' ) ;
async function api ( method, path, body ) {
const res = await fetch ( path, {
method,
headers : body ? { 'Content-Type' : 'application/json' } : { } ,
body : body ? JSON . stringify ( body) : undefined
} ) ;
if ( res. status === 204 ) return null ;
const data = await res. json ( ) ;
if ( ! res. ok) throw new Error ( data. error || ` Request failed ( ${ res. status} ) ` ) ;
return data;
}
const fetchTasks = ( category ) =>
api ( 'GET' , '/tasks' + ( category ? ` ?category= ${ encodeURIComponent ( category) } ` : '' ) ) ;
const createTask = ( body ) => api ( 'POST' , '/tasks' , body) ;
const updateTask = ( id, body ) => api ( 'PUT' , ` /tasks/ ${ id} ` , body) ;
const deleteTask = ( id ) => api ( 'DELETE' , ` /tasks/ ${ id} ` ) ;
function updateCount ( tasks ) {
const active = tasks. filter ( ( t ) => ! t. completed) . length;
const total = tasks. length;
if ( total === 0 ) {
countEl. textContent = '' ;
return ;
}
const scope = filterEl. value ? ` in ${ filterEl. value} ` : '' ;
countEl. textContent = ` ${ total} tasks ${ scope} · ${ active} active ` ;
}
function renderTask ( task ) {
const li = document. createElement ( 'li' ) ;
li. className = 'task' + ( task. completed ? ' completed' : '' ) ;
const checkbox = document. createElement ( 'input' ) ;
checkbox. type = 'checkbox' ;
checkbox. checked = task. completed;
checkbox. addEventListener ( 'change' , ( ) =>
updateTask ( task. _id, { completed : checkbox. checked } ) . then ( refresh)
) ;
const title = document. createElement ( 'div' ) ;
title. textContent = task. title;
const badge = document. createElement ( 'span' ) ;
badge. textContent = task. category;
const del = document. createElement ( 'button' ) ;
del. textContent = 'Delete' ;
del. onclick = ( ) => deleteTask ( task. _id) . then ( refresh) ;
li. append ( checkbox, title, badge, del) ;
return li;
}
function render ( tasks ) {
listEl. innerHTML = '' ;
emptyEl. classList. toggle ( 'hidden' , tasks. length > 0 ) ;
for ( const task of tasks) {
listEl. appendChild ( renderTask ( task) ) ;
}
updateCount ( tasks) ;
}
async function refresh ( ) {
try {
const tasks = await fetchTasks ( filterEl. value) ;
render ( tasks) ;
} catch ( e) {
errEl. textContent = e. message;
}
}
form. addEventListener ( 'submit' , async ( e ) => {
e. preventDefault ( ) ;
errEl. textContent = '' ;
const title = titleEl. value. trim ( ) ;
if ( ! title) {
errEl. textContent = 'Title is required.' ;
return ;
}
try {
await createTask ( {
title,
description : descEl. value. trim ( ) ,
category : catEl. value
} ) ;
form. reset ( ) ;
await refresh ( ) ;
} catch ( e) {
errEl. textContent = e. message;
}
} ) ;
filterEl. addEventListener ( 'change' , refresh) ;
refresh ( ) ;
技术支持
这段 JavaScript 代码将一切与之前构建的 API 对接。每个函数都映射到一个路由,使前端易于理解。所有请求也都通过统一的 api() 助手,这样您只需在一个地方处理错误,而无需到处重复逻辑。
筛选通过传递形如 ?category=Work 的查询字符串来实现,这与您已在后端加入的逻辑直接对应。创建、更新或删除任务后,应用会再次获取最新列表,保持 UI 同步。任务计数虽是小细节,却能让应用在使用时更有反馈感。
现在使用 npm run dev 重启服务器,然后打开 http://localhost:3000 体验任务管理器。添加几个任务、更新它们、在类别间切换,并尝试删除。此时前后端已端到端联通并协同工作。
总结
恭喜!您已使用 Node.js、Express 和 MongoDB 从零构建了一个完整的任务管理器 API。您完成了数据校验、保持了路由简洁,并把所有部分连线成一个可用系统。更重要的是,您并未用抽象层遮蔽 MongoDB 的工作方式,而是贴近其本质。从这里出发,您可以继续打磨,让应用走向生产可用。
要点回顾
使用中间件并集中化校验,能让代码保持整洁与可预期。
原生 MongoDB 驱动在避免不必要抽象的同时,提供足够的可控性。
结构良好的 API 易于扩展为生产级应用。
一开始,用一个朴素的待办清单就很好:添加一些任务,完成后勾掉即可。但随着任务越来越多,使用体验会变得糟糕。一个能改善体验的做法是引入分类,这样每个任务都可以归属到 Work、Study、Personal 等类别,您也能按类别筛选列表。
在本教程中,您将学习如何使用 Node.js 和 MongoDB 构建一个可按类别组织任务的 REST API。完成后,您将拥有一个包含五个端点(创建、列表、获取、更新、删除)以及类别筛选功能的可用 Node.js 服务器。您还将看到如何在不依赖 Mongoose 这类 ODM 的情况下,使用原生 MongoDB 驱动来实施类别校验。
您将学到什么
如果您更倾向于克隆代码并边读边看,可在 GitHub 上找到本教程的完整代码。
前置条件
开始之前,您应已具备:
步骤 1:设置项目
接下来开始构建我们的简单任务管理器。第一步是在终端中运行以下命令来创建新文件夹并初始化一个 Node 项目:
然后安装应用运行所需的四个运行时依赖:用于路由的 express、官方 MongoDB 驱动、用于加载环境变量的 dotenv,以及用于安全响应头的 helmet。
在开发阶段,使用下面的命令将 nodemon 安装为开发依赖,这样文件变更后服务器会自动重载:
接着,在 IDE 中打开项目文件夹,更新 package.json 中的 scripts,如下所示。之后您可以用 npm start 启动服务器,或用 npm run dev 进入开发模式运行:
下面是我们将逐步搭建的结构。各文件夹职责清晰: db/ 负责数据库连接,lib/ 放置辅助函数,middleware/ 存放 Express 中间件,routes/ 定义请求处理器。稍后我们会在 public/ 中加入一个小型前端。即便是小项目,这样的结构也能让脉络清晰,便于定位新代码应放何处。您可以现在就创建这些文件夹并边走边填充,也可以跳过这步,按后续步骤逐个添加文件:
步骤 2:连接 MongoDB
为了存取任务,我们首先需要将应用连接到 MongoDB。这需要使用连接字符串,它告诉 MongoDB 驱动如何连接到您的数据库。与其将其硬编码在代码中,不如放到环境变量文件中。这样既能避免敏感信息进入代码库,也便于在不同环境间切换。
创建一个 .env.example 文件来记录所需变量,再创建一个 .env 文件填写您的实际值,如下所示:
现在我们已有连接字符串,可以用它来设置 MongoDB 连接。新建一个 db/connect.js 文件。在这里初始化 MongoDB 客户端,并让应用其他部分可以复用:
该文件设置了一个可在整个应用中复用的 MongoDB 单例客户端。您只需创建一次,因为驱动已在内部处理了连接池。如果每次请求都新建客户端,一开始看似可行,但很快就会产生性能问题。
步骤 3:定义任务结构与校验
此时应用已能连接到 MongoDB。接下来在开始保存数据之前,需要先定义任务的具体结构。
在这里使用原生 MongoDB 驱动会和以往有些不同:不像 Mongoose 那样有独立的 schema 文件。相反,“schema” 就是您插入到数据库中的对象形状。一开始可能显得有些松散,但这其实很有帮助,因为您更贴近 MongoDB 的实际工作方式,逻辑也不被抽象层遮蔽。
但我们仍需要校验。因此我们不将逻辑分散在各个路由中,而是集中在一个地方,让所有操作遵循同一规则。新建一个名为 lib/taskDocument.js 的文件,并加入以下代码:
该文件相当于写入数据库的守门人。所有创建或更新请求都会经过这里,从而在一个位置统一实施规则。
它确保任务的结构始终正确、类别一致,并避免在查询数据时出现意外。自定义的 ValidationError 还能将无效输入与真实的服务器问题区分开,让您的 API 能做出恰当响应。有了这层保障,其余部分就能保持简洁。各路由只需专注于自身职责,因为它们接收到的数据已经是有效的。接下来我们将把路由接起来,并开始把任务保存到 MongoDB。
步骤 4:处理 ObjectId 与请求校验
既然已经知道任务的结构,接下来处理引用任务的方式。每当路由包含 :id 参数时,它会以普通字符串形式传入;但 MongoDB 需要的是 ObjectId。如果字符串格式错误,驱动会抛出不太友好的错误。我们不在每个路由重复处理,而是用中间件统一处理,这样所有端点行为一致。
为此,新建一个名为 middleware/parseObjectId.js 的文件,并加入以下内容:
该中间件在路由处理器之前运行。它会校验 id 是否有效、将其转换为 ObjectId,并附加到 req.taskId。这样到达路由逻辑时,您始终在使用正确的 ObjectId,而无效输入会被尽早以清晰的 400 响应拒绝。接下来我们会将它接入路由,把所有部分串联起来。
步骤 5:构建任务路由(CRUD + 筛选)
到这一步,大部分繁重工作已完成。我们定义了有效任务的样子,也处理了 ID 的解析与校验。这意味着路由处理器只需专注做一件事:与数据库交互。
现在创建名为 routes/tasks.js 的文件,并加入以下代码,来搭建实际的 API 路由:
上面的每个路由都遵循同样的模式:从请求中获取输入,交给我们之前编写的辅助函数处理,再调用 MongoDB,并返回响应。由于校验与 ID 解析已就绪,这里的代码保持小而可预期。实际表现如下:
此外,还有一些值得注意的细节:
步骤 6:在服务器中将所有部分连线
此时各个部件都已就位:有了校验、整洁的路由与可用的数据库连接。现在只需把它们连在一起并启动服务器。创建一个 server.js 文件,作为应用入口:
该文件以整洁的方式把所有内容串起来。它会在运行前检查环境变量是否已设置,使用 helmet 提供基础安全防护,并限制传入的 JSON 体积,避免误接收巨大的负载。它还会服务于 public/ 文件夹,这样前端可以与后端共存于同一应用,无需额外服务器。
所有路由都挂载在 /tasks 下,未命中的请求会返回干净的 404。错误在一个地方统一处理,而不是在每个路由中重复,这让维护更容易。数据库连接会在服务器开始监听之前建立,因此不会在尚未就绪时接收请求;关闭流程也很优雅,能正确地关闭连接。
现在运行 npm run dev,确认一切正常。如果配置正确,您会看到类似输出:
至此,一切已连通并运行!我们已经有一个完整可用的 API,接下来进行测试。
步骤 7:测试 API 端点
在构建 UI 之前,先逐个确认每个端点能单独工作。这能大大简化调试,因为您能快速判断问题来自 API 还是前端。我们将使用 Postman(或任意 HTTP 客户端),按顺序运行几次请求,每一步都基于上一步创建的数据继续进行。
创建任务: 发送一个包含标题、描述和类别的 POST 请求。如果成功,API 将返回 201 Created,连同新任务及其生成的 _id。
再创建几个: 用不同的请求体重复发送 POST 请求,以便有足够数据用于测试列表与筛选。完成后,您会有一个可查询的小数据集。
列出所有任务: 返回全部任务,并按最新优先排序。
按类别筛选: 这将仅返回 Work 类别的任务。先前添加的索引能在数据增长时保持该查询的速度。
更新任务: 使用刚刚创建的某个任务的 _id(可从 POST 响应或列表端点复制)。这会返回更新后的任务。您只需发送想要更改的字段,因此部分更新很简单。
删除任务: 使用欲删除任务的 _id。该请求返回 204 No Content。若再次尝试获取该任务,会得到 404 并显示 "task not found"。
至此,您已确认所有路由均按预期运行。API 的职责已完成,接下来我们在其之上构建一个 UI。
步骤 8:添加一个与 API 交互的前端
Postman 很适合验证 API,但在实际网页上体验会更直观,因此我们为应用加入一些前端代码。
为避免在此粘贴大量 HTML 和 CSS,我已将代码放到 GitHub 仓库。前往那里,将 index.html、styles.css 和 favicon.svg 的内容复制到项目根目录的 public/ 文件夹。保存后访问 localhost:3000。然后将下面的代码粘贴到 app.js 文件:
这段 JavaScript 代码将一切与之前构建的 API 对接。每个函数都映射到一个路由,使前端易于理解。所有请求也都通过统一的 api() 助手,这样您只需在一个地方处理错误,而无需到处重复逻辑。
筛选通过传递形如 ?category=Work 的查询字符串来实现,这与您已在后端加入的逻辑直接对应。创建、更新或删除任务后,应用会再次获取最新列表,保持 UI 同步。任务计数虽是小细节,却能让应用在使用时更有反馈感。
现在使用 npm run dev 重启服务器,然后打开 http://localhost:3000 体验任务管理器。添加几个任务、更新它们、在类别间切换,并尝试删除。此时前后端已端到端联通并协同工作。
总结
恭喜!您已使用 Node.js、Express 和 MongoDB 从零构建了一个完整的任务管理器 API。您完成了数据校验、保持了路由简洁,并把所有部分连线成一个可用系统。更重要的是,您并未用抽象层遮蔽 MongoDB 的工作方式,而是贴近其本质。从这里出发,您可以继续打磨,让应用走向生产可用。
要点回顾