肝个斗图机器人(imeme),打败隔壁小胖墩

小知识,大挑战!本文正在参与「程序员必备小知识」创作活动。本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

前言

有一天,组织内的斗图机器人坏掉了,巧不巧的是当你需要用它时,它坏掉了。

赶上要催交同学们的周报,没有表情包,就没办法委婉又不礼仪并友好和善的催促同学们交周报。

然后只能自己做图,打开了度娘,找合适表情,然后打开sketch,一通操作后,粘贴到群,搞定。

but总使用同一表情,又很枯燥,于是又打开度娘,打开sketch,一通操作,粘贴到群,搞定。

过了一段时间,度娘,sketch,群。

又过了一段时间,度娘,sketch,群。

时间一长就会很烦躁,每次都要这样搞半天(难道喜新厌旧属性?感觉像渣男?)

后来,突然开朗。求人不如求己!发挥我的主观能动性!自己敲一个!

于是在经历Node的洗礼,Color的洗礼,Canvas的洗礼,SQL的洗礼,Docker的洗礼,Vercel的洗礼后,它诞生了。

它叫imeme,是一个斗图机器人。

本文目的

给大家介绍如何设计和实现一款斗图机器人,是有前端有后端的全栈开发。

不会讲的

  • 涉及到安全问题、隐私以及制度政策等原因,机器人的接收消息内容不介绍

  • 具体功能演示,不提供截图展示,可自行体验

  • 不会详细讲清楚每一个实现细节

But,这些限制要素无关紧要,不影响全局,也不影响大家搭建自己的机器人。

重点讲解

  • 机器人的技术选型

  • 关键环节的设计思路及相关知识点。

场景还原

使用markdown还原下真实交互场景

image.png

技术选型

明确目标,鼓舞斗志。

那么应该如何设计主体流程?先从最基本的功能入手,列下需求清单:

  • Server,用来接收命令,发送消息。
  • 绘图功能,能够把文字和图片做成一张图。
  • 图片处理,不同的图片类型采取不同策略,获取最基本的图片信息。
  • 数据存储,作为数据源,提供各种有意思的基础图片及与绘图相关的基本参数。
  • 录入导出,便于数据采集,迎支持插入多条数据以及数据库的备份。
  • UI,让imeme用起来更轻松,便于管理数据源,查看图片以及调整绘图参数,还应支持交互式新增和图片下载。

针对如上特点:

  1. Server端,基于express实现node服务,axios + canvas + sql.js。
  2. UI端,vite + vue3.x + typescript设计实现,并提供lib库供多端快捷接入。

整体架构图

简单怼了一张图

image.png

界面管理就很常见了,大致长这样

image.png

关键环节的设计思路

所有源码,链接在文末参考资料中,在github上。服务部署到vercel,可访问体验Web端(网速不稳定,毕竟白嫖vercel)

Server

Server要实现,接收到消息命令请求后,绘制图形,并能够给出合理结果反馈,也就是新的图像。所以基于express实现node服务,接口的设计要求如下:

  1. /test 用于测试服务的可用性,get请求。
  2. 设置origin * 允许接口的跨域请求以及多种请求头,默认编码utf-8。
  3. 为Chat端提供的/send,post发送Webhooks消息体。
  4. 为Web端提供的/image/*接口
    • /catalog用于目录获取,读取数据库中存储的图片源列表显示。
    • /open 打开用户选中的列表内容,接口返回图片基本信息(base64及绘图数据)。
    • /save 绘图数据的保存接口,用于图片拖拽编辑后,把最新数据同步到数据库中。
    • /create 新建表情,保存到数据库。
    • /update 更新表情数据
    • /download 下载接口,用户拖拽好的内容,可以直接下载到本地。
    • /export 数据导出备份

Server的接口逻辑在service模块,分为四个层次

  • router.js api接口层,管理服务提供的所有api。
  • data.js 连接接口和数据库的数据层,数据封装,为api提供数据获取服务。
  • ajax.js 请求结果集封装,根据data.js请求,给出结果反馈信息。
  • send.jsChat端提供的发送消息服务

绘图

简单的讲,表情就是图片加文字,即我们常见的水印,选择使用canvas来处理。

Node本身不具备canvas的能力,需要借助canvas库来实现基本的绘图能力。

本部分内容在convert模块,主要提供给Chat端使用。

Web端不需要这些,对于浏览器来讲,canvas绘图小菜一碟,属于基本操作。

这里按照功能逻辑设计,分为4个层次:

  • make.js 提供绘图能力,支持图片本地保持。
  • size.js 根据base64串获取图片的widht和height。
  • format.js 菜单格式化,无效命令反馈。
  • parser.js 解析接收到的请求命令。

一个完整的水印图,由很多部分组成,拆解为base64编码的图片,水印文字,文字的位置横纵坐标,文字的颜色,字体大小,对齐方向,最大宽度。

绘图,就是把上述已知信息整合到一起

const make = (text, options) => {
  const base64Img = options.image;
  const parts = base64Img.split(';base64,');
  const type = parts[0].split(':').pop();

  if (NOT_SUPPORT.includes(type) || text === '') {
    return base64Img;
  }

  let base64 = '';
  const {width, height} = getSize(base64Img);

  if (width && height) {
    const img = new Image();
    const canvas = createCanvas(width, height);
    const ctx = canvas.getContext('2d');

    img.onload = () => {
      ctx.drawImage(img, 0, 0);

      const {x, y, font, color, align, max} = options;
      ctx.font = font;
      ctx.fillStyle = color;
      ctx.textAlign = align;
      ctx.fillText(text, x, y, max || width);

      base64 = canvas.toDataURL(type);
    };
    img.onerror = err => {
      console.error(err);
    };
    img.src = base64Img;
  }
  return base64;
};
复制代码

首先,根据base64编码,获取图片内容的基本类型,不同类型的图片,需要不同的解析流程。对于暂不支持水印功能的图片格式或者空命令的请求,直接返回base64原始编码。

接下来,调用size.js中的getSize获取图片的width和height,创建固定大小的canvas画布,进一步,得到ctx。

因为水印中图片在下层,文字在上层,所以先通过ctx.drawImage(img, 0, 0)绘制原始图片,再结合ctx.fillText(text, x, y, max || width)在(x, y)点,绘制最大长度为max的文字信息。

最后,通过base64 = canvas.toDataURL(type)生产出我们需要的绘图后的base64编码。

另外,在make.js中还提供了writeImg方法,可用于在开发中及时本地调试位置参数信息,检测生产的图片是否满足要求。(已经提供UI的交互式调整,解放了本地调试的痛苦)

图片尺寸

这部分内容在size.js,原理是根据base64的buffer,提取image的width和height。

针对不同格式的图片,要采取不同的处理策略,imeme目前提供5种(png/jpg/jpeg/gif/bpm)图片格式的处理,我们以png为例来说明,如何根据图片的buffer获取,真实的尺寸。

这里,你需要一点点的node buffer知识,以及了解简单的图片编码原理。

每种类型的文件都有自己独特的标识,直观上通过文件的扩展名来区分类型,然而扩展名可以随意的更改。所有的文件在计算机上都是以二进制方式存储的,我们可以通过分析标识头来确定文件类型。

我们本地查看任意一个png文件,用十六进制编辑器打开(可使用vscode的hexdump

image.png

我们分析下前两行内容

  • 89 50 4E 47 0D 0A 1A 0A png文件的标识头
  • 00 00 00 0D IHDR头块长度为13 bytes
  • 49 48 44 52 IHDR标识
  • 00 00 00 BC width,换算成十进制为188(16 * 11 + 12)px
  • 00 00 00 C4 height,换算成十进制为196(16 * 12 + 4)px
  • 08 色深,换算下即2^8=256,即256色的图像
  • 06 颜色类型,6表示,带α通道数据的真彩色图像
  • 00 压缩方法,LZ77派生算法(PNG Spec规定此处总为0,非0值为将来使用更好的压缩方法预留)
  • 00 滤波器方法,总为0,同上
  • 00 隔行扫描方法,0表示采用非隔行扫描
  • 25 38 3B 07 4个byte的CRC校验

image.png

在MacOS可以通过file快速查看1.png

$ file 1.png 
1.png: PNG image data, 188 x 196, 8-bit/color RGBA, non-interlaced
复制代码
  • width位于第16个byte,长度是4bytes
  • height位于第20个byte,长度是4bytes
const getPNGSize = buffer => {
  let w = 16;
  let h = 20;
  return {
    width: buffer.readUInt32BE(w),
    height: buffer.readUInt32BE(h)
  };
};
复制代码

buffer又是什么?

我们简化一下base64图片格式,还是以png为例讲解

data:image/png;base64,CODE
复制代码

对base64编码的图片字符串,解析,获取到CODE内容,然后使用Buffer.from转换为'base64'编码的buffer

import {Buffer} from 'buffer';

const buffer = Buffer.from(CODE.toString(), 'base64');
复制代码

vscode还可以使用Hex Editor插件,能够更快捷的查看转码后的内容,同时也能够帮助buffer的转换提供一些思路。hexdump需要鼠标hover才会提示。

image.png

其他图片格式,同理可得!!(???说的好轻松???)

例如gif文件

image.png

const getGIFSize = buffer => {
  return {
    width: buffer.readUInt16LE(6),
    height: buffer.readUInt16LE(8)
  };
};
复制代码

image.png

DB

数据存储,使用SQLite,足够轻量,简单易学易用,需要引入sql.js

功能介绍

该部分在db模块,基本涵盖的功能可以概括为:

  • 数据库的初始化、读取、存储、重置
  • 数据表的初始化、查询、插入、更新和删除
  • 获取某表的一条数据
  • 获取某表的所有数据
  • 获取所有数据
  • 日志
表结构设计

表结构,目前设计了四张表

  • STORY 记录图片指令和base64的image
  • TEXT 记录图片对应的绘图信息,例如x, y, font, color等
  • LOGGER 日志表,主要收集imeme缺失的资源
  • SPECIAL 特殊表,表结构同STORY,用于保存彩蛋指令,像中秋节、国庆节这种关键字,Chat端通过@imeme是查询不到的,属于隐藏的key,使用@imeme 中秋 金馆长会随机返回一张图。
CREATE TABLE STORY (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  mid CHAR(50) NOT NULL,
  title CHAR(100) COLLATE NOCASE,
  feature CHAR(100) COLLATE NOCASE,
  image TEXT NOT NULL
)
复制代码
  • id 主键,自增,不用于其他操作
  • mid 唯一key,用于数据的各种操作
  • title 文件标题,图片指令,唯一
  • feature 所属类别,用于归类,很多title可以对应同一个feature
  • image base64 image

不同于MYSQL,由于SQLite是大小写不敏感的数据库,所以为省去后面使用上的麻烦,建表的时候,把所有字段都统一小写。

数据备份

灾备的话,目前仅提供基于脚本的方式备份数据,npm run backup,默认把常用表和特殊表的内容,转化成js文件,存储到指定位置,默认为assets/backup目录。(后续会支持数据库自动备份)

数据采集

提供两种方式的数据导入

  • npm run import fileName默认读取assets/fileName目录,获取满足格式要求的文件,转换为base64,并附加绘图基本信息,存储为fileName.js文件。

  • 交互式添加单个图片,自定义表情内容,支持选择、拖拽以及拷贝粘贴的方式添加新图。

图形界面

为了更加友好方便快捷的斗图,imeme需要配备一个管理端imeme-view,它主要做这些事:

  • 管理数据源,管理imeme所有的表情资源
  • 查看表情
  • 动态调整绘图参数,支持可拖拽本文编辑,实时查看
  • 新增表情,提供选择框,拖拽、拷贝粘贴三种方式导入
  • 下载,实时下载表情资源

image.png

部署

前端静态页依赖于Gitbhu Action托管在Github Pages,Node Server部署在Vercel

vue3 + vite

<script setup lang="ts">谁用谁知道,爽的不得了。

lib

为了便于imeme的任意部署和运维,提供imeme-view的lib输出,支持在多种(es/cjs/umd/iife)环境下的使用。

主要依赖于强大的vite + rollup。

  • npm run lib 构建生成各种格式的js库
  • vite.lib.config.ts 配置文件,指定基础构建目录和打包方式
  • .env.lib 环境变量
  • lib/index.ts lib包入口,提供load方法,用于加载替换DOM元素和提供服务的url地址
  • lib/index.html 使用示例

npm使用引入meme-view

成长

精疲力尽,受益匪浅。

  • 成长的路,如果有能够一起奋斗的伙伴固然难得,在大家做项目产品的团队中,与peer保持良好的合作关系,当我们遇到问题,就能够很方便求助解答,专业问题交给专业的同学(感谢2geng同学在专业领域给予的大力支持,希望他的第一篇博文再快些)。

  • 做好时间管理,前前后后用掉很多碎片时间,通勤的路上思考,半夜睡不着爬起来赶进度,放弃午睡,每天花一点点时间,努力搬砖。

  • 脚踏实地,慎始敬终,行稳致远,进而有为。

结语

有好的idea,就动手行动,不要让idea就是一个idea。

意见收集

大家如果想要什么表情,可以自己加,也可以留言,看到后会及时补充。更欢迎提交pr,提交issue。

还有一些功能在不断的丰富和完善。

  • 解决Web端canvas绘制gif不动
  • 增加gif格式的水印服务
  • 数据的定时备份
  • 数据源的下载
  • 资源内容太少,缺少欢迎新人系列、大胆想法系列,撤回也没用等等

参考资料

  1. png格式
  2. IHDR 文件头数据块
  3. 各类型文件的标识头
  4. imeme server 源码
  5. imeme view 源码
  6. imeme

猜你喜欢

转载自juejin.im/post/7018395454962401288