Node+Typescript+Phantom+Redis 实现服务端画卡以及截图功能(上)

1,708 阅读8分钟

本文主要围绕着各个项目中大量使用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层服务画卡功能,下篇讲解介绍画卡管理后台业务。

小编第一次写文章文笔有限、才疏学浅,文中如有不正之处,万望告知。