十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Odin Project 课程 Node.js/Express 库存管理应用(Inventory Application)项目实战指南

Odin Project 课程 Node.js/Express 库存管理应用(Inventory Application)项目实战指南 Odin Project 课程 Node.js/Express 库存管理应用Inventory Application项目实战指南【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum本篇指南围绕 The Odin ProjectTOP开源全栈课程中 Node.js/Express 路径的核心实战项目——库存管理应用Inventory Application展开。你将为一个虚构店铺杂货、汽车配件、乐器、毛绒玩具……任选其一从零构建一个完整的 Express PostgreSQL 全栈应用先完成数据建模与数据库设计再按 MVC 模式组织路由与控制器实现 Item 与 Category 两套实体的完整 CRUD最后通过脚本填充数据并部署上线。读完本篇你将能够独立规划一张带关联约束的数据库表结构把 Express 路由、控制器与中间件、EJS 视图、表单处理与校验以及 PostgreSQL 集成的知识串成一条可运行的完整链路。项目背景与目标这个项目是整个 Express 章节The section was humongous的收官之作用来检验你此前学过的所有技能。你需要为一家虚构店铺创建一个库存管理Inventory management应用业务类型完全由你决定——可以管理杂货、汽车配件、婴儿玩具、乐器、小马或者任何你能想到的商品。应用的核心要求是拥有类别Category与商品Item两类实体。用户进入首页后可以选择一个类别查看然后得到该类别下的全部商品列表。同时你必须为 Item 和 Category 都实现完整的 CRUDCreate、Read、Update、Delete能力任何访问者都可以创建、查看、更新或删除任意商品或类别。这意味着该项目会把你学过的全部知识一次性串联起来课程文档在本项目中的应用introduction_to_express.md搭建 Express 服务器、理解请求-响应链路与中间件routing.md用 Router 组织/categories、/items等路由组controllers.md按 MVC 模式编写控制器与错误处理中间件views.md用 EJS 模板引擎渲染服务端 HTMLforms_and_data_handling.md表单提交、express-validator 校验与转义using_postgresql.md通过 node-postgrespg持久化数据deployment.md部署到 PaaS 平台并填充生产数据库前置知识要求在动手之前请确保你已经掌握以下内容SQL 基础本项目及后续课程都会假设你理解 SQL 语法与概念。建议先完成数据库课程中的 databases_and_sql.md 和 project_sql_zoo.md。PostgreSQL 安装参考 installing_postgresql.md按你的操作系统选择安装指南Linux 安装指南 或 macOS 安装指南。PostgreSQL shellpsql是终端下的 PostgreSQL 前端用于交互式执行 SQL、创建和修改数据库结构。Express 基础至少完成过 project_basic_informational_site.md 以及 project_mini_message_board.md迷你留言板项目因为库存管理应用可以看作留言板项目在数据库与多实体方向上的升级版。任务一初始化 Express 项目与 PostgreSQL 数据库首先创建项目骨架。参考 introduction_to_express.md 的步骤npm init -y npm install express ejs pg express-validator接着创建课程中约定的目录结构inventory-app/ ├─ errors/ # 自定义错误类 ├─ controllers/ # 控制器MVC 的 C ├─ routes/ # 路由Router 实例 ├─ views/ # EJS 模板 ├─ db/ # 数据库连接与查询、种子脚本 ├─ public/ # 静态资源 ├─ app.js └─ package.json在app.js中完成基础配置参考 views.md 与 forms_and_data_handling.md// app.js const express require(express); const app express(); const path require(node:path); const indexRouter require(./routes/indexRouter); app.set(views, path.join(__dirname, views)); app.set(view engine, ejs); // 解析 application/x-www-form-urlencoded 表单数据到 req.body app.use(express.urlencoded({ extended: true })); // 静态资源目录 app.use(express.static(path.join(__dirname, public))); app.use(/, indexRouter); const PORT process.env.PORT || 3000; app.listen(PORT, (error) { if (error) { throw error; } console.log(Inventory app listening on port ${PORT}!); });端口与环境变量生产环境的端口通常由托管平台注入因此用process.env.PORT || 3000提供回退值比硬编码更稳妥。数据库连接信息同理详见后面的环境变量与安全小节。然后创建数据库。进入 PostgreSQL shell-- 查看已有数据库 \l -- 创建库存应用数据库 CREATE DATABASE inventory_app; -- 连接到该数据库 \c inventory_app连接成功后 psql 提示符应变为inventory_app#。任务二数据建模——表、字段与关系这是整个项目最关键的一步。在写任何代码之前先停下来把所有需要的数据表、字段以及表之间的关联关系写下来。一个合格的库存应用其实体之间必然存在关联relations与约束constraints你需要为你的应用把这些数据库细节想清楚。原作业给出了两个建模示例作为参考游戏管理应用存在 game游戏、genre类型、developer开发商三个实体。一款游戏可以有一个或多个开发商和类型反过来一个开发商可以开发多款游戏。这是典型的多对多many-to-many关系。宝可梦管理应用存在 pokemon宝可梦、trainer训练家、type属性三个实体。每只宝可梦必须归属于一个 type一对多而一个 trainer 可以拥有多只宝可梦一对多。对于库存应用最直接的建模方案是Category类别与 Item商品的一对多关系CREATE TABLE categories ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name VARCHAR ( 255 ) NOT NULL, description TEXT ); CREATE TABLE items ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name VARCHAR ( 255 ) NOT NULL, description TEXT, price NUMERIC(10, 2), category_id INTEGER REFERENCES categories(id) ON DELETE ... );关键设计决策主键GENERATED ALWAYS AS IDENTITY定义了一个标识列identity columnPostgreSQL 会自动为id生成值默认从 1 开始每次递增 1并隐式创建同名的 sequence 对象如items_id_seq来追踪下一个要使用的值。外键与级联策略category_id INTEGER REFERENCES categories(id)建立了商品与类别的关联。这里的ON DELETE策略直接决定了后面删除功能的设计——这正是任务六要专门讨论的问题。约束NOT NULL保证字段必填NUMERIC(10, 2)之类的类型约束保证价格数据格式正确。你可以用\d验证表是否创建成功用\d categories查看表结构。如果你的业务需要更丰富的建模比如一个商品属于多个类别可以在实体之间加入关联表join table实现多对多关系——库存应用并不限制你只能有一对多。任务三规划路由与控制器参考 routing.md用Router把路由按实体分组每个 Router 挂载在独立路径下// app.js 中挂载路由 app.use(/categories, categoriesRouter); app.use(/items, itemsRouter); app.use(/, indexRouter);以商品为例路由清单应覆盖全部 CRUD 动作。控制器命名遵循其绑定路由的约定getSomething、createSomething、deleteSomething等参考 controllers.md// routes/itemsRouter.js const { Router } require(express); const itemsController require(../controllers/itemsController); const itemsRouter Router(); // READ itemsRouter.get(/, itemsController.itemsListGet); // 商品列表 itemsRouter.get(/:itemId, itemsController.itemDetailGet); // 商品详情 itemsRouter.get(/create, itemsController.itemCreateGet); // 新建表单 itemsRouter.get(/:itemId/update, itemsController.itemUpdateGet); // 编辑表单 // CREATE / UPDATE / DELETE破坏性动作需保护见加分项 itemsRouter.post(/create, itemsController.itemCreatePost); itemsRouter.post(/:itemId/update, itemsController.itemUpdatePost); itemsRouter.post(/:itemId/delete, itemsController.itemDeletePost); module.exports itemsRouter;要点回顾路由顺序很重要/:itemId这类带参数的路由要小心放置避免与/create等静态路径冲突Express 按定义顺序匹配第一个符合条件的路由参考 routing.md 中的 Order matters 说明。路径参数:itemId会让 Express 自动把值填入req.params.itemId控制器中通过解构const { itemId } req.params取出。删除使用 POST 而非 GET破坏性操作不能通过浏览器地址栏直接触发因此删除一律用router.post()配合表单提交这同时也是加分项保护破坏性操作的前提。控制器示例参考 using_postgresql.md 的查询封装方式// controllers/itemsController.js const db require(../db/queries); async function itemsListGet(req, res) { const items await db.getAllItems(); res.render(index, { title: Inventory, items: items }); } async function itemDetailGet(req, res) { const { itemId } req.params; const item await db.getItemById(itemId); if (!item) { throw new CustomNotFoundError(Item not found); } res.render(itemDetail, { item: item }); }关于错误处理可以把 controllers.md 中介绍的自定义错误类继承Error、携带statusCode属性与四参数错误中间件结合起来在app.js末尾统一兜底// app.js 末尾必须是四个参数才会被 Express 识别为错误中间件 app.use((err, req, res, next) { console.error(err); res.status(err.statusCode || 500).send(err.message); });任务四创建全部 READ 视图视图是应用面向用户的部分。本项目使用 EJS 模板引擎参考 views.md在views目录下创建模板通过res.render(模板名, { ...locals })渲染。至少需要以下 READ 视图首页/类别列表列出所有类别用户点击进入某个类别。类别详情view category展示该类别下的所有商品列表。商品详情view item展示单个商品的完整信息。EJS 语法要点% %执行 JavaScript循环、条件% %输出转义后的值%- %输出原始未转义HTML——用于include时避免双重转义。res.render(index, { items: items })传入的变量会挂在locals对象上模板里可以直接用items或locals.items访问。公共组件导航栏、页脚用%- include(partials/navbar, { ...data }) %复用。在类别详情中遍历商品列表的典型写法% if (locals.items items.length 0) { % ul % items.forEach((item) { % li a href/items/% item.id %% item.name %/a -- % item.price % /li % }); % /ul % } else { % pThis category has no items yet./p % } %任务五表单与 Create/Update 控制器表单结构HTML 表单通过action提交目标 URL与methodHTTP 方法与服务器通信参考 forms_and_data_handling.md。创建/更新商品时使用POST因为 POST 会把数据放在请求体而不是 URL 里不会出现在服务器日志中!-- views/itemForm.ejs -- form action/items/create methodPOST label fornameName:/label input typetext namename idname required label forpricePrice:/label input typenumber step0.01 nameprice idprice label forcategoryIdCategory:/label select namecategoryId idcategoryId % categories.forEach((category) { % option value% category.id %% category.name %/option % }); % /select button typesubmitSave/button /form注意input的name属性决定该字段在服务器端req.body中的键名。表单必须以express.urlencoded({ extended: true })中间件解析后才能通过req.body读取。校验与清洗express-validator在数据进入数据库之前必须同时做两件事校验validation——确保输入符合指定标准必填、格式正确清洗sanitization——清理用户输入中的潜在恶意字符。本项目使用express-validator库完成这两步const { body, validationResult, matchedData } require(express-validator); const validateItem [ body(name) .trim() .notEmpty().withMessage(Item name is required.) .isLength({ min: 1, max: 100 }).withMessage(Name must be between 1 and 100 characters.), body(price) .optional({ values: falsy }) .isFloat({ min: 0 }).withMessage(Price must be a non-negative number.), body(categoryId) .notEmpty().withMessage(A category is required.) .isInt().withMessage(Category must be a valid ID.), ];.trim()去掉首尾空白.optional({ values: falsy })表示只有非假值非undefined、null、false、0、空串才继续校验。校验链可以叠加每条规则都有独立的.withMessage()错误信息。控制器把校验中间件数组作为路由处理函数传入通过validationResult(req)取出错误exports.itemCreatePost [ validateItem, async (req, res) { const errors validationResult(req); if (!errors.isEmpty()) { return res.status(400).render(itemForm, { title: Create item, categories: await db.getAllCategories(), item: req.body, // 回填用户已输入的内容 errors: errors.array(), }); } const itemData matchedData(req); // 只取通过校验且经过清洗的数据 await db.createItem(itemData); res.redirect(/items); } ];Post/Redirect/GetPRG模式处理完 POST 后一律res.redirect()到 GET 页面防止用户刷新时重复提交产生重复 POST 请求。错误视图可复用 forms_and_data_handling.md 中的partials/errors.ejs模式% if (locals.errors) { % ul % errors.forEach((error) { % li% error.msg %/li % }); % /ul % } %更新动作更新的 GET 路由负责把现有记录的值回填到表单value% item.name %POST 路由在通过校验后调用db.updateItem(id, data)并重定向。结构与创建几乎一致区别只是多了一步按req.params.id取原有记录。任务六设计删除功能删除是 CRUD 中最需要动脑的一环原作业明确要求你想清楚一个问题尝试删除一个还包含商品的类别时会发生什么可选策略包括级联删除CASCADE连同该类别下的所有商品一起删除。适合商品离开类别就毫无意义的场景但数据丢失风险最大。置空SET NULL只删除类别把商品的category_id置为 NULL。要求外键列允许 NULL且商品可以在无类别状态下存在。拒绝删除RESTRICT / NO ACTION如果类别下还有商品就拒绝删除提示用户先处理这些商品。这是对数据完整性最友好的选择。这个行为取决于你的应用需求没有标准答案。实现方式有两种层面数据库层在外键约束中写明ON DELETE CASCADE/ON DELETE SET NULL/ON DELETE RESTRICT由 PostgreSQL 强制执行。应用层在删除控制器中先查询关联商品数量再决定行为。例如选择拒绝删除async function categoryDeletePost(req, res) { const { categoryId } req.params; const itemCount await db.countItemsInCategory(categoryId); if (itemCount 0) { // 渲染确认页面向用户说明原因 return res.status(400).render(categoryDelete, { category: await db.getCategoryById(categoryId), itemCount: itemCount, error: Cannot delete a category that still contains items., }); } await db.deleteCategory(categoryId); res.redirect(/categories); }任务七用脚本填充数据库手工建表、逐条插入数据非常繁琐。参考 using_postgresql.md用 Node 写一个一次性的种子脚本db/populatedb.js#! /usr/bin/env node const { Client } require(pg); const SQL CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name VARCHAR ( 255 ) NOT NULL ); CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name VARCHAR ( 255 ) NOT NULL, category_id INTEGER REFERENCES categories(id) ON DELETE RESTRICT ); INSERT INTO categories (name) VALUES (Groceries), (Tools); INSERT INTO items (name, category_id) VALUES (Organic Apples, 1), (Hammer, 2); ; async function main() { console.log(seeding...); const client new Client({ connectionString: process.argv[2], // 通过命令行参数传入数据库连接 }); await client.connect(); await client.query(SQL); await client.end(); console.log(done); } main();本地库 vs 生产库脚本设计成只运行一次且连接信息从process.argv命令行参数读取而不是硬编码。这样同一个脚本既能填充本地库也能在部署后从你的机器上填充生产库# 填充本地数据库 node db/populatedb.js postgresql://role_name:role_passwordlocalhost:5432/inventory_app # 部署应用与数据库后填充生产数据库只需运行一次 node db/populatedb.js production-db-url另一种常见做法是把脚本注册进package.json的scripts字段例如db:populate: node db/populatedb.js。数据库查询的封装在与 Express 集成时通过pg的Pool建立连接池Pool 适合 Web 服务器场景因为连接可复用一次性脚本则用Client更合适// db/pool.js const { Pool } require(pg); module.exports new Pool({ connectionString: process.env.DATABASE_URL, // 从环境变量读取见下一节 });// db/queries.js const pool require(./pool); async function getAllItems() { const { rows } await pool.query(SELECT * FROM items); return rows; } async function createItem({ name, price, categoryId }) { // $1 参数化查询防止 SQL 注入绝不能用字符串拼接用户输入 await pool.query( INSERT INTO items (name, price, category_id) VALUES ($1, $2, $3), [name, price, categoryId] ); } module.exports { getAllItems, createItem };SQL 注入防护pg的查询参数化如$1是防注入的关键。直接把用户输入拼进 SQLVALUES ( username )会让恶意输入如sike); DROP TABLE usernames; --有可乘之机。环境变量与连接信息安全数据库连接信息用户名、密码、连接串属于敏感数据绝不能硬编码进源码并提交到仓库。参考 environment_variables.md在项目根目录创建.env文件NAMEVALUE格式并务必加入.gitignore。代码中通过process.env.DATABASE_URL读取Node 内置支持用--env-file.env加载或调用process.loadEnvFile()。部署时.env不会进入仓库需要在托管平台一般通过其 Web 界面另行配置环境变量值。生产环境不要依赖--env-file加载文件不存在会抛错可改用--env-file-if-exists或对loadEnvFile做错误处理。文档化你的环境变量在 README 中说明需要哪些变量并提供.env.sample示例文件供协作者复制改名。示例.envDATABASE_URLpostgresql://role_name:role_passwordlocalhost:5432/inventory_app PORT3000任务八部署上线动态网站有服务器端应用与数据库无法用 GitHub Pages 这类静态托管方案部署需要选择支持 Node.js 服务器与数据库的托管平台。课程推荐的是平台即服务PaaS提供商详见 deployment.mdRailway可同时部署服务器与数据库按用量付费链接 GitHub 仓库即可部署。Render可部署服务器与数据库免费额度约 750 小时/月数据库按最低规格单独计费。Neon / Aiven仅提供数据库服务含 PostgreSQL免费额度下可托管主数据库。部署时的注意事项检查构建日志部署报错时先看构建日志定位错误点多数问题源于应用与托管平台要求的配置不一致。Node 版本兼容性不同平台默认 Node 版本可能不同必要时在package.json的engines字段声明兼容版本。部署后的 500 错误查看应用日志会实时记录请求与数据库查询在浏览器刷新复现错误从日志定位原因。填充生产数据库应用与数据库部署完成后用前文的方式从本机运行一次node db/populatedb.js production-db-url为生产库添加假数据。回退策略如果某次部署后出问题用git log查看最近改动、git checkout回退到上一个可用版本再逐步恢复改动。加分项用管理员密码保护破坏性操作后续课程会专门讲带安全密码的用户系统但现在你不希望任何访问者都能删除、编辑库存。原作业给出的临时方案是让用户在执行删除/更新等破坏性操作前输入一个秘密的管理员密码来确认操作。实现思路在删除/更新表单中增加一个密码输入框form action/items/% item.id %/delete methodPOST label foradminPasswordAdmin password:/label input typepassword nameadminPassword idadminPassword required button typesubmitConfirm Delete/button /form在控制器中校验密码这里仅为占位示范实际生产代码应把密码放入环境变量切勿硬编码到源码并提交exports.itemDeletePost async (req, res) { const { itemId } req.params; const { adminPassword } req.body; if (adminPassword ! process.env.ADMIN_PASSWORD) { return res.status(403).render(itemDelete, { item: await db.getItemById(itemId), error: Incorrect admin password., }); } await db.deleteItem(itemId); res.redirect(/items); };密码比对失败返回403 Forbidden并停留在确认页成功则执行删除并重定向。这只是临时保护方案重点在于让你体会到破坏性操作必须经过身份授权的设计思想为后续学习真正的用户认证sessions、cookies、密码哈希做铺垫。检查清单与验收标准完成项目后用以下清单自查Express 项目初始化完成node --watch app.js可正常启动并监听端口。PostgreSQL 数据库创建成功类别与商品表及外键关系符合建模文档。首页能展示全部类别点击类别进入后能看到该类别下的商品列表。商品与类别的 Create、Read、Update、Delete 全部可用表单校验与错误提示正常。删除含商品的类别时行为符合你预先设计的策略且不会破坏数据完整性。db/populatedb.js脚本能从命令行参数接收连接串并成功填充本地库。连接信息全部来自环境变量.env已加入.gitignore。应用已部署到 PaaS 平台生产库已填充数据访问线上地址一切正常。加分项删除/更新操作需要管理员密码才能执行。完成以上步骤你就拥有了一个数据库持久化、具备完整 CRUD、经过校验清洗且已部署上线的小型全栈应用——这正是 Express 章节所有知识点的最佳检验方式。如果你在部署或开发中遇到问题也可以回到 deployment.md 的调试与排障部分按步骤定位。【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表