用 express 支撑小爬虫
安装使用 express
express是一个基于 node 的 web 后端框架,特点是极简灵活,提供精简的基本 Web 应用程序功能。也是第一代 node 后端框架,也是目前使用率第一的框架,有很多框架还有程序是基于它的。
局部安装 express 和它的类型声明文件:npm install express --save,npm install @types/express --save
编写 index.ts,代码如下,使用 ts-node 运行它,在http://localhost:7001/里就能看到打印的“Hello”
import express from "express";
// 创建一个新的应用程序
const app = express();
// 定义路由,发送内容
app.get("/", (req, res) => {
res.send("Hello");
});
// 绑定并监听连接
app.listen(7001, () => {
console.log("start serve");
});定义路由项
像上面这个简单例子里使用了app.get定义了一个路由,如果使用多个呢?可以使用 express 中的Router来定义,并且可以单独抽到一个 ts 文件里书写。
router.ts
import { Router, Request, Response } from "express";
const router = Router();
router.get("/", (req: Request, res: Response) => {
res.send("Hello");
});
router.get("/data", (req: Request, res: Response) => {
res.send("Hello Data");
});
export default router;index.ts
import express from "express";
import router from "./router";
// 创建一个新的应用程序
const app = express();
// 使用之前定义的路由
app.use(router);
// 绑定并监听连接
app.listen(7001, () => {
console.log("start serve");
});拿取爬虫数据
将之前写的爬虫相关的类Crawler、BookAnalyzer导进来,在/data路由下再去爬取新数据。但是要给访问/data路由进行限制,拥有权限的才允许访问。可以使用post 请求传递密码,然后对路由的 request 的 body 进行校验。
但是校验过程中,request 的 body 解析会有问题,我们需要借助一个中间件,npm install body-parser --save,安装完后在 index.ts 导入它,并在使用路由前加上这一行代码app.use(bodyParser.urlencoded({ extended: false }))。接着完善我们的router.ts:
import { Router, Request, Response } from "express";
import Crawler from "./crawler";
import BookAnalyzer from "./bookAnalyzer";
const router = Router();
router.get("/", (req: Request, res: Response) => {
res.send(`<html>
<body><form method="post" action="/data">
<input type="password" name="password" />
<button>提交</button>
</form></body>
</html>`);
});
// 对应前端的post请求
router.post("/data", (req: Request, res: Response) => {
if (req.body.password === "123") {
const url = "http://top.hengyan.com/haokan/";
const crawler: Crawler = new Crawler(url, BookAnalyzer.I);
res.send("爬取数据成功!");
} else {
res.send("密码错误!");
}
});
export default router;扩展 express 的类型定义
虽然 express 有类型定义文件,但会有些瑕疵,比如 Request 和 Response 的 body 在 express 里是any,这不太符合规范。解决方案就是使用interface约束一个新类型,并且这个新类型去继承老类型,然后在使用时就使用这个新类型。
interface InewRequest extends Request {
body: {
[key: string]: string | undefined;
};
}还有就是使用中间件的话,特别是使用自定义中间件,难免会遇到在 Request新添加属性。可以将 express 原有的类型定义和自己写的类型定义融合,这里要明确一点:是融合不是覆盖以前的。像上面使用inteface配合extends是使用新类型名,类型定义融合虽然使用老的类型名,但也只是扩充了老类型的属性而已。
declare namespace Express {
// 这里要跟原来的*.d.ts里的一样
interface Request {
// 这里要跟原来的*.d.ts里的一样
xxx: string; // 而这里就是新添的属性,后面就会类型定义融合
}
}状态持久化
使用 cookie-session 中间件
登陆的状态需要持久化,可以使用中间件npm install cookie-session --save,它有自己的类型定义npm install @types/cookie-session -D。
加上使用中间件的代码:
import express from "express";
import cookieSession from "cookie-session";
import bodyParser from "body-parser";
import router from "./router";
// 创建一个新的应用程序
const app = express();
// 使用cookie-session中间件
app.use(
cookieSession({
name: "session",
keys: ["login"],
// Cookie Options
maxAge: 24 * 60 * 60 * 1000, // 24 hours
})
);
//使用中间件来解析form表单请求里的body
app.use(bodyParser.urlencoded({ extended: false }));
// 使用路由
app.use(router);
// 绑定并监听连接
app.listen(7001, () => {
console.log("start serve");
});登陆登出
最主要的就在于登陆状态的判断,req.session上一小节里的中间件,登陆成功后给 session 里塞一个login属性,在下次访问对应路由时会校验它。
我们还添加了一个中间页面,里面有“查看数据”、“爬取数据”、“退出登陆”这三个功能性按钮,对应也给他们设置了路由。
import { Router, Request, Response } from "express";
import Crawler from "./crawler/crawler";
import BookAnalyzer from "./crawler/bookAnalyzer";
import fs from "fs";
import path from "path";
interface ExpRequest extends Request {
body: {
[key: string]: string | undefined;
};
}
const router = Router();
// 登陆状态
const checkLogin = (req: ExpRequest, res: Response) => {
if (!req.session || !req.session.login) {
res.send(
`<html>
<body>
您还没有登陆!请先<a href="/">登陆!</a>
</body>
</html>`
);
return false;
}
return true;
};
// 主页
router.get("/", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
// 登陆过了
res.send(
`<html>
<body>
<a href="/show">查看数据</a>
<a href="/data">爬取数据</a>
<a href="/logout">退出登陆</a>
</body>
</html>`
);
} else {
// 没有登陆显示登陆框
res.send(`
<html>
<body>
<form method="post" action="/login">
<input type="password" name="password" />
<button>登陆</button>
</form>
</body>
</html>
`);
}
});
// 登陆校验
router.post("/login", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
// 之前登陆过了,重定向到中转站
res.redirect("/");
} else {
const { password } = req.body;
if (password === "123") {
// 登陆状态更新
req.session.login = true;
// 重定向到中转站
res.redirect("/");
} else {
res.send(
`<html>
<body>
密码错误!请重新<a href="/">登陆!</a><br/>
</body>
</html>`
);
}
}
});
// 以get的方式访问登陆校验
router.get("/login", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
// 之前登陆过了,重定向到中转站
res.redirect("/");
}
});
// 登出操作
router.get("/logout", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
req.session.login = undefined;
res.send(
`<html>
<body>
登出成功!需要再<a href="/">登陆</a>吗?
</body>
</html>`
);
} else {
res.send(
`<html>
<body>
您本来就是未登录状态!请问需要<a href="/">登陆</a>吗?
</body>
</html>`
);
}
});
// 展示爬虫数据
router.get("/show", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
try {
const filePath = path.resolve(__dirname, "../data/book.json");
const jsonStr = fs.readFileSync(filePath, "utf-8");
res.send(JSON.parse(jsonStr));
} catch (error) {
res.send(
`<html>
<body>
展示数据出错!<a href="/">返回</a><br/>
${error}
</body>
</html>`
);
}
}
});
// 爬一次数据
router.get("/data", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
try {
const url = "http://top.hengyan.com/haokan/";
const crawler: Crawler = new Crawler(url, BookAnalyzer.I);
res.send(
`<html>
<body>
爬取成功!<br/>
<a href="/show">查看全部数据</a>
</body>
</html>`
);
} catch (error) {
res.send(`爬取出错!`);
}
}
});
export default router;接口化
减少res.send给前端发送 html 元素,将其换成res.jsonn,也就是以接口的形式返回给前端,前端根据接口再去写 html。
getResponse.ts
interface Iresult {
success: boolean;
errMsg?: string;
data: Object;
}
export const getResponse = (data: Object, errMsg?: string): Iresult => {
return { success: errMsg == null, errMsg, data };
};router.ts
import { Router, Request, Response } from "express";
import Crawler from "./crawler/crawler";
import BookAnalyzer from "./crawler/bookAnalyzer";
import fs from "fs";
import path from "path";
import { getResponse } from "./utils/getResponse";
interface ExpRequest extends Request {
body: {
[key: string]: string | undefined;
};
}
const router = Router();
// 登陆状态
const checkLogin = (req: ExpRequest, res: Response) => {
if (!req.session || !req.session.login) {
res.json(getResponse(false, "您还没有登陆!请先登陆!"));
return false;
}
return true;
};
// 主页
router.get("/", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
// 登陆过了
res.send(
`<html>
<body>
<a href="/show">查看数据</a>
<a href="/data">爬取数据</a>
<a href="/logout">退出登陆</a>
</body>
</html>`
);
} else {
// 没有登陆显示登陆框
res.send(`
<html>
<body>
<form method="post" action="/login">
<input type="password" name="password" />
<button>登陆</button>
</form>
</body>
</html>
`);
}
});
// 登陆校验
router.post("/login", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
// 之前登陆过了,重定向到中转站
res.redirect("/");
} else {
const { password } = req.body;
if (password === "123") {
// 登陆状态更新
req.session.login = true;
// 重定向到中转站
res.redirect("/");
} else {
res.json(getResponse(false, "密码错误!"));
}
}
});
// 以get的方式访问登陆校验
router.get("/login", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
// 之前登陆过了,重定向到中转站
res.redirect("/");
}
});
// 登出操作
router.get("/logout", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
req.session.login = undefined;
res.json(getResponse(true));
} else {
res.json(getResponse(false, "您本来就是未登录状态!"));
}
});
// 展示爬虫数据
router.get("/show", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
try {
const filePath = path.resolve(__dirname, "../data/book.json");
const jsonStr = fs.readFileSync(filePath, "utf-8");
res.json(getResponse(JSON.parse(jsonStr)));
} catch (error) {
res.json(getResponse(error, "展示数据出错!"));
}
}
});
// 爬一次数据
router.get("/data", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
try {
const url = "http://top.hengyan.com/haokan/";
const crawler: Crawler = new Crawler(url, BookAnalyzer.I);
res.json(getResponse(true));
} catch (error) {
res.json(getResponse(false, "爬取出错!"));
}
}
});
export default router;使用装饰器来优化
我们发现 router.ts 里面有很多 get 方法,并且不是使用 Class 形式编写的代码。如果要使用 Class 形式来编写,可以考虑使用一个类来编写各种路由 handler,用另一个类来操作前一个类的原型和 Router。
其实可以使用装饰器配合元数据,思路:写一个 Class,首先成员方法是 handler 事件处理函数,给这个成员方法写个方法装饰器用来存储路由路径和本方法;然后对 Class 写个类装饰器,遍历这个类的原型上的属性,如果该属性属于元数据中的一员,证明它就是 handler 事件处理函数,然后将事件处理函数加入到 Router中。这样就完成了路由的添加,并且这个 Class无需实例化,只需要导入到 index.ts 即可,也就是触发装饰器。
对接口的返回做了优化,data 不使用 Object,而是使用泛型,泛型传入来规定 data 是个什么类型。
还对代码结构层次做了优化,将各种装饰器全都分离出来并且放到一个文件夹里,另外将登陆相关逻辑和爬取操作也分离开并且放在一个文件夹里。
src
├──controller (控制层)
│ ├──CrawlController.ts (爬取操作业务逻辑)
│ └──LoginController.ts (登陆处理业务逻辑)
├──crawler (爬虫过程)
│ ├──bookAnalyzer.ts (分析器)
│ └──crawler.ts (爬虫读取写入)
├──decorator (装饰器)
│ ├──controllerDecorator.ts (业务类的装饰器)
│ ├──requestDecorator.ts (router的handler的装饰器)
│ └──useDecorator.ts (router的中间件的装饰器)
├──utils (工具)
│ └──getResponse.ts (响应报文组装)
├──index.ts (入口文件,也是编译入口)
└──router.ts (router)优化后的代码清单
getResponse.ts
interface Iresult<T> {
success: boolean;
errMsg?: string;
data: T;
}
// 返回报文
export const getResponse = <T>(data: T, errMsg?: string): Iresult<T> => {
return { success: errMsg == null, errMsg, data };
};CrawlController.ts
import fs from "fs";
import path from "path";
import { Response } from "express";
import { controller } from "../decorator/controllerDecorator";
import { get } from "../decorator/requestDecorator";
import { use } from "../decorator/useDecorator";
import Crawler from "../crawler/crawler";
import BookAnalyzer, { IrankInfo } from "../crawler/bookAnalyzer";
import { ExpRequest, checkLogin } from "./LoginController";
import { getResponse } from "../utils/getResponse";
// 这个类不用实例化也不用静态调用,但是一定要在index.ts里导入,因为要触发装饰器
@controller("/")
class CrawlController {
// 展示爬取数据
@get("/show")
@use(checkLogin)
private show(req: ExpRequest, res: Response): void {
try {
const filePath = path.resolve(__dirname, "../../data/book.json");
const jsonStr = fs.readFileSync(filePath, "utf-8");
res.json(getResponse<IrankInfo>(JSON.parse(jsonStr)));
} catch (error) {
res.json(getResponse<boolean>(false, "展示数据出错!"));
}
}
// 去爬取一次数据
@get("/data")
@use(checkLogin)
private data(req: ExpRequest, res: Response): void {
try {
const url = "http://top.hengyan.com/haokan/";
const crawler: Crawler = new Crawler(url, BookAnalyzer.I);
res.json(getResponse<boolean>(true));
} catch (error) {
res.json(getResponse<boolean>(false, "爬取出错!"));
}
}
}LoginController.ts
import { Request, Response, NextFunction, RequestHandler } from "express";
import "reflect-metadata";
import { controller } from "../decorator/controllerDecorator";
import { get, post } from "../decorator/requestDecorator";
import { use } from "../decorator/useDecorator";
import { getResponse } from "../utils/getResponse";
// 请求体的session
interface ISession extends CookieSessionInterfaces.CookieSessionObject {
login: boolean;
}
// 请求体
export interface ExpRequest extends Request {
body: {
[key: string]: string | undefined;
};
session: ISession;
}
// 登陆状态
const isLogin = (req: ExpRequest, res: Response): boolean => {
return !!(req.session && req.session.login);
};
// 登陆状态的中间件
export const checkLogin: RequestHandler = (req: ExpRequest, res: Response, next: NextFunction): void => {
if (isLogin(req, res)) {
next();
} else {
res.json(getResponse<boolean>(false, "您还没有登陆!请先登陆!"));
}
};
// 根路径
const root: string = "/";
// 这个类不用实例化也不用静态调用,但是一定要在index.ts里导入,因为要触发装饰器
@controller(root)
class LoginController {
// 主页
@get("/")
private home(req: ExpRequest, res: Response): void {
if (isLogin(req, res)) {
res.send(
`<html>
<body>
<a href="/show">查看数据</a>
<a href="/data">爬取数据</a>
<a href="/logout">退出登陆</a>
</body>
</html>`
);
} else {
// 没有登陆显示登陆框
res.send(`
<html>
<body>
<form method="post" action="/login">
<input type="password" name="password" />
<button>登陆</button>
</form>
</body>
</html>
`);
}
}
// 登陆校验
@post("/login")
private loginPost(req: ExpRequest, res: Response): void {
const rootPath = !root || root === "/" ? "" : root;
if (isLogin(req, res)) {
// 之前登陆过了,重定向到主页
res.redirect(`${rootPath}/`);
} else {
const { password } = req.body;
if (password === "123") {
// 登陆状态更新
req.session.login = true;
// 重定向到主页
res.redirect(`${rootPath}/`);
} else {
res.json(getResponse<boolean>(false, "密码错误!"));
}
}
}
// 登出
@get("/logout")
private logout(req: ExpRequest, res: Response): void {
if (isLogin(req, res)) {
req.session.login = undefined;
res.json(getResponse<boolean>(true));
} else {
res.json(getResponse<boolean>(false, "您本来就是未登录状态!"));
}
}
}controllerDecorator.ts
import { RequestHandler } from "express";
import router from "../router";
import { PathType } from "./requestDecorator";
// 类的装饰器,这里包了一层工厂函数,目的是controller自带接口路径
export function controller(root: string) {
// 真正的装饰器
return function (target: new (...args: any[]) => {}) {
// 遍历原型上的属性
for (let key in target.prototype) {
// 路由路径
const path: string = Reflect.getMetadata("path", target.prototype, key);
// 是router.get还是router.post
const method: PathType = Reflect.getMetadata("method", target.prototype, key);
// 中间件
const middleWare: RequestHandler = Reflect.getMetadata("middleWare", target.prototype, key);
// 方法
const handler = target.prototype[key];
// 都存在才添加路由
if (path && method && handler) {
const fullPath = !root || root === "/" ? path : `${root}${path}`;
if (middleWare) {
// 使用中间件
router[method](fullPath, middleWare, handler);
} else {
router[method](fullPath, handler);
}
}
}
};
}requestDecorator.ts
// 枚举,是router.get还是router.post
export enum PathType {
Get = "get",
Post = "post",
}
// methodType来区分是router.get还是router.post,这是包第一层工厂函数的原因
function getRequestDecorator(methodType: PathType) {
// value是外部调用get(value)的入参,这也是再包一层工厂函数的原因
return function (value: string) {
// 类的方法的装饰器:target是方法所在的原型对象,key是方法名。最后的这个才是真正的装饰器。
return function (target: any, key: string) {
// 将handler和对应方法存入元素数据
// 'path'是metadataKey,value是metadataValue,target是方法所在的原型对象,key是方法名
Reflect.defineMetadata("path", value, target, key);
// 存入元数据,你是router.get还是router.post
Reflect.defineMetadata("method", methodType, target, key);
};
};
}
// get的装饰器,用于使用router.get()
export const get = getRequestDecorator(PathType.Get);
// post的装饰器,用于使用router.post()
export const post = getRequestDecorator(PathType.Post);useDecorator.ts
import { RequestHandler } from "express";
// use装饰器,用于使用中间件。这里包了一层工厂函数,因为要传middleWare进来
export function use(middleWare: RequestHandler) {
// 真正的装饰器
return function (target: any, key: string) {
const originMiddleWare: RequestHandler[] = Reflect.getMetadata("middleWares", target, key) || [];
// 可以使用多个中间件
originMiddleWare.push(middleWare);
Reflect.defineMetadata("middleWares", originMiddleWare, target, key);
};
}