数据推送上线 ,抢先体验,欢迎来用! 支持钉钉、飞书!

253 阅读9分钟

大多数业务都会有定期推送业务信息至钉钉、飞书、Teams 群的需求,有些信息要推三个群、要推两个群、有些信息要 at 人、有些要当天、有些要当月,不但要管理多个推送的 Webhook,还要管理推送的内容、监控推送是否生效等等,DataWorks 新推出的数据推送能减轻以上问题,还能助力快速完成推送内容开发,支持规范的上线流程,快来体验 --> 传送门

ahad7yzn7h66m_1127d8affaaa40bab64d1c9c542ca55a.png

推送效果如下

ahad7yzn7h66m_bbfeacc744c3498e93c2dcce8573e83f.png

富文本内容推送

支持多种数据源

目前支持 MySQL、PostgreSQL、Hologres、MaxCompute、ClickHouse 等数据源,撰写多段业务 SQL,利用 DataWorks 调度资源组,弹性设置推送周期,将数据推送至各个推送对象,数据源连接、调度问题,DataWorks 数据推送都封装处理,用户只需关注推送的内容即可。

ahad7yzn7h66m_a68ef324c856495c86d59c3db392b411.png

ahad7yzn7h66m_87e7da4249e14e3294e40f704d6c2223.png

ahad7yzn7h66m_e6d2c7dedf44489787b862fb2d0db5df.png

高度弹性的变量设计

将业务数据推送至推送内容需要高度弹性的变量才能符合各式各样的内容设计,首先,DataWorks 数据推送支持调度资源组的调度参数并提供自动 SQL 参数解析与手动添加参数模式 ,让用户能自由定义参数,再者,我们基于 facebook 开源的 lexical (技术解密),在富文本编辑器上提供选取参数的交互方式与高亮,降低使用参数的学习门槛。

支持调度参数

透过右侧面板定义参数值,支持调度参数格式,如 ${yyyymmdd} 代表今日,${yyyymmdd-1} 代表前一天,除了能用在 SQL 的运行内容上,并能运用在推送内容上,用户能使用参数弹性组合内容。

image.png

支持自动解析 SQL 参数

当 SQL 编辑器开启自动解析参数 (上方工作条的蓝色开关) 后,能侦测 SQL 的输出参数 (如 select 的字段) 与赋值参数 (如 ${var}),并自动增加至右侧面版的参数栏,用户能在推送内容上选到侦测到的变量 (支持键盘提示与插入按钮)。

ahad7yzn7h66m_82018f8d896f4bf1bb6f48fb085f3f2e.gif

ahad7yzn7h66m_394218203fa542e194ba07e09021fc80 (1).gif

支持手动添加参数与常数

若参数不存在于 SQL 或参数值为常数,可关闭自动解析参数 (上方工作条的蓝色开关) 后,使用手动添加的方式,自由维护参数。

ahad7yzn7h66m_a6296fa81d324eab9946db62516e2fa8.gif

富文本编辑器支持 ${var} 格式

Markdown 编辑器一般 不支持 ${var} 格式,我们使用基于 facebook 开源的 lexical 来建构富文本编辑器,并增加对 ${var} 格式的支持。

ahad7yzn7h66m_dc4b07dd20c94c83a7dd03a5b03b5173.gif

支持 Emoji 让内容更生动

不同的推送渠道对 Emoji 的支持也不一樣,如飞书與釘釘都能直接推送 Emoji 图标,但钉钉還可以用 [Emoji] 格式來展示釘釘自己的圖標,为了让用户能方便运用 Emoji,我们提供了插入通用 Emoji 按钮与插入钉钉 Emoji 按钮,用户只需要选择插入即可。

ahad7yzn7h66m_0aaffdb46b1e46cf9642400403646a1b.gif

ahad7yzn7h66m_6b6e16444b9f468899a4d8539d243223.gif

@ 你想 at

飞书支持 Markdown 里使用 与 的方式来 at 用户,我们修改 lexical 支持将上述两格式转成 @name 的格式方便用户识别。

ahad7yzn7h66m_59c4d68c452a46b181d1c7fb2c1a5fc4.png

支持查看源码

ahad7yzn7h66m_7abb51dff1034e4989e2c0842615d2c8.png 在富文本上方工作条的最右边提供切换富文本与源码模式按钮,能仔细查看推送的 Markdown 源码,方便排查问题,也支持想尝试进阶用法的用户,如图片链接,就能结合上述所说的变量与图表 API 结合函数计算玩出进阶应用 (下文描述)。

ahad7yzn7h66m_84481629c5914cf59ef311dd23255b05.png

ahad7yzn7h66m_77d6425f03a743eaa1018e224626a352.png

表格推送

适配多种渠道

不同渠道对表格的撰写方式不同,如飞书的表格需要使用定制的 JSON 格式,Teams 使用标准 Markdown 表格语法,钉钉 PC 版支持标准 Markdown 表格语法但手机版不支持。为了能最大化适配不一样的推送渠道,初期我们支持飞书表格与标准 Markdown 表格语法,用户只需要增加表格组件,其馀由数据推送依据渠道类型判断。 用户编写的内容

ahad7yzn7h66m_fc08a19ea3a74076be7dea691b5f144d.png

推送的结果 (一样的设定,飞书与钉钉的推送内容如下)

ahad7yzn7h66m_a827c8ce21934e59acbe44622476f027.png

ahad7yzn7h66m_2dbd2d73771144aba44b0daad20c84a2.png

简易的设定方式

数据推送的表格组件支持选用右侧面版所列的输出参数与赋值参数作为依赖字段,每个字段除提供拖拽排序外,还能进阶设定每一个字段展示的内容,如根据条件变换颜色、附加标示符、加上前缀或后缀及修改表头展示名等。

ahad7yzn7h66m_1278bac670a44c3e9ea1744cdb42a4e8.png

可视化图表推送

可视化图表推送需要借助 Markdown 图片链接的方式,透过图片链接就能实现许多进阶玩法 (注: 飞书的部份无法直接使用外部的图片链接,需要先将图片上传取得图片 key,并以此做为图片链接)。

图表 API

Chart API 已是不是新的东西,GoogleQuickChart 都有出相关的产品 ( Google 的部份已表明不维护),若要自己搭建其难度也不高,以下使用函数计算 + Chart.js 为例,原理是将 Chart.js 绘制的 canvas 对象,在 Node Server 上保存为图片并透过 HTTP 输出。 我们以 Bar Chart 为例,建立一个图表 API。新建一个函数,使用 Node.js 16 示例代码。

ahad7yzn7h66m_caefb731fde64bbeacd9afa22f892db3.png

增加 HTTP GET 触发器。

ahad7yzn7h66m_498be0e152c1410a851118dac9715e82.png

于函数配置的环境变量增加字体配置, "FONTCONFIG_FILE": "/code/fonts.conf"

ahad7yzn7h66m_b54ab50d99e7480382b4cf943feb2e54.png

至线上 vscode,增加 fonts.conf 以及加入想要的字体,将字体 ttf 档拖拽进编辑器左侧目录树。

<match target="pattern">
  <test name="family">
    <string>sans-serif</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Microsoft YaHei</string>
    <string>Open Sans Bold</string>
    <string>Noto Sans CJK SC</string>
    <string>Noto Sans</string>
    <string>Twemoji</string>
  </edit>
</match>

ahad7yzn7h66m_9449bc3ccb9247758fe887db66f3846a.png

于 index.js 代码里让 canvas 注册字型。

const { registerFont } = require('canvas');
registerFont('./Sora-Bold.ttf', {
  family: 'Sora',
});
registerFont('./Microsoft YaHei.ttf', {
  family: 'Microsoft YaHei',
});

图表 API 完整的 index.js 如下。

const Chart = require('chart.js/auto');
const colorLib = require('@kurkle/color');
const fs = require('fs');
const express = require('express');
const bodyParser = require('body-parser');
const { createCanvas, registerFont } = require('canvas');

// registerFont('./Sora-Bold.ttf', {
//   family: 'Sora',
// });

// 如有用到中文需要放入中文字体
// registerFont('./Microsoft YaHei.ttf', {
//   family: 'Microsoft YaHei',
// });

const app = express();

app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(bodyParser.raw());

const port = 9000;

const tempPath = '/tmp';

async function saveChartImage(filePath, canvas) {
  return new Promise(async (resolve, reject) => {

    if (!fs.existsSync(tempPath)) {
      fs.mkdirSync(tempPath, { recursive: true });
    }

    const out = fs.createWriteStream(filePath);
    const stream = canvas.createPNGStream({
      compressionLevel: 9, // PNG employs a lossless compression algorithm
      resolution: 300,
    });
    stream.pipe(out);

    out.on('finish', () => {
      console.log(`[${Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}] The chart image was saved: ${filePath}`);
      resolve();
    });

    out.on('error', reject);
  });
}

const Utils = {
  transparentize(value, opacity) {
    var alpha = opacity === undefined ? 0.5 : 1 - opacity;
    return colorLib(value).alpha(alpha).rgbString();
  },
  CHART_COLORS: {
    red: 'rgb(255, 99, 132)',
    orange: 'rgb(255, 159, 64)',
    yellow: 'rgb(255, 205, 86)',
    green: 'rgb(75, 192, 192)',
    blue: 'rgb(54, 162, 235)',
    purple: 'rgb(153, 102, 255)',
    grey: 'rgb(201, 203, 207)'
  }
};

const getChartData = async ({
  part1Today,
  part1Yesterday,
  part2Today,
  part2Yesterday,
}) => {

  const data = [
    { date: 'yesterday', name: 'part1', value: part1Yesterday },
    { date: 'today', name: 'part1', value: part1Today },
    { date: 'yesterday', name: 'part2', value: part2Yesterday },
    { date: 'today', name: 'part2', value: part2Today },
  ];

  return (
    {
      labels: ['yesterday', 'today'],
      datasets: [
        {
          label: 'part1',
          data: data.filter((d) => d.name === 'part1').map(row => row.value),
          borderColor: Utils.CHART_COLORS.blue,
          backgroundColor: Utils.transparentize(Utils.CHART_COLORS.blue, 0.5),
        },
        {
          label: 'part2',
          data: data.filter((d) => d.name === 'part2').map(row => row.value),
          borderColor: Utils.CHART_COLORS.red,
          backgroundColor: Utils.transparentize(Utils.CHART_COLORS.red, 0.5),
        }
      ]
    }
  );
};

// Bar Chart 的配置
const getChartConfigs = () => {
  return {
    type: 'bar',
    options: {
      responsive: true,
      plugins: {
        customCanvasBackgroundColor: {
          color: 'white',
        },
        legend: {
          position: 'top',
        },
        title: {
          display: true,
          text: 'Chart 展示'
        }
      }
    }
  };
};

app.get('/chart-api', async (req, res) => {

  const width = 300;
  const height = 200;
  const canvas = createCanvas(width, height);
  const ctx = canvas.getContext('2d');
  ctx.font = "43px Microsoft YaHei";

  // API 参数
  const part1Today = req.query['part1Today'];
  const part1Yesterday = req.query['part1Yesterday'];
  const part2Today = req.query['part2Today'];
  const part2Yesterday = req.query['part2Yesterday'];

  const data = await getChartData({
    part1Today,
    part1Yesterday,
    part2Today,
    part2Yesterday,
  });

  const chartConfig = {
    ...getChartConfigs(),
    data,
  };

  // 绘制图片
  await (async function () {
    new Chart(
      ctx,
      chartConfig,
    );
  })();

  const filePath = `${tempPath}/chart.png`;
  await saveChartImage(filePath, canvas);

  // 回送图表给请求方
  const contentType = 'image/png';
  // res.setStatusCode(200);
  res.setHeader('content-type', contentType);
  if (req.headers['Accept'] === '*/*' || req.headers['accept'] === '*/*') {
    res.send("success");
  } else {
    res.send(fs.readFileSync(filePath));
  }

});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
})

app.timeout = 0; // never timeout
app.keepAliveTimeout = 0; // keepalive, never timeout

层管理创建图表 API 需要的依赖资源。

{
  "dependencies": {
    "@kurkle/color": "0.3.2",
    "canvas": "2.11.2",
    "chart.js": "4.3.0"
  }
}

ahad7yzn7h66m_c02f26d62ce44c7c8d1109673aed61e2.png

于函数配置的层增加上述资源层。

ahad7yzn7h66m_dc52b7caa7fd40fda9505e96dfc04b47.png

部署代码进行测试。

ahad7yzn7h66m_fee2a9f750124b3da180ddd0296b3cf3.png

至触发器管理取得公网 URL,并加上 /chart-api 进行 GET 请求,取得图片,如下例 (我们提供的示例代码挖了四个参数并填入图表,此部份可自由修改)。

ahad7yzn7h66m_976390c432af48d5bc26b397377e08f1.png

有了图表 API 后,回到数据推送,将图片链接放入,并将参数的常数改为推送的参数即可。

ahad7yzn7h66m_06166452f5f443dc930b356f6e432264.png

DataV Card

图表 API 的方式能为推送带来可视化的内容,但是可视化配置与参数设计不易,数据分析已经提供了 SQL + DataV Card 的能力,用户能在数据分析上取得分析的结果与图表,并打开 DataV Card 的分享链接,我们能透过函数计算建立一个截屏 API,将此链接的内容推送出去。 取得数据分析 DataV Card 的分享链接。

ahad7yzn7h66m_14ae110d67f044eabc92ff3154cfdf91.png

首先创建一个函数。

ahad7yzn7h66m_52aabf1e409b4a849f24713a0f99d097.png

使用 Node.js 16,并选用截图示例代码。

ahad7yzn7h66m_56a2e92739704110896739b8da7f6859.png

设置 HTTP GET 触发器

ahad7yzn7h66m_1f8be78726e94d9996da6a251d1bbf04.png

新建后,部署函数并取得 HTTP 触发器的公网 URL,示例代码参数默认为 url,如下例取得百度首页截图。

https://url?url=www.baidu.com

因为示例代码没有做快取判断,下面代码增加快取代码判断,与增加 key 参数值来刷新快取 (key 为快取的 key),另外透过图片的创建时间来判断是否自动过期 (默认 1 天)。

https://url?url=www.baidu.com&key=20240505

具备快取能力的截图代码如下。

const fs = require('fs');
const puppeteer = require('puppeteer');

function autoScroll(page) {
  return page.evaluate(() => {
    return new Promise((resolve, reject) => {
      var totalHeight = 0;
      var distance = 100;
      var timer = setInterval(() => {
        var scrollHeight = document.body.scrollHeight;
        window.scrollBy(0, distance);
        totalHeight += distance;
        if (totalHeight >= scrollHeight) {
          clearInterval(timer);
          resolve();
        }
      }, 100);
    })
  });
}

module.exports.handler = function (request, response, context) {

  (async () => {

    const url = request?.query?.['url'] || request?.queries?.['url'];
    const key = (request?.query?.['key'] || request?.queries?.['key']) || 'default';

    const path = `/tmp/screenshot-${key}`;
    const contentType = 'image/png';

    const current = new Date();

    const screen = async (url) => {
      const browser = await puppeteer.launch({
        headless: true,
        args: [
          '--disable-gpu',
          '--disable-dev-shm-usage',
          '--disable-setuid-sandbox',
          '--no-first-run',
          '--no-zygote',
          '--no-sandbox',
          '--single-process'
        ]
      });

      if (!url.startsWith('https://') && !url.startsWith('http://')) {
        url = 'http://' + url;
      }

      const page = await browser.newPage();

      await page.emulateTimezone('Asia/Shanghai');
      await page.goto(url, {
        'waitUntil': 'networkidle2'
      });
      await page.setViewport({
        width: 800,
        height: 600
      });
      await autoScroll(page)

      await page.screenshot({ path: path, fullPage: true, type: 'png' });
      await browser.close();

      response.setStatusCode(200);
      response.setHeader('content-type', contentType);

      if (request.headers['Accept'] === '*/*' || request.headers['accept'] === '*/*') {
        response.send("success")
      } else {
        response.send(fs.readFileSync(path))
      }
    }

    if (url) {
      if (fs.existsSync(path)) {
        // exist
        const result = fs.statSync(path);
        // 透过图片创建的时间,来判断是否过期,内置为 1 天
        if (current.getTime() - result.ctimeMs < (1 * 24 * 60 * 60 * 1000)) {
          response.setStatusCode(200);
          response.setHeader('content-type', contentType);
          if (request.headers['Accept'] === '*/*' || request.headers['accept'] === '*/*') {
            response.send("success");
          } else {
            response.send(fs.readFileSync(path));
          }
          console.log('get from cache');
        } else {
          await screen(url);
        }
      } else {
        await screen(url);
      }
    } else {
      response.setStatusCode(404);
      response.send("failed");
    }

  })().catch(err => {
    response.setStatusCode(500);
    response.setHeader('content-type', 'text/plain');

    response.send(err.message);
  });
};

将图片链接放至数据推送的内容里,即可推送 DataV Card。

ahad7yzn7h66m_bab06fa1ec0e4909bdb8edb793d02ce5.png

注:函数计算提供的 HTTP 触发器域名为阿里云的域名,其请求返回表头默认为下载行为,会影响图表或截图 API 在钉钉或飞书等渠道的展示行为(如钉钉 PC 版,点击图片后无法展开图片),需要使用自定义域名才能避免请求的返回表头为下载行为,参考相关文章设置自定义域名

支持多种渠道,推送对象、推送节点、与调度资源组分别管理

目前已开放钉钉与飞书两个推送对象 ( Webhook ),以项目为单位统一管理推送对象,各个推送节点可以绑定多个推送对象,并选用一个调度资源组。

ahad7yzn7h66m_f7d0d54fd05d49748ab4ced680d19082.png

ahad7yzn7h66m_f4d182493c274efe8141e9b8d389489a.png

规范的节点开发流程:开发态、生产态、版本管理

推送节点具开发态 (草稿)、生产态 (已上线),透过版本管理查看各版本详情,并能回滚选用版本。规范的节点开发流程能帮助用户调试渠道的推送内容与不同类型渠道的内容适配,如开发态的节点推送至调试用途的推送对象 ( Webhook ),等内容确定后,再上线推送到正式的推送对象 ( Webhook )。

ahad7yzn7h66m_c3ff1c41a3084e8a9ddbefa030a9d632.png

开发态测试

ahad7yzn7h66m_d9bdd8840f3747cb9b2798fb20e66184.png

生产态测试

ahad7yzn7h66m_8f7578671698402bb9526da2f6606eae.png

生产态节点管理

ahad7yzn7h66m_a08cdea8b57b4fe1adfc9d20cd3fc8e0.png

即将

数据推送仍然有许多功能正在开发中,如推送实例管理、更快上手的推送开发 (向导模式)、更简易的可视化图表推送 (封装上述函数计算设定的部份)、封装飞书需处理图片上传的过程、功能合并到数据分析与数据开发里、支持更多类型渠道等。也期待更多您的需求反馈:DataWorks工单需求 快来体验数据推送吧! --> 传送门