使用 Node 和 MySQL 构建简单的 CRUD 应用程序





0/5 (0投票)
在本帖中,您将学习如何构建一个基本的 CRUD(创建、读取、更新、删除)应用程序并使用 Okta 保护该应用程序。
NodeJS + Express 是构建 API 和后端服务的流行技术栈。通常需要后端数据库。在企业和个人项目中,有几种流行的关系型数据库。MySQL 在 2000 年代初 PHP 兴起时开始流行,如今——在其首次发布 20 多年后——它被广泛的技术栈所使用。
在本帖中,您将学习如何构建一个基本的 CRUD(创建、读取、更新、删除)应用程序并使用 Okta 保护该应用程序。您将创建一个简单的 Yelp 式后端来评价餐厅,名为“FeedMeWell”。每家餐厅都会有一个它提供的菜肴列表,所有注册用户都可以评价餐厅。然后,系统将根据评分计算每家餐厅的平均评分。
请注意,您不应该在空腹时阅读这篇博文!
- NodeJS 版本 8+(尽管这些说明应适用于任何版本)
- docker 和 docker-compose(您将用于运行 MySQL 服务器的轻量级虚拟 Linux 机)
- 免费的 Okta 开发者帐户,方便进行身份验证
这就是您设置和运行项目所需的一切!
在 npm install
阶段,您将安装以下依赖项
- TypeScript(JavaScript 的类型化超集)
- TypeORM(TypeScript 和 JavaScript 的对象关系映射器)
- Express(Node 的快速、非主观、极简 Web 框架)
初始化 Node + Express 项目并添加依赖项
打开终端,然后 `cd` 进入您想创建项目的目录。
例如
mkdir feed-me-well cd feed-me-well npm init --yes # This will create a package.json file used for dependency management. npm install @okta/jwt-verifier@1.0.0 @okta/oidc-middleware@2.0.0 body-parser@1.19.0 dotenv@8.0.0 express@4.17.1 express-session@1.16.2 express-with-json@0.0.6 glob@7.1.4 mysql@2.17.1 reflect-metadata@0.1.13 ts-node@8.3.0 typeorm@0.2.18 typescript@3.5.3 npx ts-typie # This will add TypeScript types for all the dependencies that manage their typings separately in `https://github.com/DefinitelyTyped/DefinitelyTyped` repo)
创建虚拟 MySQL Docker 计算机
为了避免用所有依赖项污染您的开发机器,请使用 docker-compose
为开发目的设置数据库。
为此,请创建一个 `docker-compose.yml` 文件,该文件定义了您的项目所需的虚拟机类型。此项目仅需要一个用于 MySQL 的 docker 容器。
version: '3.1' services: okta-feed-me-well-db: container_name: okta-feed-me-well-db image: mysql command: --default-authentication-plugin=mysql_native_password restart: always ports: - 3389:3306 environment: MYSQL_ROOT_PASSWORD: example MYSQL_DATABASE: okta-feed-me-well-db MYSQL_USER: user MYSQL_PASSWORD: password
请注意,如果您从此 docker 文件创建生产 MySQL 环境,则应使用更安全的密码。
创建该 `docker-compose.yml` 文件后,您可以通过在项目根目录中运行以下命令来启动虚拟机
docker-compose up -d
根据您的互联网连接速度,首次运行可能需要一些时间来下载 mysql
docker 镜像。
之后的所有重新运行都会快得多。
定义应用程序的运行环境
尽管有关数据库的所有信息已存在于 `docker-compose.yml` 文件中,但现在您需要将该信息提供给服务器。
为此,请在项目根目录中创建一个 `.env` 文件
DB_PORT=3389 DB_USERNAME=user DB_PASSWORD=password DB_DATABASE=okta-feed-me-well-db
现在您需要告诉 TypeORM 如何连接到您刚刚创建的 MySQL 数据库。为此,请创建一个 `ormconfig.js` 文件
require('dotenv/config'); // load everything from `.env` file into the `process.env` variable
const { DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE } = process.env;
module.exports = [{
name: 'default',
type: 'mysql',
host: 'localhost',
port: DB_PORT,
username: DB_USERNAME,
password: DB_PASSWORD,
database: DB_DATABASE,
synchronize: true,
entities: [
"src/models/*.ts"
],
subscribers: [
"src/subscribers/*.ts"
],
migrations: [
"src/migrations/*.ts"
],
cli: {
entitiesDir: "src/models",
migrationsDir: "src/migrations",
subscribersDir: "src/subscribers"
}
}];
创建 Node.js 入口文件
后端入口点是 `server.js`,因此您可以使用 npm start
命令运行服务器。
该文件将从 `.env` 加载所有环境变量到 `process.env` 中,并为动态转译设置项目。
这就是 `server.js` 的样子
require('dotenv/config');
require('reflect-metadata');
require('ts-node/register');
require('./src/bootstrap.ts')
.bootstrap()
.catch(console.error);
文件 `./src/bootstrap.ts` 引导服务器。为了使此过程成为可能,您必须在项目的根目录中创建 `tsconfig.json`
{ "compilerOptions": { "module": "commonjs", "esModuleInterop": true, "target": "es6", "noImplicitAny": false, "moduleResolution": "node", "sourceMap": true, "emitDecoratorMetadata": true, "experimentalDecorators": true } }
为您的 Node.js 应用添加 Okta 用户身份验证
Okta 是一项免费的 API 服务,可让您快速轻松地创建用户、处理用户身份验证、授权、多因素身份验证等。通过使用 Okta,您可以避免自己编写耗时且可能不安全的身份验证逻辑。
注册一个永久免费的开发者帐户,然后继续以下操作。
创建 Okta 帐户并登录 Okta 控制台后,点击“Applications”(应用程序)菜单项。然后点击“Add Application”(添加应用程序)。在应用程序创建向导中,选择“Web”,然后点击“Next”(下一步)。
在“Application Settings”(应用程序设置)屏幕上,为您的应用程序命名,并复制以下应用程序设置
完成后,向下滚动并查看“Client Credentials”(客户端凭据)。您很快就需要这些信息来集成您的 Web 应用程序与 Okta。这些设置(您的 Client ID 和 Secret)是您应用程序的 OpenID Connect 凭据。
重新打开您的 `.env` 文件并添加这些值
OKTA_ORG_URL=https://{yourOktaDomain} OKTA_CLIENT_ID={yourClientId} OKTA_CLIENT_SECRET={yourClientSecret} APP_SECRET=secret
与 Okta 的身份验证服务进行交互
为了提供有关发出请求的用户的相关信息并保护服务器免受不必要的请求,我们将创建两个 Express 中间件来将此任务委托给 Okta 的身份验证服务。
从 `src/services/okta.ts` 文件中导出这些中间件(`initializeAuthentication` 和 `authenticateUser`)
import session from 'express-session'; import express from 'express'; import { ExpressOIDC } from '@okta/oidc-middleware'; import OktaJwtVerifier from '@okta/jwt-verifier'; import { JsonErrorResponse } from 'express-with-json'; // import { assertUser } from './user'; // we're going to need this import later const issuer = `${process.env.OKTA_ORG_URL}/oauth2/default`; export function initializeAuthentication(app: express.Application, port: number) { const oidc = new ExpressOIDC({ issuer, client_id: process.env.OKTA_CLIENT_ID, client_secret: process.env.OKTA_CLIENT_SECRET, appBaseUrl: process.env.APP_BASE_URL || `https://:${port}`, scope: 'openid profile' }); app.use(session({ secret: process.env.APP_SECRET, resave: true, saveUninitialized: false })); app.use(oidc.router); app.get('/', oidc.ensureAuthenticated(), (req: any, res) => { res.send(req.userContext.tokens.access_token); }); return oidc; } const oktaJwtVerifier = new OktaJwtVerifier({ issuer, clientId: process.env.OKTA_CLIENT_ID }); export async function authenticateUser(req: express.Request) { const { authorization } = req.headers; if (!authorization) { return; } const [authType, token] = authorization.split(' '); if (authType !== 'Bearer') { throw new JsonErrorResponse({ error: 'Expected a Bearer token' }, { statusCode: 400 }); } const { claims: { sub } } = await oktaJwtVerifier.verifyAccessToken(token, 'api://default'); // req.user = await assertUser(sub); // we're going to use this line as soon as we define User model } export async function requireUser(req: express.Request) { if (!req.user) { throw new JsonErrorResponse({ error: 'You must send an Authorization header' }, { statusCode: 400 }); } }
引导您的 Express 服务器
身份验证就绪后,您现在可以引导服务器了。引导逻辑包含
- 一个加载项目所有控制器功能的函数
- 一个通用的错误处理程序
- 与 `MySQL` 数据库的持久连接
- 一个 Express 服务器实例
- 一个用于处理 TypeORM 的“EntityNotFound”异常的处理器
最后一个有助于避免在每个请求处理程序中处理 `EntityNotFound` 情况。
为了实现这一切,请创建一个名为 `src/bootstrap.ts` 的新文件
import { createConnection } from 'typeorm'; import express from 'express'; import withJson from 'express-with-json' import glob from 'glob'; import path from 'path'; import bodyParser from 'body-parser'; import { EntityNotFoundError } from 'typeorm/error/EntityNotFoundError'; import { authenticateUser, initializeAuthentication } from './services/okta'; const port = 3000; function findAllControllers() { return glob .sync(path.join(__dirname, 'controllers/*'), { absolute: true }) .map(controllerPath => require(controllerPath).default) .filter(applyController => applyController); } function errorHandler(error, req, res, next) { if (!error) { return next(); } if (error) { res.status(500); res.json({ error: error.message }); } console.error(error); } export function entityNotFoundErrorHandler(error, req, res, next) { if (!(error instanceof EntityNotFoundError)) { return next(error); } res.status(401); res.json({ error: 'Not Found' }); } export async function bootstrap() { await createConnection(); const app = withJson(express()); app.useAsync(authenticateUser); app.use(bodyParser.json()); initializeAuthentication(app, port); findAllControllers().map(applyController => applyController(app)); app.use(entityNotFoundErrorHandler); app.use(errorHandler); app.listen(port, () => console.log('Listening on port', port)); return app; }
创建 `bootstrap.ts` 文件后,您现在可以像这样运行服务器
npm start
在浏览器中打开 `https://:3000`,使用 Okta 进行身份验证,然后接收一个 JWT 令牌以用于仍未存在的“FeedMeWell”应用程序的 API 进行身份验证。
定义数据库模型关系
每个好的应用程序都必须有清晰的数据库模型。根据我在本帖开头为 `FeedMeWell` 应用程序设定的要求,项目实体之间的关系如下
用户拥有多个餐厅 <=> 餐厅有一个用户所有者
用户拥有多个评分 <=> 评分有一个用户作者
餐厅拥有多个评分 <=> 评分属于一个餐厅
餐厅拥有多个菜肴 <=> 菜肴属于一个餐厅
根据实体关系为 MySQL 数据库创建模型
您需要一个 User 表的模型。它将只包含最少量的数据,因为所有用户信息都由 Okta 存储。
创建一个 `src/models/user.ts` 文件
import { Column, Entity, OneToMany, PrimaryColumn } from 'typeorm'; import { Restaurant } from './restaurant'; import { Rating } from './rating'; @Entity() export class User { @PrimaryColumn({ generated: 'increment' }) id: number; @Column({ unique: true }) oktaUserId: string; @OneToMany(() => Restaurant, restaurant => restaurant.creator) restaurants: Promise<Array<Restaurant>>; @OneToMany(() => Rating, rating => rating.creator) ratings: Promise<Array<Rating>>; }
注意对仍然缺失的 `./restaurant` 和 `./rating` 模型的引用?您也应该创建它们。
这是您的新 `Restaurant` 模型 `src/models/restaurant.ts` 的内容
import { Entity, Column, OneToMany, PrimaryColumn, ManyToOne } from 'typeorm'; import { FoodDish } from './food-dish'; import { User } from './user'; import { Rating } from './rating'; @Entity() export class Restaurant { @PrimaryColumn({ generated: 'increment' }) id: number; @Column({ unique: true }) name: string; @Column() description: string; @Column() address: string; @OneToMany(() => FoodDish, foodDish => foodDish.restaurant) foodDishes: Promise<Array<FoodDish>>; @Column() creatorId: number; @ManyToOne(() => User, user => user.restaurants) creator: Promise<User>; @OneToMany(() => Rating, rating => rating.restaurant) ratings: Promise<Array<Restaurant>>; @Column({ nullable: true }) averageRating: number; }
而 `Rating` 表的模型在 `src/models/rating.ts` 文件中应该看起来像这样
import { Column, Entity, ManyToOne, OneToMany, PrimaryColumn, Unique } from 'typeorm'; import { Restaurant } from './restaurant'; import { User } from './user'; @Entity() @Unique(['restaurantId', 'creatorId']) export class Rating { @PrimaryColumn({ generated: 'increment' }) id: number; @Column({ type: 'integer' }) rating: number; @Column() text: string; @Column() restaurantId: number; @ManyToOne(() => Restaurant, restaurant => restaurant.ratings) restaurant: Promise<Restaurant>; @Column() creatorId: number; @ManyToOne(() => User, user => user.ratings) creator: Promise<User>; }
最后一个模型存储了餐厅提供的所有美食的信息。
创建 `src/models/food-dish.ts` 文件
import {Entity, Column, ManyToOne, PrimaryColumn, Unique} from 'typeorm'; import { Restaurant } from './restaurant'; @Entity() @Unique(['restaurantId', 'name']) export class FoodDish { @PrimaryColumn({ generated: 'increment' }) id: number; @Column() name: string; @Column() description: string; @Column({ type: 'integer' }) priceInCents: number; @Column() restaurantId: number; @ManyToOne(() => Restaurant, restaurant => restaurant.foodDishes) restaurant: Promise<Restaurant>; }
就 MySQL 而言,就这些了!
现在您只需要实现缺失的 `assertUser` 函数。通过创建一个 `src/services/user.ts` 文件来完成此操作
import { getManager } from 'typeorm'; import { User } from '../models/user'; export async function assertUser(oktaUserId: string) { const manager = getManager(); const existingUser = await manager.findOne(User, { where: { oktaUserId } }); if (existingUser) { return existingUser; } const user = new User(); user.oktaUserId = oktaUserId; return await manager.save(user); }
此函数用于同步 Okta 和 MySQL 数据库之间的用户数据。创建文件后,请确保在 `src/services/user.ts` 文件中取消注释包含 `assertUser` 函数的这两行
... import { assertUser } from './user'; ... ... req.user = await assertUser(sub); ... ...
此更改将已验证用户的相关信息附加到请求对象。
创建模型并定义它们之间的关系后,TypeORM 将确保所有这些 MySQL 表在下次运行时被创建。
最后,终止正在运行的服务器,然后使用 npm start
命令重新启动它。
将 Node 应用连接到 MySQL 数据库
重新启动服务器后,通过在 docker 容器中运行 mysql
命令来检查数据库
docker-compose exec okta-feed-me-well-db mysql -u user -p okta-feed-me-well-db -p -e "select * from food_dish" docker-compose exec okta-feed-me-well-db mysql -u user -p okta-feed-me-well-db -p -e "select * from rating" docker-compose exec okta-feed-me-well-db mysql -u user -p okta-feed-me-well-db -p -e "select * from restaurant" docker-compose exec okta-feed-me-well-db mysql -u user -p okta-feed-me-well-db -p -e "select * from user"
当程序询问密码时,别忘了输入“password”。
为您的 Node + MySQL 应用添加 CRUD 功能
(C)创建、(R)读取、(U)更新和(D)删除是每个 Web 应用程序最基本的功能。让我们为 restaurant 资源添加所有这些功能。
创建一个名为 `src/controllers/restaurants.ts` 的文件
import express from 'express'; import { getManager } from 'typeorm'; import { Restaurant } from '../models/restaurant'; import { requireUser } from '../services/okta'; import { IExpressWithJson, JsonErrorResponse } from 'express-with-json/dist'; import { User } from '../models/user'; function isRestaurantCreatedBy(restaurant: Restaurant, user: User) { return restaurant.creatorId === user.id; } export async function createRestaurant(req: express.Request) { const { address, description, name, } = req.body; const restaurant = new Restaurant(); restaurant.creatorId = req.user.id; restaurant.address = address; restaurant.description = description; restaurant.name = name; const manager = getManager(); return await manager.save(restaurant); } export async function removeRestaurant(req: express.Request) { const { id } = req.params; const manager = getManager(); const restaurant = await manager.findOneOrFail(Restaurant, id); if (!isRestaurantCreatedBy(restaurant, req.user)) { throw new JsonErrorResponse({ error: 'Forbidden' }, { statusCode: 403 }); } await manager.remove(restaurant); return { ok: true }; } export async function getAllRestaurants() { const manager = getManager(); return await manager.find(Restaurant); } export async function getRestaurant(req: express.Request) { const { id } = req.params; const manager = getManager(); return await manager.findOneOrFail(Restaurant, id); } export async function updateRestaurant(req: express.Request) { const { id } = req.params; const { address, description, name, } = req.body; const manager = getManager(); const restaurant = await manager.findOneOrFail(Restaurant, id); if (!isRestaurantCreatedBy(restaurant, req.user)) { throw new JsonErrorResponse({ error: 'Forbidden' }, { statusCode: 403 }); } restaurant.address = address; restaurant.description = description; restaurant.name = name; return await manager.save(restaurant); } export default (app: IExpressWithJson) => { app.postJson('/restaurants', requireUser, createRestaurant); app.deleteJson('/restaurants/:id', requireUser, removeRestaurant); app.getJson('/restaurants', getAllRestaurants); app.getJson('/restaurants/:id', getRestaurant); app.patchJson('/restaurants/:id', requireUser, updateRestaurant); }
添加这些函数后,您应该能够创建一个餐厅。
启动应用程序,浏览到 `https://:3000` 并登录。登录后,Okta 返回的安全令牌将显示在浏览器窗口中。复制此令牌。
要创建餐厅,请执行此 cURL 请求(将 TOKEN
替换为实际令牌)
curl -X POST https://:3000/restaurants \ -H 'Authorization: Bearer TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "address": "Test Address 125", "description": "The best restaurant to test your API", "name": "Testing Food" }'
要查看数据库中的所有餐厅,请执行此 cURL 请求
curl -X GET https://:3000/restaurants
要删除创建的餐厅
curl -X DELETE https://:3000/restaurants/1 -H 'Authorization: Bearer TOKEN'
现在重新运行 POST 操作以重新创建餐厅。下一节将需要它。
还有另外两个端点我没有介绍,一个用于获取单个餐厅,一个用于更改现有餐厅。尝试使用 cURL
或 Postman
自己调用它们!
添加创建和获取菜肴的功能
每个(好吧,大多数)餐厅都有多道菜。创建一个新的控制器 `src/controllers/food-dishes.ts`,以便您可以将菜肴输入到餐厅中
import express from 'express'; import { getManager } from 'typeorm'; import { IExpressWithJson } from 'express-with-json'; import { FoodDish } from '../models/food-dish'; import { requireUser } from '../services/okta'; import { Restaurant } from '../models/restaurant'; export async function createFoodDish(req: express.Request) { const { restaurantId } = req.params; const manager = getManager(); await manager.findOneOrFail(Restaurant, restaurantId); const { description, name, priceInCents } = req.body; const foodDish = new FoodDish(); foodDish.description = description; foodDish.name = name; foodDish.priceInCents = parseInt(priceInCents); foodDish.restaurantId = parseInt(restaurantId); return manager.save(foodDish); } export async function getRestaurantFoodDishes(req: express.Request) { const { restaurantId } = req.params; return await getManager().find(FoodDish, { where: { restaurantId } }); } export default (app: IExpressWithJson) => { app.postJson('/restaurants/:restaurantId/food-dishes', requireUser, createFoodDish); app.getJson('/restaurants/:restaurantId/food-dishes', getRestaurantFoodDishes); }
为您最喜欢的新餐厅创建一个菜肴
curl -X POST \ https://:3000/restaurants/2/food-dishes -H 'Authorization: Bearer TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "description": "Exclusive dish made entirely of cURL", "name": "testing food dish", "priceInCents": 5000 }'
查看您最喜欢的餐厅的所有菜肴
curl -X GET https://:3000/restaurants/2/food-dishes
添加评分功能
为了允许用户评价餐厅,您必须实现一个用于评分功能的控制器 `src/controllers/ratings.ts`
import { getManager } from 'typeorm'; import express from 'express'; import { IExpressWithJson, JsonErrorResponse } from 'express-with-json'; import { Rating } from '../models/rating'; import { requireUser } from '../services/okta'; export async function createRating(req: express.Request) { const { restaurantId } = req.params; const { rating: ratingString, text } = req.body; const ratingNumber = parseInt(ratingString); if (ratingNumber < 0 || ratingNumber > 5) { throw new JsonErrorResponse({ error: 'Rating must be between 1 and 5' }, { statusCode: 400 }); } const rating = new Rating(); rating.creatorId = req.user.id; rating.rating = ratingNumber; rating.restaurantId = parseInt(restaurantId); rating.text = text; return await getManager().save(rating); } export async function getRestaurantRatings(req: express.Request) { const { restaurantId } = req.params; return await getManager().find(Rating, { where: { restaurantId } }); } export default function(app: IExpressWithJson) { app.postJson('/restaurants/:restaurantId/ratings', requireUser, createRating); app.getJson('/restaurants/:restaurantId/ratings', getRestaurantRatings); }
再次,使用 cURL
为现有餐厅创建评分
curl -X POST \ https://:3000/restaurants/2/ratings \ -H 'Authorization: Bearer TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "rating": 5, "text": "This is the best restaurant I'\''ve ever POSTed" }'
您现在可以查看任何给定餐厅的所有评分
curl -X GET https://:3000/restaurants/2/ratings
如果您尝试获取餐厅,您会发现它们都缺少 `averageRating` 字段。这是因为它尚未设置。每次评分更改时都必须设置并更新它。
为此创建一个 TypeORM 订阅者 `src/subscribers/restaurant-rating-subscriber.ts`
import { Rating } from '../models/rating'; import { EntityManager, EntitySubscriberInterface, EventSubscriber, InsertEvent, RemoveEvent, UpdateEvent } from 'typeorm'; import { Restaurant } from '../models/restaurant'; async function getAverageRating(manager: EntityManager, restaurantId: number): Promise<number> { const response = await manager.query( `select AVG(rating) as averageRating from rating where rating.restaurantId = ${restaurantId}` ); return response[0].averageRating; } async function recalculateAverageRating(manager: EntityManager, restaurantId: number) { const restaurant = await manager.findOneOrFail(Restaurant, restaurantId); restaurant.averageRating = await getAverageRating(manager, restaurantId); await manager.save(restaurant); } @EventSubscriber() export class RestaurantRatingSubscriber implements EntitySubscriberInterface<Rating> { listenTo() { return Rating; } async afterInsert(event: InsertEvent<Rating>) { await recalculateAverageRating(event.manager, event.entity.restaurantId); } async afterUpdate(event: UpdateEvent<Rating>) { await recalculateAverageRating(event.manager, event.entity.restaurantId); } async afterRemove(event: RemoveEvent<Rating>) { await recalculateAverageRating(event.manager, event.entity.restaurantId); } }
现在尝试删除所有评分
docker-compose exec okta-feed-me-well-db mysql -u user -p okta-feed-me-well-db -p -e "delete from rating"
然后重新创建之前的评分
curl -X POST \ https://:3000/restaurants/2/ratings \ -H 'Authorization: Bearer TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "rating": 5, "text": "This is the best restaurant I'\''ve ever POSTed" }'
ID 为 2 的餐厅现在包含有效的 `averageRating`。
了解有关 Node.js、MySQL、Express 和用户身份验证的更多信息!
如果您坚持到现在,您已经成功设置了一个虚拟 MySQL 环境,将其连接到一个引导过的 Express 应用,构建了餐厅评分功能,并通过用户身份验证使所有这些都得到了保护!做得好。
既然您正在学习新东西,请继续阅读这些与本文中使用的主题和技术相关的其他帖子
- MySQL 与 PostgreSQL “选择适合您项目的数据库
- 使用 Node 和 Postgres 构建 REST API
- Node 中使用 Express 进行现代令牌身份验证
- 使用 Node 和 OAuth 2.0 构建简单的 REST API
有问题?请求未来的帖子?在评论中留言!别忘了关注 Twitter 上的 @oktadev 并在 YouTube 上订阅。