MoreRSS

site iconoldj | 老杰修改

男,80 后,杭州。阿里 8 年,目前在一个小而美的团队。「妙笔 / WonderPen」「ccReader 」作者。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

oldj | 老杰的 RSS 预览

使用函数计算运行定时任务

2025-05-02 15:42:00

上一篇博客提到我在使用 Dokploy 部署网站服务,但 Dokploy 不支持定时任务,于是只能创建普通服务,并在内部使用脚本定时执行命令。最近发现,将这些定时任务放在函数计算中执行可能是更好的选择。

我的服务都跑在阿里云上,下面介绍的也是阿里云函数计算。

创建函数

要创建一个函数,在阿里云函数界面后台,点击创建函数按钮即可,如下图所示:

在随后的界面中,选择“任务函数”类型。

然后,函数代码部分可根据需要选择类型,比如可以使用 ACR 中的 Docker 镜像。

需要注意的是,无论是上传代码还是使用 Docker 镜像,都要确保对应的代码能提供一个 HTTP 服务,因为定时任务执行的入口即是这个 HTTP 服务。

其他还有环境变量等配置,根据你的实际情况填写即可。

函数入口

使用函数计算的定时任务,需要你的代码提供一个 HTTP 服务,定时任务执行时,会以 POST 的方式请求 /invoke 路径,即类似下面这样的请求:

curl -X "POST" "http://localhost:8050/invoke" \
     -H 'Content-Type: application/json' \
     -d $'{
  "payload": "YOUR_PAYLOAD",
  "triggerName": "trigger-name",
  "triggerTime": "2025-04-27T03:12:45Z"
}'

当然,真实的请求还有很多 HTTP 头信息。

你需要在代码中实现 /invoke 接口,并在其中执行定时任务。在函数计算后台,可以设置超时时间等属性。

注意其中的 payload 字段,后面在设置定时触发器时,可以自定义传入的 palyload 信息。

设置定时触发器

添加函数之后,即可在配置界面设置触发器。

函数计算支持多种触发器,在这儿,我们选择定时触发器即可。

其中最后一个字段“触发消息”,其中填写的内容即是上面 payload 参数的值。注意这儿传递的是普通字符串,而不是 JSON,收到之后可根据需要做一个解析。

如果你在同一个函数中有多个用途不同的触发器,可以通过 payload 参数进行区分。

设置好之后,在函数详情界面可以看到类似下面的图示。

如果一切顺利,定时任务就添加成功了,稍后可以在日志页面看到执行记录。

更新函数

如果你的函数使用的是 ACR 中的 Docker 镜像,当推送了新的镜像时,函数计算的版本不会自动更新,需要你登录网站后台手动修改,或者调用函数计算的 API 进行设置。

每次手动修改是一件很麻烦的事,建议使用 API,以便和现有的发布流程结合起来。你可以先安装 aliyun-cli 命令行工具,然后执行类似下面的命令:

aliyun fc PUT /2023-03-30/functions/YOUR_FC --region cn-shanghai --header "Content-Type=application/json;" --body "{\"tracingConfig\":{},\"customContainerConfig\":{\"image\":\"registry-vpc.cn-shanghai.aliyuncs.com/XXX/YYY:1.2.3\"}}"

请注意将其中的参数值替换为你的项目中的值。

小结

有一些定时任务(比如清理老数据、备份用户数据等)比较耗费资源,将它们迁移到函数计算中可以减少主服务器的负担,是一个不错的实践。

函数计算是按量收费的,多数情况下,定时任务使用函数计算应该比专门买一台服务器划算,不过也不要大意,请做好优化,同时注意关注每日的用量。

使用 Dokploy 部署网站服务

2025-04-20 21:41:00

之前的几年我一直在使用 K3s + Rancher 的组合来管理网站服务,不过前段时间迁移到了 Dokploy,在这儿记录一下要点。

为什么迁移?

K3s + Rancher 的组合挺好,几年来一直运行稳定,不过对像我这样的非专业运维来说还是有点太复杂了,事实上几年来,我一直只在使用这个组合的一些最基础的功能。

去年看到有人介绍 Dokploy,了解了一下之后,发现它非常适合我的使用场景,同时又足够简单,于是花了一点时间做了研究,并最终决定迁移到 Dokploy。

除了 Dokploy 之外,还有 Coolify 等产品也不错,而且功能更多一些,读者朋友如果有需要也可以试一试。

云服务还是自托管?

Dokploy 提供了云服务,订阅之后可通过他们的云服务管理自己的服务器。

云服务听起来是个不错的选择,可以减少自己运维的时间成本,我也花了 $4.5 订阅了一个月体验了一番。不过 Dokploy 的云服务在海外,我的服务器在国内,两者之间通讯不畅,因此体验并不是很好。

最后,我选择了自托管服务,将 Dokploy 和网站服务安装在同一个网络中。

安装 Dokploy

Dokploy 的安装很简单,在一台干净的服务器上运行以下命令即可:

curl -sSL https://dokploy.com/install.sh | sh

为了确保 Dokploy 能顺利运行,这台服务器建议至少要 2 CPU + 2 G 内存。

如果你的服务器在国内,安装时可能耗时较长,可以添加国内的 docker 镜像,比如修改 /etc/docker/daemon.json 文件,添加以下内容:

{
  "registry-mirrors": [
    "https://docker.1ms.run"
  ]
}

安装完成之后,即可通过 http://{服务器 IP}:3000 的形式访问 Dokploy 后台。

添加服务器

Dokploy 成功安装后,马上就可以开始创建应用。不过,这时创建的应用会和 Dokploy 安装在同一台服务器上,你也可以在 Dokploy 后台添加新的服务器,并将应用添加到新服务器上。

个人建议用一台服务器专门运行 Dokploy,然后在 Remote Servers 面板中添加其他服务器。

添加服务器之后,还需要在 Actions 菜单中点击 Setup Server,并根据提示进行设置。

其中 Deployments 那个步骤可能耗时会很长,可以考虑点击 Modify Script,将脚本复制到对应的服务器上手动执行。

添加服务

添加完服务器之后,就可以添加项目,随后在项目中添加服务了。

添加服务这儿,最重要的一个设置是 Provider,即设置代码的来源。

Dokploy 支持多种常见的源,比如 Github,配置好之后只需向指定仓库和分支推送代码,Dokploy 就会自动拉取并构建代码,就像 Vercel 一样。

对小项目来说,这样的方式自然是很方便的,不过也可以用 Docker 作为 Provider,并使用第三方镜像服务。这样主要有两个好处:

  1. 镜像的构建工作在第三方执行,不会占用线上服务器资源;
  2. 第三方构建镜像时可以打上版本号 tag,后续回滚操作将会很方便。

我使用的是阿里云的容器镜像服务,填写方式类似下图:

更新服务

Dokploy 提供了丰富的 API,几乎所有操作都可以通过 API 完成。当某个服务需要更新时,可以登录网站手动修改相关值,也可以使用 API 更新。

比如,如果一个服务的 Provider 是 Docker,可以用类似下面的请求进行修改:

curl -X "POST" "https://your-dokploy/api/application.saveDockerProvider" \
     -H 'x-api-key: $YOUR_TOKEN' \
     -H 'Content-Type: application/json' \
     -d $'{
  "applicationId": "$APP_ID",
  "dockerImage": "$DOCKER_URL"
}'

有几个注意点:

  1. 授权头信息是 x-api-key: xxx...,而不是常见的 Authorization: Bearer xxx...
  2. applicationId 的值在 URL 中,在界面上暂时没有显示。

比如某个服务的地址是 https://your-dokploy/dashboard/project/aaa/services/application/bbb,地址最后的 bbb 就是 applicationId

通过 API 的方式,可以很方便地将服务的发布、回滚等操作集中到一处管理,或者与你现有的服务集成。

使用小结

使用 Dokploy 已经有一段时间了,整体而言还是很满意的,相对其他方案它很容易上手,且足够稳定,可用于生产环境。

不足是暂时还不支持定时任务,不过可以通过启动一个普通服务并在其中运行定时脚本的方式解决。

如果你有类似的需求,不妨也试一试 Dokploy。

Electron 中的 Kiosk 窗口

2024-11-28 21:38:00

最近在产品中用到了 Electron 中的 Kiosk 模式,记录一下要点。

什么是 Kiosk 模式?

Kiosk 模式是一种专门为限制用户操作而设计的应用运行模式,通常用于构建锁定的全屏应用程序,禁止用户访问系统其他功能或退出应用。在这种模式下,应用程序占据整个屏幕,并且用户无法通过常见的方式(如键盘快捷键、窗口控制按钮等)退出或切换到其他应用。

Kiosk 模式的主要用途是为用户提供一个专注且受限的操作环境,避免对系统的其他部分产生干扰。

哪些场景下需要使用 Kiosk 模式?

Kiosk 模式被广泛应用于以下场景:

  • 公共信息亭:自助服务终端,如银行 ATM、自助点餐机、自助售票机。
  • 展览展示:在博物馆、展览会、零售店中,用于展示信息或广告内容的屏幕。
  • 教育场景:限制学生只能使用特定的教学应用,避免访问其他不必要的内容。
  • 会议或演讲:锁定演示内容,避免误操作或退出。
  • 数字标牌:作为广告屏幕或公告牌,循环播放内容。

当然,我在开发的是日常效率软件,并不属于以上场景。我用到 Kiosk 模式的场景主要如下。

图几截图软件

我开发并维护着一个截图软件图几,它有三种截图模式:全屏截图、窗口截图、区域截图。

其中区域截图的交互方式是:用户点击截图按钮(或按下截图快捷键),先生成当前屏幕的截图,随后显示一个全屏无边框窗口,在窗口中显示将刚刚生成的屏幕截图,同时允许用户在窗口上进行框选等操作。

这个无边框窗口就需要使用 Kiosk 模式,以免用户无意中切换窗口。当然,等用户完成或取消截图时,需要再退出或关闭对应的 Kiosk 窗口。

WonderPen 写作软件

WonderPen 写作软件最近添加了小黑屋模式,进入这种模式后,软件将全屏显示,屏蔽一切干扰,在完成预设的写作目标之前,将无法退出或切换到其他软件。

这个禁止退出的小黑屋,自然也使用了 Kiosk 模式。

Electron 中的 Kiosk 模式

在 Electron 中,将一个窗口设为 Kiosk 模式非常简单,在创建窗口时设置 kisok 属性为 true 即可。

有时,我们的窗口在创建时需要以普通模式显示,然后再在一定条件下切换为 Kiosk 模式,只需用类似下面的代码切换即可:

win.setKiosk(flag)

其中 flag 是一个布尔值。

你还可以使用 win.isKiosk() 方法判断当前窗口是否为 Kiosk 模式。

在实践过程中,我发现很多时候只设置 Kiosk 属性还不太够,还需要设置 frame 等属性。以下是一个示例:

const win = new BrowserWindow({
  // 其他属性...
  closable: false,
  maximizable: false,
  minimizable: false,
  resizable: false,
  fullscreen: false,
  fullscreenable: false,
  frame: false,
  skipTaskbar: true,
  alwaysOnTop: true,
  useContentSize: true.
  autoHideMenuBar: true.
  movable: false.
  thickFrame: false.
  titleBarStyle: 'default',
  paintWhenInitiallyHidden: false,
  roundedCorners: false,
  enableLargerThanScreen: true,
  acceptFirstMouse: true,
  kiosk: true,
  // 其他属性...
})

即使这样设置之后,在 macOS 上有时仍会出现 Docker 栏和顶部系统菜单栏出现在 Kiosk 窗口上方的情况,因此还需要进一步设置 alwaysOnTop 的属性为 screen-saver,代码如下:

win.setAlwaysOnTop(true, 'screen-saver', 1)

在 Windows 和 macOS 中,alwaysOnTop 的窗口有多种极别,按层级由低到高分别是:

  • normal
  • floating
  • torn-off-menu
  • modal-panel
  • main-menu
  • status
  • pop-up-menu
  • screen-saver

如果只是简单地 win.setAlwaysOnTop(true) ,则窗口的级别只是 floating,仍有可能被其他系统组件遮挡。

另外需要注意,在 macOS 下,太高的级别会挡住系统自带输入法的候选字窗口,如果你的 Kiosk 窗口需要用户输入,并且可能使用系统自带输入法的话,这个级别不能高于 modal-panel

一些其他注意点

Kiosk 模式只对当前窗口有效,一个窗口只能覆盖一个屏幕,若用户有多个显示器,则需先检测显示器数量,然后创建多个 Kiosk 窗口分别覆盖。

设置 Kiosk 模式后,用户仍可以使用 Cmd+Q 这样的快捷键退出应用,因此需要在代码中监听窗口的 close 事件,并检查是否处在 Kiosk 状态,如是则阻止退出。代码类似下面这样:

win.on('close', async (e: Electron.Event) => {
  if (win.isKiosk()) {
    e.preventDefault()
    return
  }

  // 其他逻辑
}

Windows 下退出 Kiosk 模式后,窗口的大小可能会变成全屏大小,如希望退出时恢复原大小,可以在进入 Kiosk 模式之前先记住窗口大小,退出后再设置为原大小。

Kiosk 模式并不能阻止用户重启计算机。如果希望重启计算机后能自动恢复 Kiosk 状态,可以将软件设置为随系统启动,并且启动时自动进入 Kiosk 模式。

使用 acme.sh 申请 SSL 证书

2024-09-23 10:57:00

之前很长一段时间,这个博客一直在用云服务商提供的免费 SSL 证书,那个证书有一年有效期,也即一年只需要申请部署一次,因此全手动操作也不算麻烦,但现在免费 SSL 证书的有效期统一缩短为 3 个月了,意味着每 3 个月就要操作一次,这就让手动申请和部署变得麻烦起来了。

最近,我尝试了一下使用 acme.sh 申请 SSL 证书的方法,确实方便了不少,在这里记录一下。

安装 acme.sh

acme.sh 是一个实现 ACME 协议的脚本,主要用途是申请或更新免费 SSL 证书。运行以下命令即可安装:

curl https://get.acme.sh | sh -s [email protected]

更多安装方式可见官方文档:https://github.com/acmesh-official/acme.sh

acme.sh 会被安装在 ~/.acme.sh 目录下。

手动申请证书

安装好 acme.sh 后,可以用以下命令申请证书:

acme.sh --issue --dns -d mydomain.com -d "*.mydomain.com" --yes-I-know-dns-manual-mode-enough-go-ahead-please

记得把其中的 mydomain.com 换成你自己的域名。

上面的代码中,我申请了泛域名证书,所以同时添加了 mydomain.com*.mydomain.com 域名。需要注意的是,*.mydomain.com 不包含 mydomain.com,如果你希望证书除了包含 www.mydomain.com 这样的二级域名,也包含 mydomain.com 的话,记得把 mydomain.com 也加上。

另外,*.mydomain.com 也不包含更深的层级,比如它包含 home.mydomain.com,但不包含 app.home.mydomain.com 。如果你需要更深层级的泛域名,需要把对应的域名也填上。

还需要注意的是最后一个参数 --yes-I-know-dns-manual-mode-enough-go-ahead-please 。acme.sh 更希望用户使用自动申请证书的方式(见下一小节),如果你确实需要手动申请,需加上这个参数,否则命令不会正常执行。

如果一切顺利,acme.sh 命令会输出两段 TXT 信息,需要你手动添加到对应域名的 DNS 解析中,以验证你确实对这个域名拥有权限。在证书申请完成之后,可以删除对应的 TXT 记录。

登录域名服务商(比如阿里云)后台,在域名解析中添加上对应的 TXT 记录,然后再运行以下命令,即可生成证书:

acme.sh --renew -d mydomain.com -d "*.mydomain.com" --yes-I-know-dns-manual-mode-enough-go-ahead-please

证书会被保存在 ~/.acme.sh/ 目录下,包含以下四个文件:

  • mydomain.com.cer 证书
  • mydomain.com.key 密钥
  • ca.cer
  • fullchain.cer 全链路证书

其中在网站场景主要使用 fullchain.cer 文件和 mydomain.com.key 文件。

自动申请证书

可以看到,上面手动申请的步骤,主要的手动操作就是要为域名添加 TXT 记录以验证域名权限,acme.sh 支持让这个步骤自动化,即自动添加 TXT 记录,并在验证完成之后自动删除对应的记录。

以阿里云为例(如果你的域名是在阿里云注册并解析的),首先需要去阿里云控制台获取一个 AccessKey,建议专门设置一个 RAM 用户,只开通 DNS 权限。

得到 AccessKey 之后,在命令行中执行以下命令:

export Ali_Key="key"
export Ali_Secret="secret"

随后再执行以下命令,即可自动申请或更新证书了:

acme.sh --issue --dns dns_ali -d mydomain.com -d "*.mydomain.com"

注意 --dns 参数后面的值为 dns_ali

一切顺利的话,证书申请会自动完成,并被保存在 ~/.acme.sh/ 目录下。

其他各大域名服务商的自动申请方式类似,具体可参见官方文档。

一些注意点

如果你使用了自动申请,AccessKey 会被明文保存在 ~/.acme.sh/account.conf 文件内,如果介意,可在申请完之后修改这个文件并删除对应的 AccessKey。

另外,使用自动申请后,acme.sh 会添加一条定时任务,每天自动检查证书是否需要更新。可运行以下命令查看当前系统的定时任务列表:

crontab -l

现在 acme.sh 默认使用的证书颁发机构是 ZeroSSL,还有一些其他可选机构,比如 Let's Encrypt。可以用 --set-default-ca 修改默认证书颁发机构,比如:

acme.sh --set-default-ca --server letsencrypt

我没有修改 CA,在使用默认的 ZeroSSL 的证书,目前来看暂时没有遇到什么问题。

除了自动申请证书外,大部分网络服务商也支持自动上传 SSL 证书,不过这部分我还没有研究,后续如果觉得值得记录,会另外写文分享。

处理苹果平台的 CONSUMPTION_REQUEST 消息

2024-08-09 16:31:00

最近完善了一下产品的购买流程,其中的一项工作是处理来自苹果 App Store 平台的 CONSUMPTION_REQUEST 消息,在这儿记录一下要点。

消息说明

App 如果使用了苹果的内购(IAP),每当发生用户购买、续费、退款等操作时,苹果服务器都会向开发者指定的地址发送一条消息,不同的消息有不同的 notificationType 值,其中 CONSUMPTION_REQUEST 消息的意思是用户为应用内购买发起了退款请求,App Store 请求开发者服务器提供用户的消费数据,用于协助 App Store 决定是否给用户退款。

开发者可以忽略 CONSUMPTION_REQUEST 消息,也可以根据需要,在 12 小时内回应 App Store。

回应消息

要回应 CONSUMPTION_REQUEST 消息,只需向指定的地址发一个 PUT 请求即可。具体细节可见官网文档

这个 PUT 消息的要点主要有两个:

  1. 在 Header 中添加认证 token 信息;
  2. 在 Body 中发送一个 JSON 格式的对象,向 App Store 提交对应的信息。

数据内容

我们先看 Body 中的数据内容。

根据文档,数据字段以及含义大致如下:

{
    "accountTenure": 0,  // 用户年龄段,0 表示未知
    "appAccountToken": "",  // 用户 uuid,由于之前没有设置,此处留空
    "consumptionStatus": 0,  // 消费状态,0:未知,1:未消费,2:部分消费,3:全部消费
    "customerConsented": True,  // 用户是否同意提供消费数据
    "deliveryStatus": 0,  // 交付状态,0:已成功交付
    "lifetimeDollarsPurchased": 0,  // 用户在应用内购买的总金额,0 表示未知
    "lifetimeDollarsRefunded": 0,  // 用户在应用内退款的总金额,0 表示未知
    "platform": 1,  // 平台,0:未知,1:苹果平台,2:其他平台
    "playTime": 0,  // 用户在应用内的总时间,0 表示未知
    "refundPreference": 1,  // 商家对退款的意见,0:未知,1:支持,2:不支持,3:不确定
    "sampleContentProvided": True,  // 是否已经提供了示例内容
    "userStatus": 1,  // 用户账号状态,0:未知,1:活跃,2:暂停,3:关闭,4:受限
}

你可以根据需要,修改对应字段的值。

请求 Header

请求 Header 中有两个必填的自定义字段,分别是:

  • Content-Type 值固定是 application/json
  • Authorization 值为 Bearer $jwt_token

其中 jwt_token 必须要正确填写,否则请求会返回 401 错误。

jwt_token 的具体生成说明可见官方文档,大致格式类似下面这样:

Header:

{
  "kid": "ZA12345678",
  "alg": "ES256",
  "typ": "JWT"
}

Payload:

{
  "iss": "your_uuid",
  "iat": 1723173620,
  "exp": 1723183620,
  "aud": "appstoreconnect-v1",
  "bid": "your_bundle_id"
}

其中 kidiss,以及生成 JWT 时所需的私钥等几项,需要去 App Store Connect 后台生成。

JWT 私钥

如果你之前还没有生成过对应的私钥,可以前往 App Store Connect 后台的“用户和访问” → “集成” → “App 内购买项目”页面生成,如下图所示:

生成之后,可以在这个页面下载 .p8 格式的私钥。注意这个私钥只能下载一次,下载之后请妥善保存,如果不慎遗失,只能删除再重新生成一个。

上面生成 JWT 所需的 kid 对应上图中的“密钥 ID”,iss 对应“Issuer ID”,私钥即上面下载的 .p8 文件中的内容。

然后就可以用类似下面的方法生成 JWT 了:

import jwt

jwt_token = jwt.encode(
    payload,
    private_key,
    algorithm="ES256",
    headers=headers,
)

最后,将得到的 jwt_tokenBearer $jwt_token 的形式包含在请求头的 Authorization 中,发起 PUT 请求即可。

如果请求返回 202 状态码,表示请求成功了。如果是其他值,可根据错误状态再仔细检查处理。

在 Flutter 中适配 1Password 登录

2024-06-27 14:34:00

最近在开发 Flutter 项目,其中 iOS 版 App 账号登录时,需要适配 1Password 等密码管理器,即需要告诉 1Password 等密码管理器当前 App 的登录特征信息(域名),以及应该填写界面上的哪些表单项。在这儿记录一下要点。

基本设置

我们首先要处理的,是让 App 和某个域名(通常是官网域名)关联,这样在 App 中唤起 1Password 填写密码时,1Password 才知道应该显示哪些账号。

这儿主要有三个步骤。

Apple 开发者后台设置

在 Apple 开发者后台的 Certificates, Identifiers & Profiles 页面,记得要选中 Associated Domains 选项,如下图所示:

Xcode 中的设置

接下来,要在 Xcode 中为你的 App 添加关联域名,如下图所示:

在 Domains 那一栏,添加 webcredentials:你的域名 即可,比如你的域名是 test.com,那么添加 webcredentials:test.com 就行。

网站设置

最后,还需要在你的域名对应的网站上添加一个认证文件,证明指定 App 确实和当前域名相关。这个文件的文件名固定为 apple-app-site-association,可以放在网站的根目录,或者 .well-known 目录下,确保可以通过网络访问到。

这个文件的用途很多,可能还会包含一些其他字段,和密码管理器相关的主要是以下内容:

{
  "webcredentials": {
    "apps": [
      "TeamId.BundleId"
    ]
  }
}

确保你的 apple-app-site-association 文件包含 webcredentials 字段,并将其中 apps 中的 TeamId、BundleId 换成你的真实 ID。

Flutter 中的设置

为了得到更好的登录体验,Flutter 中也要做一些设置,主要是告诉 1Password 等密码管理器需要填写哪些字段,以及各个字段分别对应什么内容。

关键代码如下:

@override
Widget build(BuildContext context) {
  // ...
  return Container(
    body: Center(
      child: AutofillGroup(
        child: Column(
          children: [
            TextField(
              autofillHints: const [AutofillHints.email],
              decoration: InputDecoration(
                labelText: 'Email',
              ),
            ),
            TextField(
              autofillHints: const [AutofillHints.password],
              decoration: InputDecoration(
                labelText: 'Password',
              ),
            ),
            ElevatedButton(
              onPressed: () {
                // Submit the form
              },
              child: Text('Submit'),
            ),
          ],
        ),
      ),
    ),
  );
}

其中最关键的有两处,一是需要自动填写的表单部分,需要用 AutofillGroup 组件包起来,这样 1Password 就知道哪些字段是需要自动填写的。二是 Email、用户名、密码等需要填写的字段,需要添加 autofillHints 属性,比如 autofillHints: const [AutofillHints.email],这样 1Password 才知道当前字段应该填什么内容。

完成这些设置之后,App 登录时就应该能正常适配 1Password 了。