使用 Ruby 替代 Node.js 写一些脚本

ruby

根据不同的场景,我会使用不同语言来完成功能的编写。

对于一次性、低频、对于性能要求不高的批处理场景,过去我喜欢使用 Node.js 配合 NPM 来完成。

主要的原因是:

  1. Node.js 拥有丰富的包的生态,可以让我少写很多代码。
  2. npm run 命令比较短,可以方便的构建出需要的快速参数

而最近 Node.js 脚本写的太多,比较烦了,所以考虑用 Ruby 来替代 Node.js 写一些脚本,完成一些短期项目开发。

和 Node.js 相比,Ruby 有其好处,也有其坏处。好处在于

  1. 原生同步执行,我可以不用担心和关注 Callback Hell。虽然有了 async/await 时,已经好很多了。但还是原生的更好。
  2. 可以用更加简单的语法完成脚本。毕竟脚本主要还是随时修改随时可用,简短但能用的脚本可以提升脚本的可维护性。
项目Node.jsRuby
包管理器NPMGems
执行命令npm run xxx借助 Makefile 完成
第三方包的数量
异步/同步默认异步默认同步

接下来一段时间,就拿 Ruby 来跑脚本啦!

插件多次加载导致的 WordPres 后台加载缓慢

turned-on monitor

WordPress Jetpack 的一个陈年 Bug – 在 wp-Options 中生成大量的数据 中,我提到,问题的根源并不是数据库,虽然在数据库中产生了大量的数据,但并没有真正意义上拖慢系统的进程, 那到底是什么拖慢了进程?

1fp2qm

随着对系统的深入排查,我发现一个异常的事情,在进入管理后台时,出现了插件更新插件列表的情况。这个并不多见。因为管理后台的进入不应该涉及到对于插件列表的更新。此外,我发现这个查询的 SQL 巨长,且包含了大量的查询。

d2b5ca33bd970f64a6301fa75ae2eb22 32

于是评估,这个可能才是导致系统缓慢的真正原因。熟悉 WordPress 的读者一定知道, WordPress 对于插件的加载,是通过在数据库中存储了一组插件路径,每次启动时通过这组插件路径来加载插件。这样对于 WordPress 来说,可以快速加载插件。

但在这次的异常场景中,同一个内容的插件被加载了多次,就算这个插件比较小,可能依然会导致计算时间。此外,WordPress 引入插件后,会加载对应的 Hook 、 Filter 和 Action,则可能导致同一个 Action 被频繁触发,造成额外的计算量。从而使得虽然数据库查询时间不长,但整体耗时却巨长无比。

而这个问题的处理倒是也比较简单:

  1. 对 active_plugins 进行备份,避免修改跪了。
  2. 对于 active_plugins 进行清空,此时 WordPress 会彻底不加载任何插件。
  3. 仿照之前的列表,启用所有的插件。

通过对于active_plugins的清理,将启动速度成功降低至 2 秒左右,将系统性能提升了 80 %。

d2b5ca33bd970f64a6301fa75ae2eb22 36

WordPress Jetpack 的一个陈年 Bug – 在 wp-Options 中生成大量的数据

silver mercedes benz emblem on blue surface

最近在处理一个 WordPress 系统访问下降的问题时,发现了一个奇怪的现象:一个只有很少的页面的网站,数据库备份竟然足足有 9.5 GB。我当时的第一反应是:数据库性能极差导致的站点性能不好。

d2b5ca33bd970f64a6301fa75ae2eb22 29

不过,到数据库打开后发现, 虽然有大量的条目生成,但因为no autoload,所以其实并不会被自动加载到缓存中,从而也不会让网站的性能有太多的下降。

d2b5ca33bd970f64a6301fa75ae2eb22 31

想想也合理,数据库中包含了数十万条记录,如果都加载到内存里,可能 PHP 默认的 1024MB 的运行内存直接被打爆了,所以问题不在此。

不过,虽然问题的核心不是它,但如此海量的脏数据对于系统依然是无价值和无意义的,于是乎我便将这些脏数据删除,数据库的大小从 9.52 GB 骤降至 34.8 MB,进入到一个正常的数据库大小区间了。

删除脏数据的命令如下:

DELETE FROM wp_options WHERE option_name LIKE '%jpsq_sync-%'
Code language: JavaScript (javascript)

相关链接

  • https://wordpress.org/support/topic/wordpress-database-error-commands-out-of-syn/
  • https://wordpress.org/support/topic/jpsq_sync-table-constantly-generated-to-the-db/
  • https://gist.github.com/bhubbard/894040fec6421f891f1f88f2c6428ef0

使用飞书消息卡片变量功能,批量数据快速录入消息卡片

0c0ca4a0ac1f249860b29e295dd55260

在开发短链助手的时候,我需要实现一个查看当前用户创建的所有短链接的能力。这个依然希望通过消息卡片来完成。而作为一个 JSON,想要构建一套合适的内容,就变得十分的麻烦和复杂。

ql9bov

解构消息卡片

我要发送的消息卡片当中,可以区分为动态内容和静态内容,对于静态内容,我可能长期都不会变化,而静态内容,则会根据用户的数据发生变化。

wsyp71

如果整体都放在代码中生成,我就需要有一段又臭又长的代码来维护其中的变化的 JSON ,而我希望整个代码的简洁,不要有比较长的代码只是用来生成卡片的逻辑,所以就用上了消息卡片的新功能:循环对象数组。

而进一步看动态内容,则我们可以将其视为是变量 A 和变量 B 在不断的被重复赋予,最终形成了一行一行的结果。

而我们想要实现这样功能。首先,需要在卡片搭建工具中创建一个循环对象数组,并将其绑定在一个「多列布局」上。

dg2nvw

绑定完成后,你的多列布局就有了被循环的可能性。
h96z3l

接下来你需要在多列布局中去构建你的每一行的结果,并在对应的位置绑定上变量,比如我这里就给多列布局防止了一个 Markdown 文本组件,并在这个文本组件中,填入了 ${source} 作为变量 A 进行填充。

e3w12l

当你根据你的需要,构建出需要的卡片结构后,点击右上角的保存并发布,就可以准备写代码来实现批量发送数据的逻辑了。

代码片段

这里的逻辑不复杂,首先需要从数据库中提取出需要用用作列表循环的数据,这里以 data.data 为例,data.data 是一个包含了 Object 的 Array,其中每一个 Object 都有 Postfix 和 Link 两个字段。这两个字段就是我们稍后要塞在卡片中的。

       // data.data = [{Postfix:"a",Link:"https://amazon.cn"}]
        let links = data.data.map(item => {
          return {
            source: `[${item.Postfix}](https://link.feishu.io/${item.Postfix})`,
            target: item.Link
          }
        })
        await client.request({
          method: "POST",
          url: "https://open.feishu.cn/open-apis/im/v1/messages",
          data: {
            receive_id: ctx.body.event.operator.operator_id.open_id,
            msg_type: 'interactive',
            content: JSON.stringify({
              "type": "template",
              "data": {
                "template_id": "ctp_AAmFBm5vnHfs",
                "template_variable": {
                  "CONTENT": links
                }
              }
            }),
          },
          params: {
            receive_id_type: 'open_id',
          },
        })
Code language: JavaScript (javascript)

最终我们构建出来,发给飞书服务器的 JSON 其实是这样子的,这段 JSON 就会和我们在卡片搭建工具中构建的 JSON 租和,自动进行拼接,从而实现我们想要的循环效果。

{
  "type": "template",
  "data": {
    "template_id": "ctp_AAmFBm5vnHfs",
    "template_variable": {
      "CONTENT": [
        {
          "source":"a",
          "target":"https://amazon.cn"
        },
        {
          "source":"b",
          "target":"https://baidu.com"
        }
      ]
    }
  }
}
Code language: JSON / JSON with Comments (json)

文章中构建的出的卡片

构建出的卡片 JSON 是这样的,方便你参考:

{
  "elements": [
    {
      "tag": "markdown",
      "content": "你创建的链接如下:"
    },
    {
      "tag": "column_set",
      "flex_mode": "none",
      "background_style": "grey",
      "columns": [
        {
          "tag": "column",
          "width": "weighted",
          "weight": 1,
          "vertical_align": "top",
          "elements": [
            {
              "tag": "div",
              "text": {
                "content": "${source}",
                "tag": "lark_md"
              }
            }
          ]
        },
        {
          "tag": "column",
          "width": "weighted",
          "weight": 4,
          "vertical_align": "top",
          "elements": [
            {
              "tag": "div",
              "text": {
                "content": "${target}",
                "tag": "lark_md"
              }
            }
          ]
        }
      ],
      "_varloop": "${CONTENT}"
    }
  ],
  "header": {
    "template": "turquoise",
    "title": {
      "content": "链接清单",
      "tag": "plain_text"
    }
  },
  "card_link": {
    "url": "",
    "pc_url": "",
    "android_url": "",
    "ios_url": ""
  }
}
Code language: JSON / JSON with Comments (json)

完整代码参考

import cloud from '@lafjs/cloud'
import axios from 'axios'

let appid = "";
let secret = ""

const lark = require('@larksuiteoapi/node-sdk');

const client = new lark.Client({
  appId: appid,
  appSecret: secret
});


export default async function (ctx: FunctionContext) {

  console.log("event",ctx.body);

  if (ctx.body.challenge) {
    return ctx.body
  }

  if (Object.hasOwn(ctx.body, "action") && ctx.body.action) {
    if (ctx.body.action.name != "submit") return { code: 1 };
    try {
      // function to create link

      if (status == 200) {
        return JSON.stringify({
          "type": "template",
          "data": {
            "template_id": "ctp_AAmFBm5vnlt0",
            "template_variable": {
              "source": ctx.body.action.form_value.postfix,
              "target": ctx.body.action.form_value.link
            }
          }
        })
      }
      return {};
    } catch (e) {
      return JSON.stringify({
        "type": "template",
        "data": {
          "template_id": "ctp_AAmFBm5vZYuo",
          "template_variable": {
            "POSTFIX": ctx.body.action.form_value.postfix
          }
        }
      });
    }
  }

  if (Object.hasOwn(ctx.body, "header") && ctx.body.header.event_type == 'application.bot.menu_v6') {
    // 处理按钮
    if (ctx.body.event.event_key == "help") {
      try {
        let content = JSON.stringify({
          template_id: "ctp_AAmFBFOpYX0S"
        });
        await client.request({
          method: "POST",
          url: "https://open.feishu.cn/open-apis/im/v1/messages",
          data: {
            receive_id: ctx.body.event.operator.operator_id.open_id,
            msg_type: 'interactive',
            content: JSON.stringify({
              "type": "template",
              "data": {
                "template_id": "ctp_AAmFBFOpYX0S",
              }
            }),
          },
          params: {
            receive_id_type: 'open_id',
          },
        })
        return {};
      } catch (e) {
        console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
        return {};
      }
    }
    if (ctx.body.event.event_key == "mylink") {
      try {
        // function to get all my link 
        let links = data.data.map(item => {
          return {
            source: `[${item.Postfix}](https://link.feishu.io/${item.Postfix})`,
            target: item.Link
          }
        })
        await client.request({
          method: "POST",
          url: "https://open.feishu.cn/open-apis/im/v1/messages",
          data: {
            receive_id: ctx.body.event.operator.operator_id.open_id,
            msg_type: 'interactive',
            content: JSON.stringify({
              "type": "template",
              "data": {
                "template_id": "ctp_AAmFBm5vnHfs",
                "template_variable": {
                  "CONTENT": links
                }
              }
            }),
          },
          params: {
            receive_id_type: 'open_id',
          },
        })
        return {};
      } catch (e) {
        console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
        return {};
      }
    }
    if (ctx.body.event.event_key == "create") {
      try {
        await client.request({
          method: "POST",
          url: "https://open.feishu.cn/open-apis/im/v1/messages",
          data: {
            receive_id: ctx.body.event.operator.operator_id.open_id,
            msg_type: 'interactive',
            content: "{\"header\":{\"template\":\"turquoise\",\"title\":{\"content\":\"创建短链接\",\"tag\":\"plain_text\"}},\"elements\":[{\"tag\":\"form\",\"name\":\"form_1\",\"elements\":[{\"tag\":\"input\",\"name\":\"postfix\",\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"请输入后缀\"},\"max_length\":10,\"label\":{\"tag\":\"plain_text\",\"content\":\"请输入后缀:\"},\"label_position\":\"left\",\"value\":{\"k\":\"v\"}},{\"tag\":\"input\",\"name\":\"link\",\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"请输入要跳转链接\"},\"label\":{\"tag\":\"plain_text\",\"content\":\"请输入要跳转链接:\"},\"label_position\":\"left\",\"value\":{\"k\":\"v\"}},{\"action_type\":\"form_submit\",\"name\":\"submit\",\"tag\":\"button\",\"text\":{\"content\":\"提交\",\"tag\":\"lark_md\"},\"type\":\"primary\",\"confirm\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"创建短链接\"},\"text\":{\"tag\":\"plain_text\",\"content\":\"确认提交吗\"}}}]}]}",
          },
          params: {
            receive_id_type: 'open_id',
          },
        })
        return {};
      } catch (e) {
        console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
        return {};
      }
    }
  }
  return { data: 'hi, laf' }
}
Code language: JavaScript (javascript)

使用飞书消息卡片模板,减少代码硬编码 JSON

0c0ca4a0ac1f249860b29e295dd55260

在开发短链助手时,一个很大的痛苦的点是我希望通过消息卡片来完成开发者的交互,这意味着我需要有大量的行为是和消息卡片来完成的。而消息卡片又不同于 HTML,是一个比较明确的 DSL。消息卡片更多是基于 JSON 提供的一套 Schema,将其放在代码中管理也是一个非常麻烦的事情。

好在最近飞书开放平台迭代了消息卡片模板的功能,我可以不用把 JSON 存在代码中,而是只在代码中存一个 Template ID ,从而降低我在代码中维护这段 JSON 的难度。

在卡片构建工具中新建卡片

首先,你需要打开消息卡片搭建工具,并在其中创建一个新的卡片(你可以使用其提供的卡片组的能力,来管理你的卡片们)。比如我就要这个卡片组来管理短链助手和其他场景的卡片。

7rmbt4

创建卡片完成后,你可以在 UI 上点击保存并发布,你就将你的卡片消息模板发布到了飞书的服务器。

o69het

此时,你就可以在代码中使用了。点击页面中间的 ID,复制消息卡片模板 ID,将你的调用代码替换为对应的逻辑即可。

lv64d7

使用模板需要注意,将消息卡片中的 Content 从过去的卡片内容,替换为 template 的 JSON。比如,使用卡片 JSON 发送的时候,我们发送的数据可能是这样的:

{
    "receive_id": "oc_820faa21d7ed275b53d1727a0feaa917",
    "content": "{\"config\":{\"wide_screen_mode\":true},\"elements\":[{\"alt\":{\"content\":\"\",\"tag\":\"plain_text\"},\"img_key\":\"img_7ea74629-9191-4176-998c-2e603c9c5e8g\",\"tag\":\"img\"},{\"tag\":\"div\",\"text\":{\"content\":\"你是否曾因为一本书而产生心灵共振,开始感悟人生?\\n你有哪些想极力推荐给他人的珍藏书单?\\n\\n加入 **4·23 飞书读书节**,分享你的**挚爱书单**及**读书笔记**,**赢取千元读书礼**!\\n\\n📬 填写问卷,晒出你的珍藏好书\\n😍 想知道其他人都推荐了哪些好书?马上[入群围观](https://open.feishu.cn/)\\n📝 用[读书笔记模板](https://open.feishu.cn/)(桌面端打开),记录你的心得体会\\n🙌 更有惊喜特邀嘉宾 4月12日起带你共读\",\"tag\":\"lark_md\"}},{\"actions\":[{\"tag\":\"button\",\"text\":{\"content\":\"立即推荐好书\",\"tag\":\"plain_text\"},\"type\":\"primary\",\"url\":\"https://open.feishu.cn/\"},{\"tag\":\"button\",\"text\":{\"content\":\"查看活动指南\",\"tag\":\"plain_text\"},\"type\":\"default\",\"url\":\"https://open.feishu.cn/\"}],\"tag\":\"action\"}],\"header\":{\"template\":\"turquoise\",\"title\":{\"content\":\"📚晒挚爱好书,赢读书礼金\",\"tag\":\"plain_text\"}}}",
    "msg_type": "interactive"
}
Code language: JSON / JSON with Comments (json)

而在使用模板时,我们只需要很短的内容就可以:

{
      "receive_id": "ou_7d8a6exxxxccs",
      "msg_type": "interactive",
      "content": "{\"type\": \"template\", \"data\": { \"template_id\": \"ctp_xxxxxxxxxxxx\", \"template_variable\": {\"article_title\": \"这是文章标题内容\"} } }"
  }
Code language: JSON / JSON with Comments (json)

这样你就可以把过去又臭又长的 JSON 变为一个简短小巧的 Template ID 来完成。

一些 Tips

在使用模板时,如果你的模板比较多,那么管理这些模板会比较成问题,一个比较好的办法是你可以考虑把 template ID 的编辑链接放在你的代码注释里,这样当你需要编辑 JSON 的时候,只需要点击代码中的链接就可以跳过来编辑了。

       client.request({
          method: "POST",
          url: "https://open.feishu.cn/open-apis/im/v1/messages",
          data: {
            receive_id: ctx.body.event.operator.operator_id.open_id,
            msg_type: 'interactive',
            content: JSON.stringify({
              "type": "template",
              "data": {
                "template_id": "ctp_AAmFBFOpYX0S", // https://open.feishu.cn/tool/cardbuilder?templateId=ctp_AAmFBFOpYX0S
              }
            }),
          },
          params: {
            receive_id_type: 'open_id',
          },
        })
Code language: CSS (css)

如何巧用飞书消息卡片输入框实现一套业务交互逻辑

0c0ca4a0ac1f249860b29e295dd55260

飞书开放平台最近开始内测了输入框的能力,基于输入框,为消息卡片提供了进一步业务系统打通的可能性,你可以不需要开发一整个网页应用,只需要借助飞书机器人和飞书消息卡片,就可以实现一套业务交互逻辑。

流程图示意

w0052z

目标说明

这里首先确定要实现的逻辑:这里我要做的是一个短链接应用,功能很简单,点击下方的机器人菜单,并在弹出的窗口中输入对应的短链接后缀和要跳转的链接,点击确定就会帮我创建一个短链接。

4rbg8i

具体效果如下:

dqk8d2

如果后缀已经被占用,则展示如下内容:

50tl60

在实现这个功能时,我首先使用了飞书提供的输入框组件的能力和表单组件能力,来实现整个业务交互,当然,你也可以根据业务形态,来选择合适的组件,构成一整个输入表单。

实现逻辑

整体的功能可以分为三步:

  1. 点击按钮:机器人需要响应点击事件,并发送一个带有输入框的消息卡片。
  2. 验证卡片输入内容:消息卡片中提供了输入框,但是用户的输入是否我们能用,需要设计一些验证的能力。
  3. 反馈用户是否创建成功:当我们创建成功后,需要给开发者提示,告诉他是否已经创建成功,帮助他结束整个流程。

接下来就是具体的实现步骤了。

点击按钮并回复卡片

首先,我先是使用了机器人的菜单功能,来实现在机器人底部配置菜单。你需要访问飞书开发者后台,找到机器人能力中的「机器人自定义菜单」,就可以配置一个机器人的自定义菜单了。机器人菜单支持跳转到指定链接,或者是推送事件,我选择推送事件,这样我就可以在服务端响应用户的创建的行为。这里我设定了事件内容为 create ,便于后续处理。

ogypfv

机器人菜单的处理则可以参考机器人菜单使用说明,通过订阅「机器人自定义事件」来完成对于相应行为的接受和对应的处理。

这部分的处理逻辑可以参考如下代码

// 判断请求体当中是否有 header 字段 && 来源的事件是否是机器人菜单
if (Object.hasOwn(ctx.body, "header") && ctx.body.header.event_type == 'application.bot.menu_v6') {

    // 请求的事件是否是创建短链接对应的事件。
    if (ctx.body.event.event_key == "create") {
      try {
        await client.request({
          method: "POST",
          url: "https://open.feishu.cn/open-apis/im/v1/messages",
          data: {
            receive_id: ctx.body.event.operator.operator_id.open_id, // 从事件体中提取事件的触发人
            msg_type: 'interactive',
            content: "", // 推送卡片 JSON
          },
          params: {
            receive_id_type: 'open_id',
          },
        })
        return {};
      } catch (e) {
        console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
        return {};
      }
    }
  }
Code language: JavaScript (javascript)

对卡片输入内容进行校验

在完成卡片响应的设定后,接下来我实现的是校验的逻辑,这里分为两层:第一层是客户端可以完成的校验:比如短链接应该少于 10 个字符。第二层是只有客户端才能完成的校验。

1. 在本地校验文件长短

如果每次发起请求都需要发送到服务端进行校验,则有比较高的校验成本。好在消息卡片提供了本地校验的能力,你可以通过 max_length 字段来验证输入框长短.

这里我是使用输入框组件的字段,来验证输入的内容长度不得大于 10 。

du1glf

2. 输入两个参数才发起请求

在消息卡片的输入框组件中,只要输入内容就会发现校验,因此我不能直接使用输入框组件,而是需要借助 form 组件,来实现用户输入两个内容再手动发起提交。则具体我构建的卡片 JSON 是这样的。

{
  "header": {
    "template": "turquoise",
    "title": {
      "content": "创建短链接",
      "tag": "plain_text"
    }
  },
  "elements": [
    {
      "tag": "form",
      "name": "form_1",
      "elements": [
        {
          "tag": "input",
          "name": "postfix",
          "placeholder": {
            "tag": "plain_text",
            "content": "请输入后缀"
          },
          "max_length": 10,
          "label": {
            "tag": "plain_text",
            "content": "请输入后缀:"
          },
          "label_position": "left",
          "value": {
            "k": "v"
          }
        },
        {
          "tag": "input",
          "name": "link",
          "placeholder": {
            "tag": "plain_text",
            "content": "请输入要跳转链接"
          },
          "label": {
            "tag": "plain_text",
            "content": "请输入要跳转链接:"
          },
          "label_position": "left",
          "value": {
            "k": "v"
          }
        },
        {
          "action_type": "form_submit",
          "name": "submit",
          "tag": "button",
          "text": {
            "content": "提交",
            "tag": "lark_md"
          },
          "type": "primary",
          "confirm": {
            "title": {
              "tag": "plain_text",
              "content": "创建短链接"
            },
            "text": {
              "tag": "plain_text",
              "content": "确认提交吗"
            }
          }
        }
      ]
    }
  ]
}
Code language: JSON / JSON with Comments (json)

这部分的关键是用 form 组件包裹 Input 组件,从而规避了 Input 组件输入内容就会发送到服务端校验的问题。

7yro9r

3. 在服务端验证有无

这部分逻辑我在实现的时候相对简单,没有专门去进行校验(主要是因为我的短链接服务和机器人是两个不同的服务),而是通过短链服务返回 200 还是 401 来判断是否出现了重复的问题,所以这里只是简单的使用了一个 try catch 来完成校验。

需要注意的是,这里你会注意到,返回是直接返回了一段 JSON String,这是因为触发这个事件是通过消息卡片的回调能力,如果你在消息卡片的回调能力返回一个 JSON,就会直接把 UI 层面的卡片渲染为你返回的卡片结果。靠着这个功能,我来实现的成功与失败返回不同的内容。

if (Object.hasOwn(ctx.body, "action") && ctx.body.action) {    
    try {
      // create link

      if (status == 200) {
        return JSON.stringify({
          "type": "template",
          "data": {
            "template_id": "ctp_AAmFBm5vnlt0",
            "template_variable": {
              "source": ctx.body.action.form_value.postfix,
              "target": ctx.body.action.form_value.link
            }
          }
        })
      }
      return {};
    } catch (e) {
      return JSON.stringify({
        "type": "template",
        "data": {
          "template_id": "ctp_AAmFBm5vZYuo",
          "template_variable": {
            "POSTFIX": ctx.body.action.form_value.postfix
          }
        }
      });
    }
  }
Code language: JavaScript (javascript)

完整代码参考

整个机器人的部分的代码只有 170 余行,不多,供你参考

import cloud from '@lafjs/cloud'
import axios from 'axios'

let appid = "";
let secret = ""

const lark = require('@larksuiteoapi/node-sdk');

const client = new lark.Client({
  appId: appid,
  appSecret: secret
});


export default async function (ctx: FunctionContext) {

  console.log("event",ctx.body);

  if (ctx.body.challenge) {
    return ctx.body
  }

  if (Object.hasOwn(ctx.body, "action") && ctx.body.action) {
    if (ctx.body.action.name != "submit") return { code: 1 };
    try {
      // function to create link

      if (status == 200) {
        return JSON.stringify({
          "type": "template",
          "data": {
            "template_id": "ctp_AAmFBm5vnlt0",
            "template_variable": {
              "source": ctx.body.action.form_value.postfix,
              "target": ctx.body.action.form_value.link
            }
          }
        })
      }
      return {};
    } catch (e) {
      return JSON.stringify({
        "type": "template",
        "data": {
          "template_id": "ctp_AAmFBm5vZYuo",
          "template_variable": {
            "POSTFIX": ctx.body.action.form_value.postfix
          }
        }
      });
    }
  }

  if (Object.hasOwn(ctx.body, "header") && ctx.body.header.event_type == 'application.bot.menu_v6') {
    // 处理按钮
    if (ctx.body.event.event_key == "help") {
      try {
        let content = JSON.stringify({
          template_id: "ctp_AAmFBFOpYX0S"
        });
        await client.request({
          method: "POST",
          url: "https://open.feishu.cn/open-apis/im/v1/messages",
          data: {
            receive_id: ctx.body.event.operator.operator_id.open_id,
            msg_type: 'interactive',
            content: JSON.stringify({
              "type": "template",
              "data": {
                "template_id": "ctp_AAmFBFOpYX0S",
              }
            }),
          },
          params: {
            receive_id_type: 'open_id',
          },
        })
        return {};
      } catch (e) {
        console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
        return {};
      }
    }
    if (ctx.body.event.event_key == "mylink") {
      try {
        // function to get all my link 
        let links = data.data.map(item => {
          return {
            source: `[${item.Postfix}](https://link.feishu.io/${item.Postfix})`,
            target: item.Link
          }
        })
        await client.request({
          method: "POST",
          url: "https://open.feishu.cn/open-apis/im/v1/messages",
          data: {
            receive_id: ctx.body.event.operator.operator_id.open_id,
            msg_type: 'interactive',
            content: JSON.stringify({
              "type": "template",
              "data": {
                "template_id": "ctp_AAmFBm5vnHfs",
                "template_variable": {
                  "CONTENT": links
                }
              }
            }),
          },
          params: {
            receive_id_type: 'open_id',
          },
        })
        return {};
      } catch (e) {
        console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
        return {};
      }
    }
    if (ctx.body.event.event_key == "create") {
      try {
        await client.request({
          method: "POST",
          url: "https://open.feishu.cn/open-apis/im/v1/messages",
          data: {
            receive_id: ctx.body.event.operator.operator_id.open_id,
            msg_type: 'interactive',
            content: "{\"header\":{\"template\":\"turquoise\",\"title\":{\"content\":\"创建短链接\",\"tag\":\"plain_text\"}},\"elements\":[{\"tag\":\"form\",\"name\":\"form_1\",\"elements\":[{\"tag\":\"input\",\"name\":\"postfix\",\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"请输入后缀\"},\"max_length\":10,\"label\":{\"tag\":\"plain_text\",\"content\":\"请输入后缀:\"},\"label_position\":\"left\",\"value\":{\"k\":\"v\"}},{\"tag\":\"input\",\"name\":\"link\",\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"请输入要跳转链接\"},\"label\":{\"tag\":\"plain_text\",\"content\":\"请输入要跳转链接:\"},\"label_position\":\"left\",\"value\":{\"k\":\"v\"}},{\"action_type\":\"form_submit\",\"name\":\"submit\",\"tag\":\"button\",\"text\":{\"content\":\"提交\",\"tag\":\"lark_md\"},\"type\":\"primary\",\"confirm\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"创建短链接\"},\"text\":{\"tag\":\"plain_text\",\"content\":\"确认提交吗\"}}}]}]}",
          },
          params: {
            receive_id_type: 'open_id',
          },
        })
        return {};
      } catch (e) {
        console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
        return {};
      }
    }
  }
  return { data: 'hi, laf' }
}
Code language: JavaScript (javascript)

使用 fresh 来提升你的 Golang 开发效率

5e54199359bbafe0ef692365a9bcffb6

Golang 作为一个编译型语言,在编写程序时,一个不太方便的点便是每次修改完代码,都需要重新编译才能测试效果。虽然你可以使用 go run main.go 命令来运行一个 go 文件,但由于项目往往文件比较多、修改时还是需要手动输入命令比较麻烦,所以给 Golang 的开发过程带来了不少的问题。

fresh 就是一个帮助你执行一些重复命令的命令行工具,有了 fresh ,你就可以不用自己手动执行 go run main.go,它在检测到文件发上了变化后,会自动帮你中断掉当前进程,并重新执行命令,帮你实现 live-reload 的效果。

fresh 的执行效果

v1btrb

https://github.com/gravityblast/fresh

安装

安装比较简单,执行 如下命令后,你就可以在任何地方执行 fresh 命令了。

go get github.com/pilu/fresh
Code language: JavaScript (javascript)

执行

当你当前目录有 main.go 文件时,直接执行 fresh 就会自动执行 main.go 文件。不过,如果你想要自定义的话,也可以通过配置文件来完成。创建一个 sample.conf 文件,贴入如下配置,并执行 fresh -c sample.conf 就可以让 fresh 按照你的配置来执行命令。

root:              .
tmp_path:          ./tmp
build_name:        runner-build
build_log:         runner-build-errors.log
valid_ext:         .go, .tpl, .tmpl, .html
no_rebuild_ext:    .tpl, .tmpl, .html
ignored:           assets, tmp
build_delay:       600
colors:            1
log_color_main:    cyan
log_color_build:   yellow
log_color_runner:  green
log_color_watcher: magenta
log_color_app:<a href="https://github.com/gravityblast/fresh#usage"></a>
Code language: HTML, XML (xml)

使用 Find Duplicates 插件清理 Calibre 书库

two women facing security camera above mounted on structure

我在导入 Kindle 的图书时,错误的导入了两遍,导致我的 Calibre 仓库快速膨胀,因此,我希望清理掉其中重复的书籍,减少二次存储。

清理使用的是 Find Duplicates 这个插件

安装

在 Calibre 的「首选项」-「插件安装」页面,搜索 Find Duplicates,就可以找到这个插件,双击安装,并重新启动,即可使用该插件。

205m0u

如果你默认选择的是在主菜单展示工具,则会像我一样,在页面顶部有一个新的入口

lb787o

使用

使用非常简单,点击按钮,进入搜索页面,可以配置对比的元素,可以是二进制对比(更精准)或者是 基于某个特定的标签对比(比如 ISBN),也可以使用默认的标题作者对比。根据你的喜好,你可以选择合适的方式进行对比。

在下方的结果输出中,可以选择“Show All Groups at once with highlighting”,这样可以在一个列表中快速看到所有需要清理的图书,快速删除,达成目标。

点击确定,执行后的结果就是这样子的了,你只需要在重复的两本书中,选择你要删除的那一本即可。

如何 Debug 爬虫无法成功爬取的问题

a tractor in a field

在写爬虫的时候,我们会遇到最常见的问题是浏览器访问是一切正常的,但到代码编写的时候,就发现无法正常爬取。

y39ss5

这个时候往往是我们的爬虫所模拟的行为和代码里是不同的(比如浏览器拥有 30 个参数,但我们的代码中只有一个参数),从而导致最终执行的效果不同。而想要在代码中实现和浏览器相同的效果,最重要的是完全复制浏览器的行为,以便于让代码去模拟

所以关键在于找到从30个参数正常运转,到1个参数不运转的关键参数,毕竟我们不想在代码当中添加太多的 Magic Value 来解决一些问题。所以要找到关键的参数。

获取现场

首先,我们需要获取到和浏览器一样的数据,因为这个是我们进行后续的基础,有了它,我们才能进行从 30 个参数减少到 1 个参数。在执行这个步骤时,你需要借助 Chrome Devtools,来获取到现场。

使用 F12 打开 Chrome DevTools,切换到 「网络」Tab ,并刷新页面,以重新加载请求。页面正常加载完成后,你就可以从中找到你要 Debug 的请求。在这个请求上点击右键,选择「复制」,并选择「以 cURL 格式复制」,复制以后,你会得到如下的内容。这就是你在使用浏览器时,实际发送给服务器的请求。

你可以将这段命令放在 Terminal 中运行,你会看到和浏览器中的一样的内容输出。

此时,我们看复制出来的命令,其中包含了链接(我们的一个的参数),以及大量的 Header,这些 Header 中的某一个可能就是服务器将我们视为爬虫的 Header,然后拒绝我们的。

curl 'https://www.baidu.com/' \
  -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \
  -H 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7' \
  -H 'Cache-Control: no-cache' \
  -H 'Connection: keep-alive' \
  -H 'Cookie: BIDUPSID=1B455AFF07892965CF63335283C0BD80; PSTM=1690036933; BD_UPN=123253; BDUSS=BDcmp1YzFSeTRDLXVGZlNBbDJKZ08ya1lMQUpBVTlEaWM5WE9mV25YWn5EfmxrRVFBQUFBJCQAAAAAAAAAAAEAAADwPbowsNe084aq4MIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH-C0WR~gtFkan; BDUSS_BFESS=BDcmp1YzFSeTRDLXVGZlNBbDJKZ08ya1lMQUpBVTlEaWM5WE9mV25YWn5EfmxrRVFBQUFBJCQAAAAAAAAAAAEAAADwPbowsNe084aq4MIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH-C0WR~gtFkan; delPer=0; BD_CK_SAM=1; ZFY=sMxi79JqlMJjPMSJ3gQr5ht5g0MCtkIqefc2OTMspV4:C; BD_HOME=1; BDRCVFR[feWj1Vr5u3D]=I67x6TjHwwYf0; rsv_jmp_slow=1691762441727; BAIDUID=1B455AFF07892965CF63335283C0BD80:SL=0:NR=10:FG=1; sug=0; sugstore=1; ORIGIN=2; bdime=0; BAIDUID_BFESS=1B455AFF07892965CF63335283C0BD80:SL=0:NR=10:FG=1; PSINO=2; COOKIE_SESSION=161794_0_4_3_2_11_1_0_4_4_1_0_161839_0_9_0_1691922269_0_1691922260%7C4%230_0_1691922260%7C1; MCITY=-131%3A; H_PS_PSSID=36558_39217_38876_39118_39198_26350_39138_39100; BA_HECTOR=8gah01agag8g2kala0a12l2o1idr2ba1o; RT="z=1&dm=baidu.com&si=8e7a4596-4b9f-45c3-bf30-f7dffaddd79d&ss=llewny2r&sl=3&tt=1ag&bcn=https%3A%2F%2Ffclog.baidu.com%2Flog%2Fweirwood%3Ftype%3Dperf&ld=89j&ul=104t&hd=105p"' \
  -H 'DNT: 1' \
  -H 'Pragma: no-cache' \
  -H 'Sec-Fetch-Dest: document' \
  -H 'Sec-Fetch-Mode: navigate' \
  -H 'Sec-Fetch-Site: none' \
  -H 'Sec-Fetch-User: ?1' \
  -H 'Upgrade-Insecure-Requests: 1' \
  -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36' \
  -H 'sec-ch-ua: "Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "macOS"' \
  --compressed
Code language: JavaScript (javascript)

使用二分法测试 Header

如果要查出哪些 Header 是关键参数,一个比较便捷的方式是采用二分法的方式来调试这些 Header。

具体操作时,你只需要删除一半的 Header,并再次发送 cURL,看是否可以正确获得我们想要的内容。如果你发现删除的这一半并不会让你拿到的返回结果报错,就可以继续使用二分法删除 Header,直到定位到真正会影响到我们正确获取结果的 Header 为止。

当你测试到某个 header 被移除后回导致无法正确返回内容,接下来你可以把剩下的 header 继续使用二分法来删除,测试具体会影响结果的 Header,直到删无可删之时,就说明我们已经拿到了能正常运转的最简参数。

此时,你就可以放心的将上述逻辑放在你的代码中来进行维护。

除此之外,你还可以使用上述的逻辑,把你的爬虫逻辑放进单元测试,这样当目标网站调整了爬虫逻辑之后,你就会快速发现。

ACME.sh 续签不成功下的 Debug 记录

a computer screen with a remote control on it

我的域名证书目前基本上 100% 使用 acme.sh,免费大碗,唯一的问题是需要续签。对于一些涉及到 CDN 场景的可能不太友好。不过我的 CDN 往往也不太使用主域名,所以也可以接受。

最近在给一个域名续签的时候,出现了报错的问题

续签代码:

 "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh"
Code language: JavaScript (javascript)

报错如下:

[Sun Aug 13 06:02:43 PM CST 2023] Renew: 'replace.com'
[Sun Aug 13 06:02:43 PM CST 2023] Renew to Le_API=https://acme.zerossl.com/v2/DV90
[Sun Aug 13 06:02:43 PM CST 2023] Using CA: https://acme.zerossl.com/v2/DV90
[Sun Aug 13 06:02:44 PM CST 2023] Multi domain='DNS:replace.com,DNS:feishu.io'
[Sun Aug 13 06:02:44 PM CST 2023] Getting domain auth token for each domain
[Sun Aug 13 06:02:48 PM CST 2023] Getting webroot for domain='replace.com'
[Sun Aug 13 06:02:48 PM CST 2023] Getting webroot for domain='feishu.io'
[Sun Aug 13 06:02:48 PM CST 2023] Verifying: replace.com
[Sun Aug 13 06:02:49 PM CST 2023] Processing, The CA is processing your order, please just wait. (1/30)
[Sun Aug 13 06:02:53 PM CST 2023] replace.com:Verify error:"error":{
[Sun Aug 13 06:02:53 PM CST 2023] Please add '--debug' or '--log' to check more details.
[Sun Aug 13 06:02:53 PM CST 2023] See: https://github.com/acmesh-official/acme.sh/wiki/How-to-debug-acme.sh
[Sun Aug 13 06:02:55 PM CST 2023] Error renew replace.com.
Code language: JavaScript (javascript)

在出现问题之后,可以通过在命令中添加 --debug 的方式,来实现查看响应的日志,debug 的命令如下:

 "/root/.acme.sh"/acme.sh --cron --debug --home "/root/.acme.sh"
Code language: JavaScript (javascript)

则在新的输出中,说明了我具体卡在哪里了

[Sun Aug 13 06:04:49 PM CST 2023] replace.com:Verify error:"error":{
[Sun Aug 13 06:04:49 PM CST 2023] Debug: get token url.
[Sun Aug 13 06:04:49 PM CST 2023] GET
[Sun Aug 13 06:04:49 PM CST 2023] url='http://replace.com/.well-known/acme-challenge/rm-enWjHphDeyjXtfXu2mi1V-D6cZY8EHAe_Gi7TmC4'
[Sun Aug 13 06:04:49 PM CST 2023] timeout=1
[Sun Aug 13 06:04:49 PM CST 2023] _CURL='curl --silent --dump-header /root/.acme.sh/http.header  -L  --connect-timeout 1'
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>
[Sun Aug 13 06:04:49 PM CST 2023] ret='0'
Code language: HTML, XML (xml)

acme.sh 的报错非常清晰,我的报错是因为我的文件目录中 /.well-known/ 中的验证文件找不到了,所以报错。那我只需要去检查我的 Nginx 配置即可。这里因为我的 Nginx 配置根目录不在默认指明的目录(因为用了 Docker ,nginx 只是一个代理),所以我需要将其修改为真正的 webroot。

修改 ~/.acme.sh/replace.com/replace.com.conf 中的 Le_Webroot 配置为真实的 webroot,再重新签名,就域名成功签名了。

给 mdbook 增加备案号显示

rustlang

由于 Gitbook 长期不维护,为了保障整个架构持续可用,所以导致我不得不从 Gitbook 迁移到 mdbook。

在使用 mdbook 的时候,有个问题是,我的不少电子书域名都是备案过后的,mdbook 又不像 gitbook 支持可以在目录里跳转外链,因此我需要自己实现给 mdbook 添加一个外链。

实现思路

由于没办法直接通过在 Markdown 当中实现跳转外链,那么一个比较简单粗暴的方法就是直接在模板当中添加,刚好 mdbook 在文档中有说明具体的操作方法

你只需要在 mdbook 的根目录中创建一个 theme 文件夹,并生成一个 index.hbs 文件,用于渲染页面。

接下来,你只需要在适当的位置添加上你需要的代码即可(下方的 toc 后面的 有序列表便是我手动添加的)

   <nav id="sidebar" class="sidebar" aria-label="Table of contents">
            <div class="sidebar-scrollbox">
                {{#toc}}{{/toc}}

                 <ol class="chapter">
                    <li class="chapter-item expanded "><a href="https://beian.miit.gov.cn/" target="_blank">这里是你的备案号</a></li>
                </ol>

            </div>
            <div id="sidebar-resize-handle" class="sidebar-resize-handle"></div>

        </nav>
Code language: HTML, XML (xml)

参考文档

https://github.com/rust-lang/mdBook/issues/1354

APILetter S1E5 如何设计一个符合 RESTFul 风格的批量操作的 OpenAPI 接口?

APILetter

批量创建、批量更新、批量删除

批量获取说完了,接下来我们来聊聊批量更新,实际上批量更新、批量创建虽然有场景,但也不多,在这种场景下,我们已经很难像批量获取那样,在原有资源上进行操作,而是需要借助批量资源来实现批量操作。

以用户资源(User)为例,当我们需要对其进行批量创建、删除、更新时,我们需要创建一个批量资源 BulkUser,并通过对 BulkUser 操作,来创建用户。Bulk User 本质上是将请求的多个资源转换为了异步的任务,在发起后,开发者可以在任务结果中查询具体的值来使用。

如何理解异步的任务?
这里异步的任务更多是一种设计表现,并不强制要求一定异步。异步的表现设计和相关的接口实现,是为了给后续留出纵向扩展的空间。既 无论是否行为是否真实异步,都需要在返回结果中返回任务 ID & 任务状态,以便于开发者自行实现异步处理的逻辑。

{
    "code":0,
    "data":{
       "job":{          "id":"123",          "status":"ok"        },
        "results":[
            {
            ...
            }
        ]
    }
}
Code language: JavaScript (javascript)

批量创建

批量创建用户的操作和创建单个用户的操作是比较接近的,主要差异点在于 Path 上有区别,且传递参数时,会传递多个资源的属性。

批量创建用户

# request
POST /bulk_users/

{
    "users":[
        {
            // user1
            ...
        },
        {
            // user2
            ...
        }
    ]
}
# response
HTTP/1.1 200 OK
{
    "code":0,
    "data":[
       {
            // user1
            ...
        },
        {
            // user2
            ...
        }
    ]
}
Code language: PHP (php)

批量更新用户

批量更新时,你已经知道了你需要更新的资源的 ID,因此,可以这样设计的你的接口:

#request
PUT /bulk_users?id[]=1&id[]=2

{
    "gender":"other"
}

# response
HTTP/1.1 204 No Content
{
 "code":0,
    "data":[
       {
            // id=1
            ...
        },
        {
            // id=2
            ...
        }
    ]
}
Code language: PHP (php)

批量删除

有了上面的几个例子,批量删除就比较好定义了。就像这样:

#request
DELETE /bulk_users?id[]=1&id[]=2

# response
HTTP/1.1 200 OK
{
 "code":0,
    "data":[
       {
            // id=1
           "status":"deleted"
        },
        {
            // id=2
            "status":"deleted"
        }
    ]
}
Code language: PHP (php)

总结

批量操作在获取场景,可以考虑通过 List + Filter 的方式,或搜索的方式来实现一套更加标准的搜索接口,而规避提供定制化的自定义接口。从规范的视角,两者都是符合规范的,也可以都对用户提供,并不互斥。而对于没办法复用的创建、更新、删除,则可以考虑使用创建异步任务的方式,来实现批量操作,给开发者一个明确的异步预期,让开发者可以自行查询业务的实现方式。

在 Render.com 上部署 Django 4.2

person holding sticky note

最近在写 Linux 中国的翻译工具的时候,后端我使用的是 Django,版本则选择了 Django 4.2,Python 3.11。在部署 Django 的时候,我选择使用 Render.com 来部署。 不过,在部署的时候,我遇到了一些问题,Render 官方提供的 Getting Started with Django on Render 会部署错误,所以有了今天这篇文章, 告诉大家如何把最新的 Django 4.2 部署到 Render 上。

初始化项目

Render 没有使用 pip,而是使用 Poetry 来管理 Django 项目的,因此,你需要使用 Poetry 来完成项目的初始化。

poetry init #初始化 Poetry 的 配置文件
poetry add django gunicorn # 添加依赖 Django 和 gunicorn
poetry run django-admin startproject linuxondjango .
Code language: PHP (php)

初始化项目基本上就是用 Poetry 替代 pip ,这里没有需要针对 Render 特化的部分,就不做过多的介绍。

编写逻辑代码

当你完成了项目的初始化之后,可以编写你自己的业务逻辑代码,这部分不再多讲,可以正常开发使用。

配置项目以支持 Render 的服务端环境。

1. 从环境变量中读取 Secret Key

Django 使用 Secret Key 作为 Session 加密等一些加密场景的 Salt 和 Seed,所以在 Django Admin 创建项目时,会默认生成一个 Session。不过出于安全考虑,最好不要将其放在代码中,而是在服务端生成后,通过环境变量来存储,避免代码泄露后导致的 session 被解密。

你需要在 settings.py 中,添加如下代码,来替代默认的 key。

import os
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('SECRET_KEY', default='your secret key')
Code language: PHP (php)

2. 在环境变量中读取 Debug 配置

Render 会自动配置一些环境变量,因此,你可以直接通过判断当前环境上下文来确认当前是否是在 Render 的服务端,如果不在,则配置 Debug 为 True,来解决线上不使用 Debug 模式的需求。

DEBUG = 'RENDER' not in os.environ
Code language: JavaScript (javascript)

3. 从环境变量中读取可用域名

Django 是有域名配置的,非配置域名,无法访问当前应用,因此,你需要在 Render 当中读取域名,来确保可以正常访问。当然,如果你自己配置了自己的域名,也可以直接手动写在 ALLOWED_HOSTS 当中。

ALLOWED_HOSTS = []

RENDER_EXTERNAL_HOSTNAME = os.environ.get("RENDER_EXTERNAL_HOSTNAME")
if RENDER_EXTERNAL_HOSTNAME:
    ALLOWED_HOSTS.append(RENDER_EXTERNAL_HOSTNAME)

Code language: JavaScript (javascript)

配置 render.yml 来支持 Render BluePrint

你可以直接复制下面的内容,来作为你的项目的启动配置。其中 build.sh 为构建项目的配置。

build.sh

build.sh 当中最重要的是重新安装 Poetry,因为我使用的是 Python 3.11.4, 和 Render 默认的 Python 3.7 不匹配,所以没办法直接用默认的 Poetry,需要自动手动升级 Poetry。

#!/usr/bin/env bash
# exit on error
set -o errexit

pip install --upgrade pip; pip install poetry;  # 重新安装一下最新的 Poetry,因为默认的 Poetry 的版本比较低。
poetry install

python manage.py collectstatic --no-input
python manage.py migrate
Code language: PHP (php)

render.yml

Render 当中,最重要的是 startCommandPYTHON_VERSION ,startCommand 这里是我使用 gunicorn 来启动 Django 应用,而 PYTHON_VERSION 则是用来设定具体的 Python 版本,这里我根据我自己的需求,选择了 Python 3.11.4。

databases:
  - name: linuxondjango-db
    databaseName: mysite
    user: mysite
    plan: free

services:
  - type: web
    name: linuxondjango
    plan: free
    runtime: python
    buildCommand: "./build.sh"
    startCommand: "gunicorn linuxondjango.wsgi:application"
    envVars:
      - key: DATABASE_URL
        fromDatabase:
          name: linuxondjango-db
          property: connectionString
      - key: SECRET_KEY
        generateValue: true
      - key: WEB_CONCURRENCY
        value: 4
      - key: PYTHON_VERSION # 这里的 python version 是用来指定 Python 版本的,比如这里我用的是 3.11.4。
        value: 3.11.4
Code language: PHP (php)

总结

Render 的教程总体来说没啥大问题,但是在一些小的点上,需要你自己简单 Hack 一下,比如需要自己升级一下 Poetry、设定 Python 版本。如果你也在用高版本的 Django & Render,希望这篇文章 可以帮到你。

如何解决 Kindle 在 M1 系列设备上无法访问的问题

flat screen monitor

问题

在使用 M1 的时候,我遇到一个很麻烦的问题是 M1 无法识别出我的 Kindle系统无法自动加载 M1 设备,这导致习惯于导入标注并使用 Klib 管理的我来说,等于用 Kindle 的功能不齐全了。

使用 macOS 自带的系统工具,也可以看到系统 Kindle 的磁盘,但无法加载。

d2b5ca33bd970f64a6301fa75ae2eb22 29
磁盘工具的展示

如果使用磁盘工具尝试加载,也会报错 com.apple.DiskManagement.disenter错误-119930872

d2b5ca33bd970f64a6301fa75ae2eb22 30
报错的提示

无法使用系统工具加载。

解决方案

在参考了 jakevin 的分享后,我使用如下方式来解决我的 Kindle 挂载问题。

查询外置设备

执行如下命令,可以使用系统自带的 diskutil 查看目前有哪些磁盘。我在这里补充了 grep,来筛选出只有外置磁盘的设备。

diskutil list | grep external -A2
Code language: PHP (php)
d2b5ca33bd970f64a6301fa75ae2eb22 31

手动挂载设备

执行如下命令,来手动挂载 Kindle。

sudo mkdir /Volumes/Kindle # 创建一个新的挂载点,挂载 Kindle
sudo mount -t msdos /dev/disk4 /Volumes/Kindle/ # 使用 mount 命令,挂载 /dev/disk4(你根据需要换成你自己的设备。)
Code language: PHP (php)

一般来说, Kindle 的默认格式化是 fat32 格式,所以用上面的命令就行,但如果你的 Kindle 是 ex-fat 格式,则可以使用如下命令挂载。

sudo /sbin/mount_exfat /dev/disk4 /Volumes/Kindle/ #这里使用的是 mount_exfat。
Code language: PHP (php)

如此操作,便可以让 M1 识别 Kindle 了。

d2b5ca33bd970f64a6301fa75ae2eb22 32

给你的 console.log 添加一些特定的输出

9a1f326b911de6c1629837f3b57551e5 1

在写 Node.js 代码时,常常会使用 console.log 来输出内容,以便于调试。但默认的 console.log 只能标准的输出,在很多需要上下文 debug 的时候,可能信息是不足的。除了使用 debugger 以外,你还可以试着改造 console.log

在你的 index.js 顶部添加如下代码,即可实现在使用 console.log 时自动在前面加上时间信息。当然,你也可以实现自己需要的上下文,比如当前的文件、当前的行数等。

console.log = (function() {
  var console_log = console.log;
  return function() {
    var args = [];
    args.push(`${new Date().toLocaleString()}` + ' -> ');
    for(var i = 0; i < arguments.length; i++) {
      args.push(arguments[i]);
    }
    console_log.apply(console, args);
  };
})();
Code language: JavaScript (javascript)

这个函数的逻辑不复杂,对 console.log 进行了覆盖,写如了新的函数,并通过 arguments 将开发者传入的参数重新打印,以确保不丢失开发者传入的参数。

如何将 Zed 编辑器设置为你的命令行默认编辑器

linux terminal

我最近在使用 Zed 作为我的主要编辑器,在编辑一些命令行文件时,也会使用 Zed 来编辑。但有些时候,一些应用程序的命令会自动调用默认的编辑器,这个时候会默认使用 nano 或者 vim ,而不是 Zed,体验略差。所以我希望将命令行默认的编辑器修改为 Zed 编辑器。

如果需要将 Zed 作为自己的命令行编辑器,首先需要确认 Zed 是否支持等待模式(wait model),在命令行中执行 zed --help,可以看到 Zed 是支持等待模式的。接下来就可以进行后续的步骤来进行测试了。

d2b5ca33bd970f64a6301fa75ae2eb22 3

这里为是在写 Rails 时用到的,因此,我继续使用 rails credentials 来测试。执行如下命令来确认该命令是否可用。

EDITOR="zed --wait" rails credentials:edit
Code language: JavaScript (javascript)

执行后,可以正常唤起,则说明整个链路已经通畅,接下来只需要将其配置在系统的默认环境变量里即可,将如下代码放在 .zshrc 中即可。

export EDITOR="zed --wait"
Code language: JavaScript (javascript)

其他编辑器的命令参考

# Sublime Text
export EDITOR="subl --wait"
# VSCode
export EDITOR="code --wait"
Code language: PHP (php)

为 Next.js 加上 Git Commit 版本号

text

在开发服务端应用的时候,由于服务端应用本身的特性,其实是没有一个明确的版本的概念。毕竟不需要专门下载,理论上每次都是最新的,所以也没有版本的概念。

但在实际开发调试过程中,我们又的确需要关注版本的概念,因为会影响具体的表现形态,所以就需要有一个前后端协调的版本号概念,来帮助我们更好的定位问题,避免前后端之间的扯皮。

一个比较好的思路是,虽然服务端没有版本号概念,但大部分时候会有一个对应的 Commit ID(毕竟现在开发项目完全不用版本控制工具的还是挺少见的)。所以,你可以选择将 Commit ID 作为版本号,进行输出,从而让协作者知道当前线上跑的版本,便于 debug。

在具体实现时,有两种方式:

1. 将 Commit ID 放在 Header 里

我自己平时会把 Vercel 和 Next.js 提供的 API Route 作为一个简单的 Serverless FaaS 环境来使用,因此一个诉求便是在 API Route 当中返回具体的 Commit ID。而为了避免对代码的侵入,将其放在 Response Header 当中是比较合适的。

d2b5ca33bd970f64a6301fa75ae2eb22 2
添加完成的效果。

而如果你希望和我一样,达成对特定路由下的返回结果添加特定的 Header(比如上面截图中 x-build-sha 就是我添加的 Commit ID 的 Header),则需要借助于 Next.js 提供的自定义 Header 能力

通过在 next.config.js 当中的 header 属性中添加具体的配置,来实现对特定的路径下添加自定义 Header。

module.exports = {
  async headers() {
    return [
      {
        source: '/about',
        headers: [
          {
            key: 'x-custom-header',
            value: 'my custom header value',
          },
          {
            key: 'x-another-custom-header',
            value: 'my other custom header value',
          },
        ],
      },
    ];
  },
};
Code language: JavaScript (javascript)

这里面比较关键的是 source 字段,这个字段定义了究竟哪些路由下会返回特定的 Header。比如上面的这段配置就是只给 /about 添加具体的 Header。你可以使用 /:path* 来匹配所有路由,从而实现给所有路由都添加上具体的 Header。

以我为例,我在线上跑的配置实际上是下面这段配置:

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [{ key: 'X-Build-SHA', value: process.env.VERCEL_GIT_COMMIT_SHA }]
      }
    ];
  }
}

module.exports = nextConfig
Code language: JavaScript (javascript)

在上面这段配置中,我给所有的路径都配置了一个 x-build-sha 的 header ,并从进程的变量中提取出 VERCEL_GIT_COMMIT_SHA 变量(这个变量在 Vercel 的部署环境中指向具体的 Commit ID)的值,将其返回。

2. 将 Commit ID 放在 UI 里

除了在 Header 中返回,如果你是需要去 Debug UI 的话,版本号同样重要,这个时候,你可以选择将 Commit ID 放在界面上,从而实现快速找到 Commit ID。

在 Vercel 部署的 Next.js 上,有一批 Next.js 框架所属的环境变量, 可以直接在 UI 当中引用(上面的 VERCEL_GIT_COMMIT_SHA 是不能在 UI 中直接引用的)。

只需要在特定的位置,加入SHA: {process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA} ,就可以展示具体的 Commit ID。

(除了 Next.js,其他框架也有类似的框架变量可以使用,你可以根据自己的需求来选择)。

总结

在服务端 Debug 时,将你的 Commit ID 以某种方式返回可以有效的帮助快速定位问题,试着给你的 Next.js 添加上这个 Commit ID,来加速你的问题排查吧~

到底多少个错误码才是合理的?

text

在对外开放 OpenAPI 的时候,错误的设计也是一个极为影响开发者开发体验的设计点。今天我们简单聊聊关于错误码和错误处理

为什么需要错误码?

由于我们不能保证系统可以完全处理用户的请求,因此我们需要通过错误码来告诉开发者发生了不符合预期的情况。不符合预期的情况可能由用户输入错误导致,也可能由内部微服务故障导致。为了建设一个健壮(Robust)的系统,我们需要通过对外暴露一批错误码,帮助开发者更好地处理各种异常情况。

避免错误码数量过少

既然我们的目的是帮助开发者解决异常情况,那么一个合理的答案是:和异常情况匹配的错误码数量是一个好的错误码数量。

如果在某个 API 接口上提供了一个错误码,则意味着我们认为这个接口只会出现一种情况导致的错误。而实际上,很可能会有多种情况导致错误发生。这种情况经常在 OpenAPI 评审过程中被提出来,也是用户在使用 OpenAPI 时常遇到的问题:为什么我找不到这个错误码?对于开发者来说,无论输入何种错误,都会得到相同的错误码,难以定位和解决问题。同一个错误码也意味着你无法提供足够的错误信息来进行排查。例如,常见的 “400 Bad Request” 错误,如果参数很多,排查错误可能是一个极其痛苦的过程。

避免错误码数量过多

另一方面,错误码数量过多也会导致问题。有些 OpenAPI 接口提供了几十个不同的错误码,看起来感觉很不错。但是仔细一看,就会发现这些错误码实际上只是针对不同的字段错误而已,导致错误码数量快速增长。而实际上,可以将参数错误放在同一个错误码中,并通过动态的参数和原因来解决,而不是返回一堆类似的错误码。大量的错误码对于开发者来说,存在记忆困难的问题,在实际编写代码的过程中,也需要编写大量的错误处理逻辑,来兼容我们对外抛出的错误处理逻辑。

换一种思路来组织错误码

如果你无法很好的掌握拆分和组织错误码的粒度,那我可以给你一个建议:按照用户处理错误的手段来拆分错误码。过去从内部视角来组织和错误码,很容易出现错误码过多或过少的情况,而从外部视角来梳理错误码,则可以帮助我们更好的厘清错误码的分类和组织。

用户并不关注我们的系统到底因为什么出现了错误,他们只关心出现了什么样的错误?我应该如何处理这些错误?那错误码可以非常快速的收敛为以下几类:

  • 本资源参数校验错误,对应的处理策略往往是开发者需要查看文档,了解资源参数的限制, 修改参数重新调用。
  • 跨资源参数校验错误,对应的处理策略往往是开发者需要通过其他 API 获取关连资源的 ID 等信息,以便于重新调用。
  • 请求频率太高,对应的处理策略是优化调用的接口和方法,调低并发量。
  • 服务端错误,对应的处理策略是重试,并在重试无效时联系官方人员。

通过上述的分类方式,我们可以快速的将错误码归类到几个大的分类,从而实现合并同类项,收敛错误码但不至于让开发者不知道下一步 Action 的情况。

错误码不重要,错误处理才重要

看到这里,相信你对于文章中提出的什么才是好的错误码设计已经有了答案。但我想说的是,错误码从来都不是核心。实际上,如果我们回看各种编程语言的范式,大多没有错误码这种设计,而是选择将更多的信息通过 exception 这样的形式暴露给开发者。错误码的设计虽然给到开发者一个可以用来做唯一判定的数据,但可以做唯一判定的不一定非要是数字。数字错误码的设计严格来说,并不是一个好的设计,因为他使得你的代码中必然会存在某些特定的 Magic Number,你需要小心的维护这些 Magic Number 来确保向开发者返回错误时不至于返回错误的错误类型和错误码。

对于一个已经存在的 OpenAPI 系统来说,错误码已经成为既定事实,则要做的是让这套系统可以更好的运转下来。但如果你要设计一套全新的错误系统,那么类似 Slack 这样的返回形式,可能是一个更好的选择,既可以规避掉 Magic Number 的问题,又可以确保每一个错误有其对应唯一的枚举值。

p3nta3

总结

好的错误码设计是适度的,你需要学会平衡错误码和对应错误信息的数量,不要太多干扰开发者,但也不可太少,不足以支撑实际的接口调用。好的错误码可以帮助我们和开发者建设更加健壮的系统,减少不必要的沟通成本,也可以让我们每一个使用这个 API 的人,都更加的幸福。