在我们自建这套系统之前,我们一直依赖 Crowdin 提供的 Over-The-Air Content Delivery 功能,为安装在众多服务器上的 QuickShop Hikari/Reremake 在线更新本地化语言文件。
那么好端端的,你们怎么突然自建了
很简单,付不起钱了?。
作为个人开发者维护的完全免费的软件,每年找要我们 100 USD 我们可吃不消,更何况用户越来越多。
最初 Crowdin 免费提供该功能,但在中途修改为了付费服务(然而我并不知情),直到22年12月圣诞节期间,Crowdin 团队年底清算的时候给我发来了一封邮件:
Crowdin CDN payment (support@crowdin.com )
Hi there, Greetings from Crowdin, hope you're going great? Sorry to bother you during the holiday season, but our team did an annual review and we noticed that your team is actively using Over-The-Air Content Delivery feature, which is a paid option. The price is based on the number of requests ($3/1M requests) and the transferred data ($2/10GB) - if you have up to 1M requests and 10GB of data transfer, CDN is free. According to our database, there were already sent 19M requests and 50GB of data was transferred for a total amount of 72 USD. You can also see this information in the Payments tab, and I'm also sharing the document with detailed statistics about your OTA usage in the last months; please find it attached. There is no need for you to pay the previous debt, but as you are already aware that CDN is a paid option, we kindly ask you to remove CDN from your code at your earliest convenience (preferably within the next month), so no new debt will grow or we can discuss the suitable ways for you to pay for future OTA usage if you would like to keep using it Please let me know your thoughts on the matter Looking forward to hearing from you,
当然,这就是另一个故事了——我们达成了协议:我们将配额降低到免费计划的许可范围内,然后免去我们的账单(72USD)。
尽管中间有许多的不愉快,但是我们还是解决了这个问题 —— 把我们的所有流量从 Crowdin 迁移到我们自己的基建上。
同时,为了尽量减少需要修改的代码,我们决定复刻一个兼容的 Crowdin Over-The-Air Content Delivery。
寻找合适的替代服务器
Crowdin 团队表示由于 Amazon 向他们收取请求费,因此 Crowdin 不得不向我们也收取这部分费用。
因此,我们需要在一个不限制请求数(最好也不限制流量)的服务上搭建我们自己的 OTA 服务,最重要的一点是需要在我们能够支付的起的范围之内。
Crowdin 给我附上了一个账单明细,这份文件对我们估计使用量有很大的帮助:
cdn_usage.txt
+----------+----------+-----------------------+ | date | requests | transferred_gigabytes | +----------+----------+-----------------------+ | Apr 2022 | 1241624 | 4.729 | | May 2022 | 1374644 | 3.768 | | Jun 2022 | 2477846 | 6.025 | | Jul 2022 | 2649011 | 6.571 | | Aug 2022 | 2814239 | 6.446 | | Sep 2022 | 2255808 | 4.454 | | Oct 2022 | 2257621 | 5.098 | | Nov 2022 | 2087014 | 4.902 | | Dec 2022 | 1924092 | 7.711 |
我们按照每个月小于 500w 次 HTTP(S) 请求,每个月大约在 15 GB 的流量来寻找替代服务。
我们最后选择的是:CloudFlare R2
最终我们选择的 CloudFlare R2 收费标准
对于读请求,我们只要控制在每个月 Class B 请求数(包含各种读操作)小于 1000 万次,存储量每个月小于 10 GB,那么我们的 R2 桶就是完全免费的。至于 Class A 请求数(包含各种写操作),100万次也绰绰有余了(毕竟只有我们释放更新的时候才会消耗 Class A 的配额)。
同时,CloudFlare R2 服务使用 CloudFlare CDN 的能力 —— 这意味着普通 CDN 有的功能它都有——也意味着免流量费。
有 CDN 兜底的话,只要确保请求不会打穿 CDN,那么甚至不会消耗 Class B 的配额。
在 R2 上复刻一套 Crowdin 的 Over-The-Air Content Delivery
分析工作原理
Content Delivery 的 API 相当简单,就是按照一个 manifest.json
给的内容进行字符串拼接,就能得到目的文件路径。
那么首先看看官方的 manifest.json
包含哪些内容:
Crowdin 官方 - manifest.json
{"files":["\/hikari\/crowdin\/lang\/%locale%\/messages.yml","\/hikari\/crowdin\/lang\/%locale%\/example.yml"],"languages":["af","ar","bg","ca","zh-CN","zh-TW","zh-HK","cs","da","nl","en","fi","fr","de","el","he","hi","hu","it","ja","ko","lt","no","pl","pt-PT","pt-BR","ro","ru","sr","es-ES","sv-SE","th","tr","uk","vi"],"language_mapping":{"ro":{"locale":"ro-RO"},"fr":{"locale":"fr-FR"},"es-ES":{"locale":"es-ES"},"af":{"locale":"af-ZA"},"ar":{"locale":"ar-SA"},"bg":{"locale":"bg-BG"},"ca":{"locale":"ca-ES"},"cs":{"locale":"cs-CZ"},"da":{"locale":"da-DK"},"de":{"locale":"de-DE"},"el":{"locale":"el-GR"},"fi":{"locale":"fi-FI"},"he":{"locale":"he-IL"},"hu":{"locale":"hu-HU"},"it":{"locale":"it-IT"},"ja":{"locale":"ja-JP"},"ko":{"locale":"ko-KR"},"lt":{"locale":"lt-LT"},"nl":{"locale":"nl-NL"},"no":{"locale":"no-NO"},"pl":{"locale":"pl-PL"},"pt-PT":{"locale":"pt-PT"},"ru":{"locale":"ru-RU"},"sr":{"locale":"sr-SP"},"sv-SE":{"locale":"sv-SE"},"tr":{"locale":"tr-TR"},"uk":{"locale":"uk-UA"},"zh-CN":{"locale":"zh-CN"},"zh-TW":{"locale":"zh-TW"},"en":{"locale":"en-US"},"vi":{"locale":"vi-VN"},"pt-BR":{"locale":"pt-BR"},"th":{"locale":"th-TH"},"hi":{"locale":"hi-IN"},"zh-HK":{"locale":"zh-HK"}},"custom_languages":[],"timestamp":1672672146,"content":{"af":["\/content\/hikari\/crowdin\/lang\/af-ZA\/messages.yml","\/content\/hikari\/crowdin\/lang\/af-ZA\/example.yml"],"ar":["\/content\/hikari\/crowdin\/lang\/ar-SA\/messages.yml","\/content\/hikari\/crowdin\/lang\/ar-SA\/example.yml"],"bg":["\/content\/hikari\/crowdin\/lang\/bg-BG\/messages.yml","\/content\/hikari\/crowdin\/lang\/bg-BG\/example.yml"],"ca":["\/content\/hikari\/crowdin\/lang\/ca-ES\/messages.yml","\/content\/hikari\/crowdin\/lang\/ca-ES\/example.yml"],"zh-CN":["\/content\/hikari\/crowdin\/lang\/zh-CN\/messages.yml","\/content\/hikari\/crowdin\/lang\/zh-CN\/example.yml"],"zh-TW":["\/content\/hikari\/crowdin\/lang\/zh-TW\/messages.yml","\/content\/hikari\/crowdin\/lang\/zh-TW\/example.yml"],"zh-HK":["\/content\/hikari\/crowdin\/lang\/zh-HK\/messages.yml","\/content\/hikari\/crowdin\/lang\/zh-HK\/example.yml"],"cs":["\/content\/hikari\/crowdin\/lang\/cs-CZ\/messages.yml","\/content\/hikari\/crowdin\/lang\/cs-CZ\/example.yml"],"da":["\/content\/hikari\/crowdin\/lang\/da-DK\/messages.yml","\/content\/hikari\/crowdin\/lang\/da-DK\/example.yml"],"nl":["\/content\/hikari\/crowdin\/lang\/nl-NL\/messages.yml","\/content\/hikari\/crowdin\/lang\/nl-NL\/example.yml"],"en":["\/content\/hikari\/crowdin\/lang\/en-US\/messages.yml","\/content\/hikari\/crowdin\/lang\/en-US\/example.yml"],"fi":["\/content\/hikari\/crowdin\/lang\/fi-FI\/messages.yml","\/content\/hikari\/crowdin\/lang\/fi-FI\/example.yml"],"fr":["\/content\/hikari\/crowdin\/lang\/fr-FR\/messages.yml","\/content\/hikari\/crowdin\/lang\/fr-FR\/example.yml"],"de":["\/content\/hikari\/crowdin\/lang\/de-DE\/messages.yml","\/content\/hikari\/crowdin\/lang\/de-DE\/example.yml"],"el":["\/content\/hikari\/crowdin\/lang\/el-GR\/messages.yml","\/content\/hikari\/crowdin\/lang\/el-GR\/example.yml"],"he":["\/content\/hikari\/crowdin\/lang\/he-IL\/messages.yml","\/content\/hikari\/crowdin\/lang\/he-IL\/example.yml"],"hi":["\/content\/hikari\/crowdin\/lang\/hi-IN\/messages.yml","\/content\/hikari\/crowdin\/lang\/hi-IN\/example.yml"],"hu":["\/content\/hikari\/crowdin\/lang\/hu-HU\/messages.yml","\/content\/hikari\/crowdin\/lang\/hu-HU\/example.yml"],"it":["\/content\/hikari\/crowdin\/lang\/it-IT\/messages.yml","\/content\/hikari\/crowdin\/lang\/it-IT\/example.yml"],"ja":["\/content\/hikari\/crowdin\/lang\/ja-JP\/messages.yml","\/content\/hikari\/crowdin\/lang\/ja-JP\/example.yml"],"ko":["\/content\/hikari\/crowdin\/lang\/ko-KR\/messages.yml","\/content\/hikari\/crowdin\/lang\/ko-KR\/example.yml"],"lt":["\/content\/hikari\/crowdin\/lang\/lt-LT\/messages.yml","\/content\/hikari\/crowdin\/lang\/lt-LT\/example.yml"],"no":["\/content\/hikari\/crowdin\/lang\/no-NO\/messages.yml","\/content\/hikari\/crowdin\/lang\/no-NO\/example.yml"],"pl":["\/content\/hikari\/crowdin\/lang\/pl-PL\/messages.yml","\/content\/hikari\/crowdin\/lang\/pl-PL\/example.yml"],"pt-PT":["\/content\/hikari\/crowdin\/lang\/pt-PT\/messages.yml","\/content\/hikari\/crowdin\/lang\/pt-PT\/example.yml"],"pt-BR":["\/content\/hikari\/crowdin\/lang\/pt-BR\/messages.yml","\/content\/hikari\/crowdin\/lang\/pt-BR\/example.yml"],"ro":["\/content\/hikari\/crowdin\/lang\/ro-RO\/messages.yml","\/content\/hikari\/crowdin\/lang\/ro-RO\/example.yml"],"ru":["\/content\/hikari\/crowdin\/lang\/ru-RU\/messages.yml","\/content\/hikari\/crowdin\/lang\/ru-RU\/example.yml"],"sr":["\/content\/hikari\/crowdin\/lang\/sr-SP\/messages.yml","\/content\/hikari\/crowdin\/lang\/sr-SP\/example.yml"],"es-ES":["\/content\/hikari\/crowdin\/lang\/es-ES\/messages.yml","\/content\/hikari\/crowdin\/lang\/es-ES\/example.yml"],"sv-SE":["\/content\/hikari\/crowdin\/lang\/sv-SE\/messages.yml","\/content\/hikari\/crowdin\/lang\/sv-SE\/example.yml"],"th":["\/content\/hikari\/crowdin\/lang\/th-TH\/messages.yml","\/content\/hikari\/crowdin\/lang\/th-TH\/example.yml"],"tr":["\/content\/hikari\/crowdin\/lang\/tr-TR\/messages.yml","\/content\/hikari\/crowdin\/lang\/tr-TR\/example.yml"],"uk":["\/content\/hikari\/crowdin\/lang\/uk-UA\/messages.yml","\/content\/hikari\/crowdin\/lang\/uk-UA\/example.yml"],"vi":["\/content\/hikari\/crowdin\/lang\/vi-VN\/messages.yml","\/content\/hikari\/crowdin\/lang\/vi-VN\/example.yml"]},"mapping":["\/mapping\/hikari\/crowdin\/lang\/en-US\/messages.yml","\/mapping\/hikari\/crowdin\/lang\/en-US\/example.yml"]}
经过分析可以得到:
files
字段包含这个 Distribution 的所有文件,并且是有序的
languages
包含这个 Distribution 中有哪些语言,且是 Crowdin 格式的代码
language_mapping
是我们在 Crowdin 项目的 "Language Mapping" 中设置的语言代码映射。我们之前将其映射为了类似 Minecraft 中使用的格式,这样方便我们在项目里进行转换。
custom_languages
为空,我们没有添加任何自定义语言
timestamp
是这份 manifest 的生成时间,我们可以用这个时间戳进行检查翻译文件是否有更新,并仅在需要时更新本地缓存的翻译文件
content
是另一个有序列表 ,按顺序存储 files
字段中的文件在该语言下所对应的路径
mapping
是 Crowdin 内部分析词条关系的,在我们的项目内没有用到,所以我们忽略了(也是最近才新增的功能)
获取翻译
分析完了原理,那么我们首先需要从 Crowdin 取到翻译。好在 Crowdin 有现成的 API 可用。
提供 PROJECT_ID
, PROJECT_BRANCH_ID
就可以要求 Crowdin 构建 和下载 翻译(一个压缩包)。
构建需要时间,可以轮询 Crowdin 的一个 API 获取是否完成,以及获取翻译 ID 。
Project ID 可以从项目首页看到:
QuickShop-Hikari 的 Project ID 为 524354
Project Branch ID 可以在审查元素或者通过请求 API 中找到:
hikari branch 的 ID 是 126
生成 manifest 文件
Crowdin 的 Distribution 的 manifest 并不是动态的,因此我们可以直接甩一个静态的 JSON 文件到 R2 上去就可以了。
至于结构,对着上面的分析照猫画虎 就可以了。
以下是我们生成的 manifest 文件:
CrowdinCopyDeploy 生成的 - manifest.json
{"files":["/hikari/crowdin/lang/%locale%/messages.yml","/hikari/crowdin/lang/%locale%/example.yml"],"languages":["ro","fr","es-ES","af","ar","bg","ca","cs","da","de","el","fi","he","hu","it","ja","ko","lt","nl","no","pl","pt-PT","ru","sr","sv-SE","tr","uk","zh-CN","zh-TW","en","vi","pt-BR","th","hi","zh-HK"],"language_mapping":{"ro":{"locale":"ro-RO"},"fr":{"locale":"fr-FR"},"es-ES":{"locale":"es-ES"},"af":{"locale":"af-ZA"},"ar":{"locale":"ar-SA"},"bg":{"locale":"bg-BG"},"ca":{"locale":"ca-ES"},"cs":{"locale":"cs-CZ"},"da":{"locale":"da-DK"},"de":{"locale":"de-DE"},"el":{"locale":"el-GR"},"fi":{"locale":"fi-FI"},"he":{"locale":"he-IL"},"hu":{"locale":"hu-HU"},"it":{"locale":"it-IT"},"ja":{"locale":"ja-JP"},"ko":{"locale":"ko-KR"},"lt":{"locale":"lt-LT"},"nl":{"locale":"nl-NL"},"no":{"locale":"no-NO"},"pl":{"locale":"pl-PL"},"pt-PT":{"locale":"pt-PT"},"ru":{"locale":"ru-RU"},"sr":{"locale":"sr-SP"},"sv-SE":{"locale":"sv-SE"},"tr":{"locale":"tr-TR"},"uk":{"locale":"uk-UA"},"zh-CN":{"locale":"zh-CN"},"zh-TW":{"locale":"zh-TW"},"en":{"locale":"en-US"},"vi":{"locale":"vi-VN"},"pt-BR":{"locale":"pt-BR"},"th":{"locale":"th-TH"},"hi":{"locale":"hi-IN"},"zh-HK":{"locale":"zh-HK"}},"custom_languages":[],"timestamp":1674216441,"content":{"ro":["/content/hikari/crowdin/lang/ro-RO/messages.yml","/content/hikari/crowdin/lang/ro-RO/example.yml"],"fr":["/content/hikari/crowdin/lang/fr-FR/messages.yml","/content/hikari/crowdin/lang/fr-FR/example.yml"],"es-ES":["/content/hikari/crowdin/lang/es-ES/messages.yml","/content/hikari/crowdin/lang/es-ES/example.yml"],"af":["/content/hikari/crowdin/lang/af-ZA/messages.yml","/content/hikari/crowdin/lang/af-ZA/example.yml"],"ar":["/content/hikari/crowdin/lang/ar-SA/messages.yml","/content/hikari/crowdin/lang/ar-SA/example.yml"],"bg":["/content/hikari/crowdin/lang/bg-BG/messages.yml","/content/hikari/crowdin/lang/bg-BG/example.yml"],"ca":["/content/hikari/crowdin/lang/ca-ES/messages.yml","/content/hikari/crowdin/lang/ca-ES/example.yml"],"cs":["/content/hikari/crowdin/lang/cs-CZ/messages.yml","/content/hikari/crowdin/lang/cs-CZ/example.yml"],"da":["/content/hikari/crowdin/lang/da-DK/messages.yml","/content/hikari/crowdin/lang/da-DK/example.yml"],"de":["/content/hikari/crowdin/lang/de-DE/messages.yml","/content/hikari/crowdin/lang/de-DE/example.yml"],"el":["/content/hikari/crowdin/lang/el-GR/messages.yml","/content/hikari/crowdin/lang/el-GR/example.yml"],"fi":["/content/hikari/crowdin/lang/fi-FI/messages.yml","/content/hikari/crowdin/lang/fi-FI/example.yml"],"he":["/content/hikari/crowdin/lang/he-IL/messages.yml","/content/hikari/crowdin/lang/he-IL/example.yml"],"hu":["/content/hikari/crowdin/lang/hu-HU/messages.yml","/content/hikari/crowdin/lang/hu-HU/example.yml"],"it":["/content/hikari/crowdin/lang/it-IT/messages.yml","/content/hikari/crowdin/lang/it-IT/example.yml"],"ja":["/content/hikari/crowdin/lang/ja-JP/messages.yml","/content/hikari/crowdin/lang/ja-JP/example.yml"],"ko":["/content/hikari/crowdin/lang/ko-KR/messages.yml","/content/hikari/crowdin/lang/ko-KR/example.yml"],"lt":["/content/hikari/crowdin/lang/lt-LT/messages.yml","/content/hikari/crowdin/lang/lt-LT/example.yml"],"nl":["/content/hikari/crowdin/lang/nl-NL/messages.yml","/content/hikari/crowdin/lang/nl-NL/example.yml"],"no":["/content/hikari/crowdin/lang/no-NO/messages.yml","/content/hikari/crowdin/lang/no-NO/example.yml"],"pl":["/content/hikari/crowdin/lang/pl-PL/messages.yml","/content/hikari/crowdin/lang/pl-PL/example.yml"],"pt-PT":["/content/hikari/crowdin/lang/pt-PT/messages.yml","/content/hikari/crowdin/lang/pt-PT/example.yml"],"ru":["/content/hikari/crowdin/lang/ru-RU/messages.yml","/content/hikari/crowdin/lang/ru-RU/example.yml"],"sr":["/content/hikari/crowdin/lang/sr-SP/messages.yml","/content/hikari/crowdin/lang/sr-SP/example.yml"],"sv-SE":["/content/hikari/crowdin/lang/sv-SE/messages.yml","/content/hikari/crowdin/lang/sv-SE/example.yml"],"tr":["/content/hikari/crowdin/lang/tr-TR/messages.yml","/content/hikari/crowdin/lang/tr-TR/example.yml"],"uk":["/content/hikari/crowdin/lang/uk-UA/messages.yml","/content/hikari/crowdin/lang/uk-UA/example.yml"],"zh-CN":["/content/hikari/crowdin/lang/zh-CN/messages.yml","/content/hikari/crowdin/lang/zh-CN/example.yml"],"zh-TW":["/content/hikari/crowdin/lang/zh-TW/messages.yml","/content/hikari/crowdin/lang/zh-TW/example.yml"],"en":["/content/hikari/crowdin/lang/en-US/messages.yml","/content/hikari/crowdin/lang/en-US/example.yml"],"vi":["/content/hikari/crowdin/lang/vi-VN/messages.yml","/content/hikari/crowdin/lang/vi-VN/example.yml"],"pt-BR":["/content/hikari/crowdin/lang/pt-BR/messages.yml","/content/hikari/crowdin/lang/pt-BR/example.yml"],"th":["/content/hikari/crowdin/lang/th-TH/messages.yml","/content/hikari/crowdin/lang/th-TH/example.yml"],"hi":["/content/hikari/crowdin/lang/hi-IN/messages.yml","/content/hikari/crowdin/lang/hi-IN/example.yml"],"zh-HK":["/content/hikari/crowdin/lang/zh-HK/messages.yml","/content/hikari/crowdin/lang/zh-HK/example.yml"]}}
创建 content 文件结构
content 的目录文件结构和压缩包给出的是一致的,不需要额外操作,但是的确需要解压 之后小小的移动一下 。
最后
都完事之后调用 S3 API 上传到 R2 并且刷新缓存就可以啦!
CloudFlare R2 控制面板
配置 CloudFlare CDN
由于如果请求数超限 CloudFlare 还是会向我们收费,首先我们需要合理配置一下 CDN 的缓存规则:
忽略所有查询字符串:
每次部署我们都会手动刷新缓存,所以字符串并不必要
配置一下 WAF 和速率限制,过滤不可能是 QuickShop 的请求:
对于可疑的请求,强制验证
最后成果
通过发布新版本和指导用户使用启动命令行参数修改 Crowdin OTA 主机后,我们在不修改已有的 Crowdin 相关代码的情况下使得所有用户平滑迁移到了新的基建。
以及 CloudFlare 帮助我们保住了我们的钱包,目前我们每年只要支付域名费用(相当便宜)就可以了。
CloudFlare 统计数据 - 最近 30 天
至于触发构建,那当然是交给 Github Actions 啦。
Github Actions 节选
文章中提到的项目
CrowdinCopyDeploy - 该工具用于创建兼容的 Over-The-Air Content Delivery 的 R2 环境
CrowdinOTA - 一个带有缓存控制和多线程下载的 Crowdin Over-The-Air Content Delivery 客户端