WWDC Quick Look 💓 By SwiftGGTeam
Meet CKTool JS

Meet CKTool JS

观看原视频

Highlight

这场 Session 介绍了 CKTool JS,一个基于 Node.js 的 CloudKit 命令行工具。它的定位是:让 CloudKit 的日常管理操作可以通过脚本自动化,而不是依赖 Xcode 或网页控制台。

核心内容

CloudKit 应用的数据放在 iCloud container 里。开发时,开发者要反复改 schema、查 record、写测试数据。过去,Apple 平台 App 可以用 CloudKit framework,网页可以用 CloudKit JS,自动化脚本则主要依赖 Xcode 13 引入的 macOS 命令行工具 cktool

00:57)CKTool JS 把这个工作流带到 JavaScript。它提供一组 npm package,让脚本可以直接调用 CloudKit 管理和数据库 API。Session 明确说,它支持和 cktool 命令行工具相同的操作类型,也被 CloudKit Console 用来实现添加 record type、查询 record 等功能。

这件事解决的是工具链问题。团队已经有 Node.js 构建脚本、CI 脚本、数据准备脚本时,不必为了 CloudKit schema 和 record 操作切换到另一个工具。schema 可以从 .ckdb 文件导入,record 可以用 JavaScript 查询、创建、更新、删除。

01:43)CKTool JS 还附带 TypeScript 类型定义。错误的调用方式可以在编译期暴露,IDE 也能补全 API。对于要长期维护的自动化脚本,这比一串裸 JSON 请求更容易审查。

详细内容

配置 CKTool JS

02:38)CKTool JS 由多个 @apple scope 下的 npm package 组成。主要包是 @apple/cktool.database。如果运行在 Node.js,还需要 @apple/cktool.target.nodejs;如果运行在浏览器,则使用 @apple/cktool.target.browser

03:10)因为 CKTool JS 会直接访问 iCloud,脚本需要 token。schema 导入、导出、校验、reset to production 这类管理操作使用 management token。访问用户私有数据需要 user token。两类 token 都从 CloudKit Console 获取。

// Create security object and setup default args

const { CKEnvironment } = require("@apple/cktool.database");

const security = {
    "ManagementTokenAuth": "<YOUR_MANAGEMENT_TOKEN>",
    "UserTokenAuth": "<YOUR_USER_TOKEN>"
};

const defaultArgs = {
    "teamId": "<YOUR_TEAM_ID>",
    "containerId": "<YOUR_CONTAINER_ID>",
    "environment": CKEnvironment.DEVELOPMENT
};

关键点:

  • 第 3 行从 @apple/cktool.database 取出 CKEnvironment
  • 第 5 到第 8 行保存认证信息。management token 用于 schema 等管理操作,user token 用于访问用户数据。
  • 第 10 到第 14 行保存每次调用都会用到的参数:team、container、environment。
  • CKEnvironment.DEVELOPMENT 对应开发环境。Session 建议在开发环境测试 schema 变更,避免影响生产数据。

07:17)有了认证和默认参数后,还要创建平台配置和 API 对象。createConfiguration 来自 target package,所以 Node.js 和浏览器会使用不同的底层配置。

// Create configuration and API objects

const { createConfiguration } = require("@apple/cktool.target.nodejs");
const { PromisesApi } = require("@apple/cktool.database");

const configuration = createConfiguration();
const api = new PromisesApi({
    "configuration": configuration,
    "security": security
});

关键点:

  • 第 3 行使用 Node.js target package 提供的 createConfiguration
  • 第 4 行引入 PromisesApi,它包含访问 iCloud 的异步方法。
  • 第 6 行创建平台相关配置。
  • 第 7 到第 10 行把配置和 token 交给 PromisesApi,后续的 schema 和 record 操作都从 api 发起。

.ckdb 文件管理 schema

08:40)Session 用一个硬币收藏 App 做例子。这个 App 有 CountriesCoinsComponents 等 record type。schema 可以写成 CloudKit Schema Language 文本文件,约定扩展名是 .ckdb

09:05)应用新 schema 前,通常先把开发环境 reset 到生产环境状态。CKTool JS 提供 resetToProduction。随后用 importSchema 把本地 .ckdb 文件上传到 container。

// Create a function to apply a schema

const { File } = require("@apple/cktool.target.nodejs");
const fs = require("fs/promises");
const path = require("path");

const importMySchema = async () => {
    const schemaPath = "<YOUR_SCHEMA_FILE>.ckdb";
    const buffer = await fs.readFile(schemaPath);
    const file = new File([buffer], schemaPath);
    await api.importSchema({ ...defaultArgs, "file": file });
}

// Chain the calls
api.resetToProduction(defaultArgs)
  .then(() => importMySchema());

关键点:

  • 第 3 行引入 CKTool JS 的 File,用于构造上传文件。
  • 第 4 行使用 Node.js 的 promise 版文件系统 API 读取 schema 文件。
  • 第 8 行指定 .ckdb schema 文件路径。
  • 第 9 行把文件读入 Buffer
  • 第 10 行把 Buffer 包装成 CKTool JS 可以上传的 File
  • 第 11 行调用 api.importSchema,并通过展开语法复用 defaultArgs
  • 第 15 到第 16 行先执行 resetToProduction,成功后再导入 schema,保证两个异步步骤按顺序运行。

这个流程适合放进 CI。schema 文件进入版本控制,脚本每次先把开发环境恢复到生产状态,再应用当前分支的 schema,测试就能在可预期的 container 状态下运行。

查询 record

10:52)除了 schema,CKTool JS 也能读写数据。Session 提醒,record 的 field value 会在客户端做类型和范围检查。大整数这类 JavaScript 原生类型不适合直接表示的值,需要使用 CKTool JS 的类型转换或 field value factory function。

12:02)访问 record 前,先整理数据库参数。这里选择 private database 和默认 zone。

// Create a database arguments object.

const {
    CKDatabaseType, CKEnvironment
} = require("@apple/cktool.database");

const databaseArgs = {
    "containerID": "<YOUR_CONTAINER_ID>",
    "environment": CKEnvironment.DEVELOPMENT,
    "databaseType": CKDatabaseType.PRIVATE,
    "zoneName": "_defaultZone"
};

关键点:

  • 第 3 到第 5 行引入 database type 和 environment 常量。
  • 第 8 行指定目标 container。
  • 第 9 行继续使用开发环境。
  • 第 10 行把操作限定在 private database。
  • 第 11 行指定默认 zone,后续查询、创建、更新、删除都会复用这个对象。

12:16)查询使用 queryRecords。示例根据国家的三位 ISO code 查找 Countries record。

// Define helper function for querying records

const { CKDBQueryFilterType } = require("@apple/cktool.database");
const countryQueryRecordForCountryCode3 = async (countryCode3) => {
    const response = await api.queryRecords({
        ...databaseArgs,
        "body": {
            "query": {
                "recordType": "Countries",
                "filters": [{
                    "fieldName": "isoCode3",
                    "fieldValue": makeRecordFieldValue.string(countryCode3),
                    "type": CKDBQueryFilterType.EQUALS
                }]
            }
        }
    });
    return response.result.records[0];
}

关键点:

  • 第 3 行引入查询过滤器类型。
  • 第 4 行定义异步 helper,输入是三位国家代码。
  • 第 6 行展开 databaseArgs,避免每次重复传 container、environment、database、zone。
  • 第 8 到第 15 行描述 CloudKit 查询体。
  • 第 9 行查询 Countries record type。
  • 第 11 到第 13 行用 makeRecordFieldValue.string(countryCode3) 构造字符串 field value,并用 EQUALS 做相等过滤。
  • 第 18 行从 response.result.records 取第一个匹配 record。

创建 record

12:58)创建 record 前,要把普通 JavaScript 值转换成 CloudKit record field value。示例里,硬币的国家字段是 reference,发行年份是 Int64,面值是 Double。

// Define a helper function for creating field values

const {
    makeRecordFieldValue, CKDBRecordReferenceAction
} = require("@apple/cktool.database");

const makeCoinFieldValues = ({ countryRecordName, issueYear, nominalValue }) => ({
    "country": makeRecordFieldValue.reference({
        recordName: countryRecordName,
        action: CKDBRecordReferenceAction.DELETE_SELF
    }),
    "issueYear": makeRecordFieldValue.int64(issueYear),
    "nominalValue": makeRecordFieldValue.double(nominalValue)
});

关键点:

  • 第 3 到第 5 行引入 field value factory 和 reference action 常量。
  • 第 7 行定义 helper,把原始硬币数据转换成 CloudKit field value 字典。
  • 第 8 到第 11 行把国家 record name 转成 reference field value。
  • 第 10 行使用 DELETE_SELF,表示被引用 record 删除时,这条 reference 所在 record 也会按该 action 处理。
  • 第 12 行把发行年份转成 Int64 field value。
  • 第 13 行把面值转成 Double field value。

13:26)真正创建数据时,调用 createRecord,把 record type 和 fields 放进 request body。

// Define helper method for creating coins

const coinCreateRecord = async (fields) => {
    const response = await api.createRecord({
        ...databaseArgs,
        "body": {
            "recordType": "Coins",
            "fields": fields
        },
    });
    return response.result.record;
}

关键点:

  • 第 3 行定义创建硬币 record 的异步 helper。
  • 第 5 行继续复用 databaseArgs
  • 第 7 行指定 record type 为 Coins
  • 第 8 行把上一个 helper 生成的 field value 字典传给 CloudKit。
  • 第 11 行返回服务器创建后的 record。

13:48)创建硬币前,脚本先查询国家 record,再把国家的 recordName 写入 reference。

// Call coin creation method with field values

const countryRecord = await countryQueryRecordForCountryCode3("USA");

const coinRecord1 = await coinCreateRecord(
    makeCoinFieldValues({
        "countryRecordName": countryRecord.recordName,
        "issueYear": 2007,
        "nominalValue": 0.10
    })
);

关键点:

  • 第 3 行查询 ISO code 为 USA 的国家 record。
  • 第 5 行调用创建 record 的 helper。
  • 第 7 行把国家 record 的 recordName 传给 reference field。
  • 第 8 行设置硬币发行年份。
  • 第 9 行设置硬币面值。
  • 第 5 到第 11 行整体返回新建的 coinRecord1,后续更新或删除会继续用到它。

更新和删除 record

14:16)更新 record 使用 updateRecord。Session 特别标注,更新时需要传入 recordChangeTag

// Define helper method for updating coins.
// Note that recordChangeTag is required

const coinUpdate =
    async (recordName, recordChangeTag, fields) => {
        const response = await api.updateRecord({
            ...databaseArgs,
            "recordName": recordName,
            "body": {
                "recordType": "Coins",
                "recordChangeTag": recordChangeTag,
                "fields": fields
            }
        });
        return response.result.record;
    }

关键点:

  • 第 5 行的 helper 接收 record name、record change tag 和新字段。
  • 第 7 行复用数据库参数。
  • 第 8 行指定要更新的 record。
  • 第 10 行指定 record type。
  • 第 11 行传入 recordChangeTag,用于更新 CloudKit record。
  • 第 12 行提交新的 field value 字典。
  • 第 15 行返回更新后的 record。

14:57)删除 record 更直接,只要传入数据库参数和 record name。

// Deleting a record

await api.deleteRecord({
    ...databaseArgs,
   "recordName": coinRecord1.recordName
});

关键点:

  • 第 3 行调用异步删除方法。
  • 第 4 行复用 container、environment、database、zone 参数。
  • 第 5 行指定要删除的 record name。
  • Session 的示例在开发环境操作。生产环境脚本应先查询确认目标 record,再执行删除。

核心启发

1. 给 CloudKit schema 做 PR 级验证

  • 做什么:把 .ckdb schema 文件放进仓库,每个 PR 跑一次 CKTool JS 脚本。
  • 为什么值得做:Session 展示了 resetToProductionimportSchema 的组合,可以把开发环境恢复到生产状态后再应用当前 schema。
  • 怎么开始:准备 management token,在 CI 中执行 api.resetToProduction(defaultArgs).then(() => importMySchema()),失败时阻止合并。

2. 为测试准备可重复的数据集

  • 做什么:在测试前创建一组固定的 CountriesCoins 或业务 record,测试后删除。
  • 为什么值得做:CKTool JS 可以用 JavaScript 查询、创建、更新、删除 record,适合接入已有 Node.js 测试工具链。
  • 怎么开始:先用 queryRecords 找依赖 record,再用 makeRecordFieldValue 构造字段,最后调用 createRecord 写入测试数据。

3. 做一个 schema 导入前的本地检查工具

  • 做什么:提交 schema 前,检查 .ckdb 文件是否存在、是否能读入、目标 environment 是否为 development。
  • 为什么值得做:Session 的导入示例需要读取本地文件、包装成 File、调用 importSchema。这些步骤都适合在脚本里提前校验。
  • 怎么开始:复用 fs.readFile 和 CKTool JS File 的导入流程,在真正调用 api.importSchema 前打印 team、container、environment。

4. 给运营或支持团队做受限数据修复脚本

  • 做什么:把常见修复动作封装成参数化脚本,例如按唯一字段查询 record,再修正某个 field。
  • 为什么值得做:Session 展示了按 isoCode3 查询 record、用 recordChangeTag 更新 record 的完整路径。
  • 怎么开始:把查询条件限制在唯一字段,更新时传入当前 record 的 recordNamerecordChangeTag,只允许脚本操作开发者明确列出的 record type。

关联 Session

评论

GitHub Issues · utterances