本文主要围绕着各个项目中大量使用canvas画卡所做的思考,然后实现一个服务端画卡功能、来减少业务开发成本。本文首先介绍服务端画卡逻辑,另一篇文章介绍公共画卡管理后台(后续会更新...)。
流程介绍
-
客户端或者服务端请求画卡接口(接口参数:{ drawData: 画卡数据,weight: 画卡权重,cbApi: 回调接口,userId, taskId})。
-
解析参数存放到redis中。
-
判断当前是否有任务在执行;如果没有就创建一个page实例,同时获取redis中数据,进行画卡;如果有就添加相应的任务中进行画卡等待。
-
画卡成功后,调用回调接口,返回画卡数据。 以上就是大致流程,以下是流程图:
Phantom.js介绍
phantomjs实现了一个无界面的webkit浏览器。虽然没有界面,但dom渲染、js运行、网络访问、canvas/svg绘制等功能都很完备,在页面抓取、页面输出、自动化测试等方面有广泛的应用。
简单例子
// test.js
var page = require('webpage').create(),
system = require('system'),
address;
if (system.args.length === 1) {
phantom.exit(1);
} else {
address = system.args[1];
page.open(address, function (status) {
console.log(page.content);
phantom.exit();
});
}
运行
phantomjs ./test.js baidu.com
phantomjs提供了很多API,这里不做细讲phantomjs详细文档
项目搭建
项目结构使用的nestjs + typescript + redis + mongodb作为服务端,react + typescript + mobx + antd作为管理后台。
1、Nestjs
Nest 是一个用于构建高效,可扩展的 Node.js 服务器端应用程序的框架。它使用渐进式 JavaScript,内置并完全支持 TypeScript(但仍然允许开发人员使用纯 JavaScript 编写代码)并结合了 OOP(面向对象编程),FP(函数式编程)和 FRP(函数式响应编程)的元素。 具体安装和运行参考文档nestjs
2、Redis搭建
项目之初使用了cache-manager-redis-store作为数据缓存,同时提供了redis操作方法;现在画卡功能就直接使用redis的列表(List)相关API。
1、CacheConfigService需要使用CacheOptionsFactory接口来提供配置选项
2、使用工厂函数,异步注入依赖配置
3、监听redis连接
4、CacheService中封装List 提供对外的API方法
3、业务代码
3.1 业务目录结构
- task.config.service.ts 提供初始化phantomjs
- task.controller.ts 控制器负责处理传入的 请求 和向客户端返回 响应 。
- task.service.ts 处理画卡业务逻辑
- task.module.ts 模块
3.2 初始化phantomjs
首先创建一个PhantomTask类,用于承载Phantomjs 初始化所有配置监听信息。
/**
* 用于node层画卡、微信消息推送卡、截图功能实现
* @export
* @class PhantomTask
*/
export class PhantomTask {
// page 对象
public clientPage: WebPage
// phantom 对象
public phantomInstance: PhantomJS
// 限制次数
private num: number = 0
private event: EventEmitter
constructor() {
if (!this.event) {
this.event = new EventEmitter()
}
}
...
}
对外提供了一个初始化的API;初始化Phantom配置和监听Phantom相关方法;画卡成功后的数据获取是通过监听onAlert方法获取alert弹窗数据,this.event.emit触发自定义事件传递画卡数据。 注意Phantomjs不支持es6语法
/**
* 初始化访问
* @memberof PhantomTask
*/
public async initTask() {
if (!this.clientPage) {
try {
this.phantomInstance = await create(['--ignore-ssl-errors=yes'])
// 创建页面
this.clientPage = await this.phantomInstance.createPage()
// 开始时间
let _serverRequestStartTime: [number, number] = process.hrtime()
// 是否执行页面内的javascript
this.clientPage.setting('javascriptEnabled', true)
// 是否载入图片
this.clientPage.setting('loadImages', true)
// 资源开始加载的时候
this.clientPage.on('onLoadStarted', () => {
console.log('[phantomjs task log]: 开始下载资源')
_serverRequestStartTime = process.hrtime()
})
// 页面所有资源载入完成后触发
this.clientPage.on('onLoadFinished', (status) => {
if (Object.is(status, 'success')) {
const _serverRequestEndTime: [number, number] = process.hrtime()
const ms = (_serverRequestEndTime[0] - _serverRequestStartTime[0]) * 1e3 + (_serverRequestEndTime[1] - _serverRequestStartTime[1]) * 1e-6
console.log('[phantomjs task log]: 页面所有资源加载完成,耗时 ', ms, 'ms')
} else {
console.log('[phantomjs task log]: 页面所有资源加载失败')
}
})
// 捕获到alert
this.clientPage.on('onAlert', (data) => {
this.event.emit('imageData', data)
console.log('onAlert:========', data)
})
// 捕获所有page上下文发生的javascript错误
this.clientPage.on('onError', (msg, trace) => {
console.error('onError ------ ', msg, trace)
})
// 可以捕获console消息
this.clientPage.on('onConsoleMessage', (msg, lineNum, sourceId) => {
console.log('onConsoleMessage ------ ', msg, lineNum, sourceId)
})
// 在page创建后触发
this.clientPage.on('onInitialized', () => {
console.log('[phantomjs task log]page创建后触发')
})
// 监听url变化
this.clientPage.on('onUrlChanged', (targetUrl) => {
console.log('[phantomjs task log]监听url', targetUrl)
})
// 打卡页面, es6代码不支持
const result = await this.clientPage.open('输入画卡页面地址')
// 查看页面源码
const content = await this.clientPage.property('content')
// console.log(content)
console.log('[phantomjs task log]完成页面加载', result)
} catch (error) {
console.log('page error:', error)
this.phantomInstance && this.phantomInstance.exit()
this.phantomInstance = null
this.clientPage = null
// 限制次数
if (this.num < 10) {
this.num++
setTimeout(() => {
this.initTask
}, 2000)
} else {
console.error('启动次数达到上线10次,请检查业务逻辑!!!')
}
}
}
}
此方法是拿到画卡数据时调用,触发页面画卡函数以及监听画卡完成后数据的获取。evaluate函数(可以操作window对象)。也可以直接使用es5封装画卡功能在evaluate函数中触发;考虑到代码的通用可维护性(维护一套代码),这里调用获取画卡管理后台页面,通过触发window.initDraw()函数画卡。
/**
* 触发页面画卡函数以及监听画卡完成后数据的获取。
* @param {*} cardData
* @returns
* @memberof PhantomTask
*/
public async createShareCard(cardData): Promise<string> {
if (!this.clientPage) {
console.error('[phantomjs task error]: 页面未初始化成功!')
return null
}
return new Promise((resolve, reject) => {
// 画卡完成后通过alert弹窗获取参数,通过监听imageData事件获取数据
this.event.once('imageData', (data) => {
console.log('[phantomjs task log]: 图片生产完成')
resolve(data)
})
// page打开页面的上下文(下文直接用page上下文指代)执行function的功能
this.clientPage.evaluate(function(data) {
// 这里把页面画卡函数直接放置window对象下,直接调用如:
// window.initDraw(data)
console.log('提供window操作, 例如触发画卡功能')
// 获取文本可以直接返回, 外部可以直接获取到文本
// return document.querySelector('.title').innerText;
// 不支持es6 语法
// const test = () => {
// console.log(1)
// }
// test()
}, [cardData])
})
}
3.3 处理请求业务
TaskService服务中包含高任务和低任务,根据weight权重来判断加入高任务List中还是低任务List中;
/**
* 画卡、截图服务
* @export
* @class TaskService
* @extends {ServiceExt}
*/
@Injectable()
export class TaskService extends ServiceExt {
private phantomTask: PhantomTask
// 高任务key
private HEIGH_CARD_KEY: string = 'HEIGH_CARD_KEY'
// 低任务key
private SHARE_CARD_DATA: string = 'SHARE_CARD_DATA'
// 当前key
private currentRedisKey: string = this.SHARE_CARD_DATA
// 用于存储当前画卡
private currentTaskData: any = null
// 判断是否有新任务添加进来
private isJoin: boolean = false
// 用于标识是否清除page实例
private isClear: boolean = false
// 倒计时
private timer: any = null
constructor(
private readonly httpService: HttpService, //
private readonly cacheService: CacheService,
) {
super()
this.phantomTask = new PhantomTask()
}
...
}
处理接口请求数据,根据当前时间生成唯一标识,用于后续画卡任务判断。把数据根据级别存储到Redis中。如果当前没有任务进行就创建。
/**
* 画卡
* @param {*} body
* @memberof TaskService
*/
public async cardTask(body: TaskDrawModel) {
this.isJoin = true
this.isClear && (this.isClear = false)
// 产生唯一标识, 用于后续逻辑判断
body.taskKey = Date.now() + '' + Math.round(Math.random() * 1000)
const data = JSON.stringify(body).replace(/\u2028/g, '')
// 更加权重追加到相应的List数据中
const key = Object.is(body.weight, 'max') ? this.HEIGH_CARD_KEY : this.SHARE_CARD_DATA
try {
// 添加到redis中
const status = await this.cacheService.lpush(key, data)
setTimeout(async () => {
// 如果不存在任务,就创建phantomTask
if (!this.phantomTask.phantomInstance) {
await this.phantomTask.initTask()
// 更新任务key
this.currentRedisKey = key
this.taskRun(true)
}
this.isJoin = false
}, 10)
return { code: 200, msg: '添加任务到成功'}
} catch (error) {
return { msg: '添加任务到队列失败' }
}
}
Promise.race([])接受两个Promise,第一个用于当前任务运行,第二个用于防止任务被卡住影响所有画卡任务。
/**
* 获取Redis数据长度判断是否有任务存在。
* @private
* @param {boolean} [flag]
* @memberof TaskService
*/
private async taskRun(flag?: boolean) {
try {
// 判断当前是否存在高任务
if (this.currentRedisKey !== this.HEIGH_CARD_KEY && !flag) {
await this.heightTask()
}
// 获取任务长度
const len = await this.cacheService.llen(this.currentRedisKey)
if (len > 0) {
// tslint:disable-next-line:prefer-const
console.log('[task log (share card)]: key=', this.currentRedisKey , ' 当前任务余量 ', len)
Promise.race([
new Promise(async (resolve, reject) => {
try {
// 获取画卡数据触发画卡
await this.createTask()
clearTimeout(this.timer)
resolve('')
} catch (error) {
reject(error)
}
}),
new Promise(async (resolve, reject) => {
try {
// 防止某个任务被卡住影响所有画卡任务
await this.taskOvertime()
} catch (error) {
reject(error)
}
}),
]).then(() => {
this.currentTaskData = null
if (this.isClear && !this.isJoin) {
console.log('[task log (share card)], 当前所有任务已完成,不在重新递归')
} else {
this.taskRun()
}
}).catch((error) => {
const backData = JSON.stringify(this.currentTaskData) + '\n'
console.error('任务失败, data=', backData, ' error info=', error)
this.currentTaskData = null
this.taskRun()
})
} else {
this.clearTask()
console.log('[task log (share card)]: 当前没有任何数据了!!!')
}
} catch (error) {
console.error('[task log (share card)]: 查询剩余任务数量出错', error)
console.log('[task log (share card)]: 尝试重新查询...')
setTimeout(() => {
this.taskRun()
}, 1000)
}
}
判断是否有高任务存在,存在优先运行高任务
/**
* 判断是否有高任务存在,存在优先运行高任务
* @private
* @memberof TaskService
*/
private async heightTask() {
const len = await this.cacheService.llen(this.HEIGH_CARD_KEY)
this.currentRedisKey = len > 0 ? this.HEIGH_CARD_KEY : this.SHARE_CARD_DATA
}
/**
* 读取数据开始任务
* @private
* @memberof TaskService
*/
private async createTask() {
try {
const _serverRequestStartTime: [number, number] = process.hrtime()
const result = await this.cacheService.rpop(this.currentRedisKey)
const _serverRequestEndTime: [number, number] = process.hrtime()
const ms = (_serverRequestEndTime[0] - _serverRequestStartTime[0]) * 1e3 + (_serverRequestEndTime[1] - _serverRequestStartTime[1]) * 1e-6
console.log('[task log (share card)]: 读取数据成功用时: ', ms, 'ms')
if (result) {
console.log('[task log (share card)]: 开始执行任务--', result)
const shareData = JSON.parse(result)
// 当前正在画卡的图片
this.currentTaskData = shareData
const taskKey = this.currentTaskData.taskKey
// 触发前端画卡
const imageUrl = await this.phantomTask.createShareCard(shareData.drawData)
console.log(`taskKey=${taskKey} --- shareData.taskKey=${shareData.taskKey}`)
// taskKey判断任务是否一致,主要为了防止任务超时
if (taskKey === shareData.taskKey) {
console.log('画卡成功开始请求回调接口---------')
this.requestCallback(imageUrl, shareData)
} else {
console.log('任务已超时,不做推卡处理')
}
} else { // 如果列队中没有存在任务时
console.log('[task log (share card)] 队列中没有任务堆积, data = null')
this.clearTask()
}
} catch (error) {
console.log('[task log (share card)],读取数据报错:', error)
// 抛出异常
throw new Error(error)
}
}
/**
* 用于处理任务超时问题,阻塞画卡进度
* @private
* @memberof TaskService
*/
private taskOvertime() {
const _serverRequestStartTime: [number, number] = process.hrtime()
return new Promise((resolve, reject) => {
this.timer = setTimeout(async () => {
const _serverRequestEndTime = process.hrtime()
const ms = (_serverRequestEndTime[0] - _serverRequestStartTime[0]) * 1e3 + (_serverRequestEndTime[1] - _serverRequestStartTime[1]) * 1e-6
console.error('[task log (share card)]: 失败用时: ', ms, 'ms')
if (!this.currentTaskData.hadCached) {
console.log('超时任务,push到redis后重试, data=', JSON.stringify(this.currentTaskData))
this.currentTaskData.hadCached = true
await this.cacheService.lpush(this.HEIGH_CARD_KEY, JSON.stringify(this.currentTaskData))
} else {
console.log('任务已经被重试过,不再重试任务')
}
clearTimeout(this.timer)
reject('create card timeout.超时!')
}, 5000)
})
}
/**
* 清除任务
* @private
* @memberof TaskService
*/
private async clearTask() {
const len: number = await this.cacheService.llen(this.SHARE_CARD_DATA)
const len1: number = await this.cacheService.llen(this.HEIGH_CARD_KEY)
console.log(`[task log] 重新获取所有任务:SHARE_CARD_DATA=== ${len}, HEIGH_CARD_KEY=== ${len1}, 当前是否有新的任务添加进来:isJoin=${ this.isJoin }`)
// 如果所有任务都没有,并且也没有新的任务添加进来,则清除
if (len === 0 && len1 === 0 && !this.isJoin) {
this.phantomTask.phantomInstance.exit()
this.phantomTask.phantomInstance = null
this.phantomTask.clientPage = null
this.isClear = true
} else {
// 如果没有新任务添加, 则查询缓存,否则获取最新的任务
if (len > 0) {
this.currentRedisKey = this.SHARE_CARD_DATA
}
if (len1 > 0) {
this.currentRedisKey = this.HEIGH_CARD_KEY
}
}
}
画卡成功后的回调,使用httpService回调接口返回画卡结果。
/**
* 画卡成功后的回调
* @private
* @param {string} imgUrl
* @param {TaskDrawModel} data
* @memberof TaskService
*/
private requestCallback(imgUrl: string, data: TaskDrawModel) {
const parmas = {
userId: data.userId,
taskId: data.taskId,
imgUrl
}
this.httpService.post(data.cbApi, {...parmas}).pipe(
retry(3), // 重试三次
catchError(val => of('fail'))
).subscribe((val) => {
if(val !== 'fail') {
console.log('回调成功')
} else {
console.error('回调失败')
}
})
}
最后
本文到此结束。希望对你有帮助。本文主要说明怎么实现node层服务画卡功能,下篇讲解介绍画卡管理后台业务。
小编第一次写文章文笔有限、才疏学浅,文中如有不正之处,万望告知。