分类目录归档:技术

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)
5e54199359bbafe0ef692365a9bcffb6

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

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)
two women facing security camera above mounted on structure

使用 Find Duplicates 插件清理 Calibre 书库

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

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

安装

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

205m0u

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

lb787o

使用

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

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

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

a tractor in a field

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

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

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,直到删无可删之时,就说明我们已经拿到了能正常运转的最简参数。

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

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

a computer screen with a remote control on it

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

我的域名证书目前基本上 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,再重新签名,就域名成功签名了。

rustlang

给 mdbook 增加备案号显示

由于 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

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

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

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

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

person holding sticky note

在 Render.com 上部署 Django 4.2

最近在写 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,希望这篇文章 可以帮到你。

flat screen monitor

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

问题

在使用 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
9a1f326b911de6c1629837f3b57551e5 1

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

在写 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 将开发者传入的参数重新打印,以确保不丢失开发者传入的参数。