前言
接上文,NestJS实战之开发短链服务(四),在上一篇文章中,我们已经把对短链规则的CRUD和对策略的CRUD完成了,在本文中,我们将会处理短链的跳转规则,本文也将会是本专栏的最后一篇文章。
在上一篇文章中存在一部分遗漏的知识点,我们将会在本文的开头中补充完成。废话少说,我们直接进入正题吧。
如何验证用户自定义策略的合法性?
在自定义策略的部分,我们要求用户可以自行编写短链跳转策略的函数体,但是前端如何验证这个函数的合理性呢?这其实是一个关键的问题,如果不做函数有效性的验证的话,这对我们系统的能力是大打折扣的,加入验证可以避免一些低级的错误。
我们可以mock一个Express
的Request
对象,既然是mock的,那肯定不可能指望100%相似,我们只能尽可能的使得它们接近。
以下是我的一个实现:
js
function mockExpressRequest(options) {
return {
// 模拟请求路径
path: options.path || '/',
// 模拟HTTP方法
method: options.method || 'GET',
// 模拟请求体
body: options.body || {},
// 模拟查询参数
query: options.query || {},
// 模拟URL参数
params: options.params || {},
// 模拟请求头
headers: options.headers || {},
// 模拟cookies
cookies: options.cookies || {},
// 模拟session
session: options.session || {},
// 添加自定义方法,如Express中的`get`用于获取头信息
get: function(headerName) {
return this.headers[headerName.toLowerCase()]
}
}
}
// 使用示例
export const mockReq = mockExpressRequest({
path: '/user',
method: 'POST',
body: { username: 'john_doe', password: '123456' },
query: { redirect: 'true' },
params: { userId: '1' },
headers: { 'content-type': 'application/json' },
cookies: { sessionToken: 'abc123' },
session: { userId: '1' }
})
然后,我们读取MonacoEditor
中用户输入的内容,这个内容是一个字符串,如果我们通过eval
将其解析之后,它将是一个可用的函数。
js
[
{
validator: (rule, value, callback) => {
try {
const func = this.form.func
// eslint-disable-next-line no-eval
const defineFunc = eval('(' + func + ')')
defineFunc(mockReq, new URL('https://www.google.com'))
callback()
} catch (exp) {
console.log(exp)
callback(new Error('自定义策略存在语法错误'))
}
}
}
]
以上是我用ElementUI
定义的一个验证规则。我们需要用一对小括号把函数字符串包起来,这样得到的结果才是用户编写的函数 ,然后我们执行用户的这个自定义函数,传入我们mock的Request
对象和一个mock的URL
对象,如果函数能够正常返回一个URL
对象的话,则认为用户编写的自定义策略是有效的,通过这种方式,我们在前端完成了对用户自定义策略的验证。
短链转化核心
在前面的4篇文章中,我们费了九牛二虎之力完成了各种准备工作之后,现在正式开始处理短链转长链的核心。为了使得代码更加紧凑,我们将这一系列业务封装在一个叫做Gateway
的Module
当中,完成之后直接在主模块中绑定即可。
理清短链转长链的方式
如果你已经不太清楚我的数据表的话,可以先回过去看一下我本系列第一篇文章的表结构。我们的规则表上有一个字段叫做custom_strategy_func
,即一次性的自定义策略,同时,我们还有一个策略表,通过它我们可以预设很多内置的策略,我们在新建规则的时候,可以直接选择应用某个系统预设的策略,所以这儿存在一个优先级关系。
对于一个规则来说,custom_strategy_func
的优先级最高,如果说用户新建的短链规则有应用它,并且返回内容是预期的(即URL
对象),系统则取它的结果作为最终的跳转规则;
如果没有的话,我们就需要查找关联的生效中 的系统预设的跳转策略,根据用户选择的策略依次应用,根据最终的结果,如果有预期的(即URL
对象),则取这个结果作为跳转的结果。
如果用户一个也没有选预设的策略的话,就看他是否配置跳转的url,即规则表里面的target_url
字段,如果都没有的话,那么就跳转到错误页。
编码实现
同样采用分三层的方式实现。
1、Repository
数据访问层需要处理的内容非常简单,我们只需要根据短链码查短链规则和查询所有生效中的自定义策略。
ts
import { Rule as RuleEntity } from '@/modules/persistence/entities/rule';
import { Strategy as StrategyEntity } from '@/modules/persistence/entities/strategy';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
export class GatewayRepository {
@InjectRepository(RuleEntity)
private ruleRepo: Repository<RuleEntity>;
@InjectRepository(StrategyEntity)
private strategyRepo: Repository<StrategyEntity>;
/**
* 根据code查询短链实体
* @param code
* @returns
*/
getRule(code: string) {
return this.ruleRepo.findOne({
where: {
code,
},
});
}
/**
* 获取位于ID集合中的生效中的自定义策略列表
* @param ids
* @returns
*/
getValidStrategyList(ids: number[]) {
return this.strategyRepo
.createQueryBuilder('strategy')
.where('strategy.id IN (:...ids)', { ids })
.andWhere('strategy.is_valid = :isValid', { isValid: true })
.getMany();
}
}
2、Service
这是最关键的部分,我们在这个位置处理一次性的自定义策略和系统预设的策略,虽然之前我们已经检验过用户输入的自定义策略,但是仍然可能还会有潜在出错的可能性,所以对于这些不那么受信任 的代码,我们需要谨慎的将其包裹在try-catch
语句里面执行。
ts
import {
CallTrack,
Ignore,
} from '@/modules/common/decorators/call-track.decorator';
import { Inject, Injectable } from '@nestjs/common';
import { DEFAULT_URL } from '@/modules/common/config';
import { Request } from 'express';
import { stringify } from 'querystring';
import { GatewayRepository } from './gateway.repository';
@Injectable()
@CallTrack
export class GatewayService {
@Inject()
private gatewayRepo: GatewayRepository;
/**
* 根据短链码获取跳转的地址对象
* @param code 短链码
* @param request 获取跳转对象
* @returns
*/
@Ignore
async getRedirectURL(code: string, request: Request): Promise<URL> {
const ruleEntity = await this.gatewayRepo.getRule(code);
// 如果被禁用
if (!ruleEntity || !ruleEntity.isValid) {
return null;
}
let url = new URL(ruleEntity.targetUrl || DEFAULT_URL);
const targetUrlQueryStr = url.searchParams.entries();
// 把targetURL上面的参数于当前的进行拼接
for (const [key, value] of targetUrlQueryStr) {
if (!request.query.hasOwnProperty(key)) {
request.query[key] = value;
}
}
const queryStr = stringify(request.query as NodeJS.Dict<string | string[]>);
// 透传查询字符串
url.search = queryStr.toString();
// 如果自定义了一次性的策略函数
if (ruleEntity.customStrategyFunc) {
try {
const customFn = eval('(' + ruleEntity.customStrategyFunc + ')');
// 改成异步的
url = await customFn(request, url);
// 如果正确返回了url对象的话,将不再走系统预设的策略
if (url instanceof URL) {
return url;
}
} catch (exp) {
console.log(exp);
url = null;
}
}
// 如果有应用自定义策略集合
if (ruleEntity.strategyIds.length) {
const customStrategyFuncList =
await this.gatewayRepo.getValidStrategyList(ruleEntity.strategyIds);
let urlObj = url;
try {
// 依次应用自定义策略,考虑每个自定义函数的返回值可能是异步的
for (const strategy of customStrategyFuncList) {
const execFn = eval('(' + strategy.func + ')');
urlObj = await execFn(request, urlObj);
}
url = urlObj;
} catch (exp) {
console.log(exp);
url = null;
}
}
return url;
}
}
我们把这些自定义的策略考虑成异步函数,对于用户自定义处理会更加友好,所以在处理的时候都加上了await
关键字调用,只是在微任务队列处理的时候才完成,对外界来说没有多大的差别。
3、Controller
由于我的短链服务用Docker部署的,需要用一个地址来嗅探服务是否还在正常运行中,因此我把/
这个路径占用了,各位可以根据自己的需求定制业务。
我们在映射路由的时候,用到了NestJS
提供路径参数(即对Express
的封装)的能力,这样我们就不用自行去截取某个位置的字符串了,可以直接在Request
对象上取到我们预期的短链码,然后调用下层的方法查询真实跳转地址,若有效则进行一个302
的跳转,否则就定位到短链服务的404
页面。
ts
import { Controller, Get, HttpCode, Inject, Logger, Res } from '@nestjs/common';
import { GatewayService } from './gateway.service';
import { Request, Response } from 'express';
import { REQUEST } from '@nestjs/core';
import { resolve } from 'path';
@Controller()
export class GatewayController {
constructor(private readonly gateService: GatewayService) {}
@Inject(REQUEST)
request: Request;
@Get()
@HttpCode(200)
healthCheck() {
return 'the server is running';
}
@Get('/p/:hashCode')
async redirectReq(@Res() response: Response) {
const code = this.request.params['hashCode'];
const url = await this.gateService.getRedirectURL(code, this.request);
if (!url) {
const filePath = resolve(process.cwd(), '404.html');
response.statusCode = 404;
response.sendFile(filePath);
} else {
const distUrl = url.toString();
Logger.log('短链码:' + code + ',真实跳转的地址是:' + distUrl);
response.redirect(302, distUrl);
}
}
}
4、绑定Module
关联相应的业务逻辑类
ts
import { Module } from '@nestjs/common';
import { GatewayController } from './gateway.controller';
import { GatewayService } from './gateway.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Rule } from '@/modules/persistence/entities/rule';
import { Strategy } from '@/modules/persistence/entities/strategy';
import { GatewayRepository } from './gateway.repository';
@Module({
// 导入TypeORM的支持
imports: [TypeOrmModule.forFeature([Rule, Strategy])],
controllers: [GatewayController],
providers: [GatewayService, GatewayRepository],
})
export class GatewayModule {}
最后,在应用程序的主Module导入这个GatewayModule
就可以了。
ts
import { Module } from '@nestjs/common';
import { GatewayModule } from './gateway/gateway.module';
@Module({
// 省略了其它代码
imports: [GatewayModule],
})
export class AppModule {}
到这个位置,我们的短链服务就基本上开发完成了。
结语
通过5篇文章的学习,我们完成了从0-1的短链服务开发。在这个系列中我们的侧重点不是阐述NestJS相关的使用方法,而是向大家展示如何利用它所提供的API去完成一个真正有意义的项目。
短链服务是现代大前端趋势下相当重要的一环,有了短链服务可以天然的将不同的平台的服务关联在一起,能够给用户提供一致的使用体验,所以这是每个公司不可或缺的基础能力。
在我的这个短链服务中,其中最核心的一个设计就是将策略外置,这可以使得我们的系统可以根据业务需求去建设很多复杂的策略,不过代价就是需要动态解析外置的策略函数,相对来说会使得性能要稍微差一点,如果你对这个设计有芥蒂的话,可以自行使用设计模式去替换这部分设计,系统提供一个个预设的内置策略,就不用动态解析了。
虽然说短链服务是一个很小的项目,但是却真实做到了麻雀虽小,五脏俱全,我们在项目中处理了全局的异常捕获,处理了日志,并且对于某些方法的调用也处理了日志输出,在开发过程中我们按三层架构划分代码结构,这都是服务端开发中一些基础的要求,对于缺乏NestJS实践的同学有较强的借鉴意义,各位读者可以加以体会。
在开发中,我们根据不同的业务划分出模块,使得代码组织比较独立、紧凑。当我们在开发或维护某个功能的时候,只需要将目光集中在项目的模块中,更清晰、更易于维护。
这个项目的代码如果大家有需要的话,可以在私信我,对于这个短链服务如果有更好的改进建议,可以在评论区留言。
由于笔者的水平有限,写作过程中难免存在纰漏,如有问题,可联系我进行修改。