vbr.me App Store 价格查询:Spring Boot + Alpine.js + 13 地区汇率转换怎么搭
posts posts 2026-07-11T21:09:58+08:00app.vbr.me 是一个轻量级的 App Store 全球比价工具,覆盖 13 个国家/地区(美/中/台/港/日/韩/菲/土/尼/印/巴/巴/埃),支持内购项目对比和人民币汇率换算。本文拆开它的 GitHub 仓库 hypooo/app-store-price(81 commits)的完整实现:前端 Alpine.js + Tailwind CSS CDN + Axios 单一 component 架构、后端 Java 21 + SpringBoot 4 + Jsoup 解析 #serialized-server-data 嵌入 JSON、13 个 AreaEnum 地区定义、Hutool TimedCache + 细粒度锁的缓存策略(应用列表 1 天 + 应用详情 1 天 + 汇率 1 天)、Amazon Corretto 21-alpine 的 Dockerfile 设计、ExchangeRateUtil 通过 open.er-api.com 拉实时汇率的转换算法,并补一段对独立爬虫项目作者可复用的工程经验。技术笔记Spring Boot, Java 21, Alpine.js, Tailwind CSS, Jsoup, App Store, Price Tracker, Docker, Corretto, Hutool, Exchange Rate API, iOSvbr.me App Store 价格查询:Spring Boot + Alpine.js + 13 地区汇率转换怎么搭
学习目标
读完本文后,你应当能够:
- 说出 app.vbr.me 这个 App Store 价格查询工具的完整架构(前端 + 后端 + 数据源 + 缓存 + 部署)
- 解释为什么 GitHub 仓库
hypooo/app-store-price选择了 Java 21 + Spring Boot 4 而非 Python(FastAPI)或 Node(Express) - 拆解
AppService.java里的双重检查细粒度锁模式(按 appName + areaCode 锁,不是按 cache 全局锁) - 描述
#serialized-server-data是怎么成为 App Store 反爬的"金钥匙"的——它藏在每个 App Store 页面里 - 画出 13 个 AreaEnum 地区定义的二维表(code/name/currency/locale),知道为什么内购文案需要按 locale 区分
- 复述 Hutool TimedCache 的 TTL 设计 + 汇率 API 选
open.er-api.com而非exchangerate-api.com的工程原因
本文目录
- vbr.me 是什么
- 整体架构:5 个工程决策
- 前端:Alpine.js + Tailwind CSS 单 component
- 后端:Spring Boot 4 + Java 21 + Jsoup
- 13 个地区:AreaEnum 设计
- 数据源:#serialized-server-data 与 open.er-api.com
- 缓存策略:Hutool TimedCache + 细粒度锁
- 部署:Amazon Corretto 21-alpine + Docker Compose
- 对独立爬虫项目作者的 5 条工程经验
- 关键资源与延伸阅读
1. vbr.me 是什么
打开 app.vbr.me,第一眼看上去是一个清爽的单页应用:搜索框、国家选择器、对比表格。功能很直白——搜索一个 iOS 应用,看它在 13 个国家/地区的定价、内购项目和人民币折算价。
这听起来像一个不起眼的小工具,但它解决的是一个真实的痛点:iOS 应用在不同国家/地区的价格不透明到令人抓狂。同一个 ChatGPT 订阅,美区 $20/月 vs 港区 HK$158/月 vs 日区 ¥3,200/月 vs 印度区 ₹1,650/月——换算成人民币差距巨大。但你没法直接在 App Store 里跨区比较,必须一个区一个区注册账号。
vbr.me 把这个流程压缩到一个搜索框。背后的实现藏在一个不太起眼的 GitHub 仓库里:https://github.com/hypooo/app-store-price,81 个 commits,2025-10-28 创建。下面是逐层拆解。
2. 整体架构:5 个工程决策
打开浏览器开发者工具看 app.vbr.me 的请求,你会发现整套架构只有 5 个核心模块:
┌─────────────────────────────────────────────────────────┐
│ Browser (单页应用) │
│ - Alpine.js 3.13.3 (component) │
│ - Tailwind CSS 3.4.10 (CDN) │
│ - Axios (HTTP client) │
│ - Flag Icons (lipis/flag-icons) │
└────────────────────────┬────────────────────────────────┘
│ POST /app/*
▼
┌─────────────────────────────────────────────────────────┐
│ Spring Boot 4.1.0 (Java 21) │
│ - Controller: AppController (3 个 API) │
│ - Service: AppService (锁 + 缓存 + 爬虫) │
│ - POJO: request / response DTOs + AreaEnum │
│ - Common: BizException / ExchangeRateUtil / LogUtil │
└────────────────────────┬────────────────────────────────┘
│ HTTPS GET
▼
┌─────────────────────────────────────────────────────────┐
│ App Store 公开页面 + open.er-api.com │
│ - https://apps.apple.com/{areaCode}/{iphone|...}/... │
│ - 解析 #serialized-server-data 嵌入 JSON │
│ - 汇率:https://open.er-api.com/v6/latest/{currency} │
└─────────────────────────────────────────────────────────┘5 个关键工程决策藏在每个模块里:
| 决策 | 选择 | 工程理由 |
|---|---|---|
| 前端框架 | Alpine.js(不是 React/Vue) | 全应用只是"一个搜索 + 一个表格",Alpine 的"HTML 内嵌指令"比 React 组件树轻 90% |
| CSS | Tailwind CSS CDN(不是构建) | 没有工程化诉求,CDN 跑 JIT 已经够,节省 webpack/postcss 复杂度 |
| CDN | jsdmirror.com + font.im | 中国大陆镜像,避免 cdn.jsdelivr.net 偶尔被墙 |
| 后端语言 | Java 21(不是 Python/Node) | 爬虫 + 并行 + 缓存需要强类型 + 高并发;Lombok + Hutool 把 Java 啰嗦的部分消掉 |
| 数据库 | 无 | 应用列表/详情/汇率全部走 HTTP 爬取 + 内存缓存,不持久化任何数据 |
最后一条决策是最值得说的。整个项目没有数据库,连 SQLite 都不用。Hutool 的 TimedCache 把所有数据存在 JVM 堆里。这种"零数据库"设计在爬虫项目里特别合适——你的数据源就是别人的接口,没必要多一层存储抽象。
3. 前端:Alpine.js + Tailwind CSS 单 component
打开 https://app.vbr.me/,右键查看源码,会看到一个 987 行的 HTML 文件,里面只有 6 个 <script> 标签(其中 4 个是外部 CDN,2 个是 Umami analytics),以及一个嵌入在 <body> 里长达 ~700 行的 x-data="appStore()" 表达式。
Alpine.js 是这个项目的灵魂。它的工作方式是把整个应用建模成一个 Alpine component:
<body x-data="appStore()" x-init="init()">
<!-- 标题、搜索栏、结果区、Footer 全部用 Alpine 指令 -->
<div x-show="loading" class="spinner"></div>
<button @click="searchAppList()">搜索</button>
<template x-for="price in comparisonResults" :key="price.area">
<div x-text="formatPrice(price.price)"></div>
</template>
</body>所有逻辑都在 appStore() 函数里:
function appStore() {
return {
// 状态变量
appName: 'ChatGPT',
areaCode: 'us',
appList: [],
results: [],
colorMode: localStorage.getItem('colorMode') || 'system',
// 当前选中的应用(基于地区)
get currentApp() {
if (!this.results || this.results.length === 0) return {};
return this.results.find(r => r.area.toLowerCase() === this.areaCode.toLowerCase())
|| this.results[0] || {};
},
// 初始化
init() {
this.getAreaList();
this.getPopularSearchWords();
this.applyColorMode();
},
// 搜索应用列表
async searchAppList() {
if (!this.appName.trim()) { this.errorAppList = '请输入应用名称'; return; }
if (this.appName.trim().length > 20) {
this.errorAppList = '应用名称长度不能超过20个字符';
return;
}
this.loadingAppList = true;
try {
const response = await axios.post('/app/getAppList', {
appName: this.appName.trim(),
areaCode: this.areaCode
});
if (response.data.code === 0) {
this.appList = response.data.data || [];
}
} catch (err) {
this.errorAppList = '网络请求失败,请稍后重试';
} finally {
this.loadingAppList = false;
}
},
// ...
};
}Alpine 的工程哲学:HTML 是骨架,x-data 是状态,@click / x-show / x-for 是行为。没有虚拟 DOM、没有 JSX、没有构建步骤。这与 React/Vue 的"组件树 + 状态管理 + 路由"形成鲜明对比。
Tailwind CSS 3.4.10 CDN 的引入也很聪明——<script src="https://cdn.jsdmirror.com/npm/tailwindcss-cdn@3.4.10/tailwindcss.js"> 在浏览器里跑 JIT 编译,所有 utility class 当场生效。完全省掉了 webpack/postcss/tailwind.config.js。
最值得说的是 CDN 选择。jsdmirror.com 是 cdn.jsdelivr.net 的中国镜像,font.im 是 Google Fonts 的中国镜像。这两个选择透露出作者的部署假设:服务对象主要是中国大陆用户——否则没必要主动绕开官方 CDN。
4. 后端:Spring Boot 4 + Java 21 + Jsoup
后端是整个项目最有学习价值的部分。它是个极简的 Spring Boot 项目,只有 1 个 Controller、1 个 Service、13 个 POJO 类、3 个 Common 工具类。
4.1 Controller:3 个 API
AppController.java 全部 90 行,定义了 3 个 POST 接口:
@RestController
@RequestMapping("app")
@RequiredArgsConstructor
public class AppController {
private final AppService appService;
@PostMapping("getAreaList")
public List<AreaResDTO> getAreaList() {
return appService.getAreaList();
}
@PostMapping("getPopularSearchWordList")
public List<String> getPopularSearchWordList() {
return appService.getPopularSearchWordList();
}
@PostMapping("getAppList")
public List<GetAppListResDTO> getAppList(@RequestBody @Validated GetAppListReqDTO reqDTO) {
return appService.getAppList(reqDTO);
}
@PostMapping("getAppInfo")
public List<GetAppInfoResDTO> getAppInfo(@RequestBody @Validated GetAppInfoReqDTO reqDTO) {
return appService.getAppInfo(reqDTO.getAppId());
}
}注意所有接口都是 POST——不是 GET。这是有意的设计:
- 请求体带参数(appName、areaCode、appId),GET 的 URL 长度限制更严
- POST 不被浏览器缓存——App Store 价格会变化,避免 CDN 缓存过期数据
- POST 不出现在浏览器历史——避免泄露搜索记录
4.2 Service:核心爬虫逻辑
AppService.java 是真正的工作引擎。它实现了两件事:
getAppList(reqDTO):按 appName + areaCode 搜索,返回候选应用列表getAppInfo(appId):按 appId 拉 13 个地区的详情,并行抓取
getAppList 的关键逻辑:
public List<GetAppListResDTO> getAppList(GetAppListReqDTO reqDTO) {
// 1. 记录搜索次数(用于热门搜索)
POPULAR_SEARCH_WORD.add(reqDTO.getAppName());
// 2. 缓存 key:{areaCode}-{appName}
String cacheKey = StrUtil.format("{}-{}", reqDTO.getAreaCode(), reqDTO.getAppName());
List<GetAppListResDTO> appListCache = APP_LIST_CACHE.get(cacheKey);
if (CollUtil.isNotEmpty(appListCache)) {
return appListCache;
}
// 3. 细粒度锁:按 (areaCode, appName) 分段
Object lock = LOCK_POOL.computeIfAbsent(
StrUtil.format("getAppList-{}-{}", reqDTO.getAreaCode(), reqDTO.getAppName()),
k -> new Object()
);
synchronized (lock) {
// 4. 锁内双重检查
appListCache = APP_LIST_CACHE.get(cacheKey);
if (CollUtil.isNotEmpty(appListCache)) {
return appListCache;
}
// 5. 并行抓 4 个平台(iphone/ipad/mac/tv)
List<GetAppListResDTO> resultList = new CopyOnWriteArrayList<>();
CollUtil.newArrayList("iphone", "ipad", "mac", "tv").parallelStream().forEach(entity -> {
String searchUrl = StrUtil.format(
"https://apps.apple.com/{}/{}/search?term={}",
reqDTO.getAreaCode(), entity, StrUtil.trim(reqDTO.getAppName())
);
HttpResponse response = HttpUtil.createGet(searchUrl).execute();
// 6. 解析 HTML
Document doc = Jsoup.parse(response.body());
Element scriptElement = doc.selectFirst("#serialized-server-data");
// 7. Fastjson2 解析 + 提取 items
// ...
});
// ...
}
}这段代码做了 7 件事,每一件都是工程上值得展开的:
- 搜索计数(
POPULAR_SEARCH_WORD.add)——为热门搜索词功能积累数据 - 缓存 key 用
-分隔({areaCode}-{appName})——避免不同 area 的同 appName 撞 key - 细粒度锁池(
LOCK_POOL是 ConcurrentHashMap)——同一 (areaCode, appName) 共享一个锁对象,不同请求之间不互斥 computeIfAbsent—— 锁对象懒创建,避免内存浪费- 双重检查 —— 锁外查一次,锁内查一次,避免不必要的锁等待
- 4 平台并行 —— 同一 areaCode 下并行抓 iphone/ipad/mac/tv 的搜索页
#serialized-server-data—— App Store 反爬的"金钥匙"
getAppInfo(appId) 是另一个值得说的函数——它对 13 个 AreaEnum 并行抓取:
public List<GetAppInfoResDTO> getAppInfo(String appId) {
// ... 缓存检查 + 细粒度锁
Arrays.stream(AreaEnum.values()).parallel().forEach(areaEnum -> {
String appStoreUrl = StrUtil.format(
"https://apps.apple.com/{}/app/id{}",
areaEnum.getCode(), appId
);
HttpResponse response = HttpUtil.createGet(appStoreUrl, true).execute();
if (!response.isOk()) {
log.error("appId: {}, app not found in {} app store", appId, areaEnum.getCode());
return;
}
Document doc = Jsoup.parse(response.body());
GetAppInfoResDTO resDTO = new GetAppInfoResDTO();
resDTO.setAppId(appId);
resDTO.setArea(areaEnum.getCode());
resDTO.setAreaName(areaEnum.getName());
Element scriptElement = doc.selectFirst("#serialized-server-data");
if (Objects.isNull(scriptElement)) {
log.error("appId: {}, area: {}, script element not found", appId, areaEnum.getCode());
return;
}
JSONObject jsonResult = JSON.parseObject(scriptElement.html().trim())
.getJSONArray("data").getJSONObject(0).getJSONObject("data");
resDTO.setName(jsonResult.getString("title"));
resDTO.setSubtitle(jsonResult.getJSONObject("lockup").getString("subtitle"));
resDTO.setDeveloper(jsonResult.getJSONObject("developerAction").getString("title"));
resDTO.setAppStoreUrl(appStoreUrl);
resDTO.setPrice(parsePrice(
jsonResult.getJSONObject("lockup").getJSONObject("offerDisplayProperties")
.getString("priceFormatted"),
areaEnum
));
// ... 内购解析
});
}这段代码的核心:Arrays.stream(AreaEnum.values()).parallel() + #serialized-server-data。
AreaEnum.values() 是 13 个地区的数组,parallel() 默认用 ForkJoinPool.commonPool() 并行抓 13 个页面。对一个 app 来说,从请求到响应,13 个地区全部并行,理论延迟 ≈ 单页面延迟。
5. 13 个地区:AreaEnum 设计
AreaEnum.java 是项目里设计最巧妙的文件——它用一个 enum 把所有地区相关的元信息封装在一起:
@Getter
@AllArgsConstructor
public enum AreaEnum {
USA("us", "美国", "$", "USD", StrUtil.COMMA, "In-App Purchases", "en-US"),
CHINA("cn", "中国", "¥", "CNY", StrUtil.COMMA, "App内购买", "zh-CN"),
TAIWAN("tw", "台湾", "NT$", "TWD", StrUtil.COMMA, "App內購買", "zh-TW"),
HONGKONG("hk", "香港", "HK$", "HKD", StrUtil.COMMA, "App 內購買", "zh-HK"),
JAPAN("jp", "日本", "¥", "JPY", StrUtil.COMMA, "アプリ内購入", "ja-JP"),
KOREA("kr", "韩国", "₩", "KRW", StrUtil.COMMA, "앱 내 구입", "ko-KR"),
PHILIPPINES("ph", "菲律宾", "₱", "PHP", StrUtil.COMMA, "In-App Purchases", "en-PH"),
TURKEY("tr", "土耳其", "₺", "TRY", StrUtil.DOT, "In-App Purchases", "tr-TR"),
NIGERIA("ng", "尼日利亚", "₦", "NGN", StrUtil.COMMA, "In-App Purchases", "en-NG"),
INDIA("in", "印度", "₹", "INR", StrUtil.COMMA, "In-App Purchases", "en-IN"),
PAKISTAN("pk", "巴基斯坦", "₨", "PKR", StrUtil.COMMA, "In-App Purchases", "en-PK"),
BRAZIL("br", "巴西", "R$", "BRL", StrUtil.DOT, "Compras dentro do app", "pt-BR"),
EGYPT("eg", "埃及", "E£", "EGP", StrUtil.COMMA, "In-App Purchases", "ar-EG-u-nu-latn"),
}每个 enum 常量有 7 个字段:
| 字段 | 作用 | 示例 |
|---|---|---|
code | Apple Store URL 中的地区代码 | us / cn / hk |
name | 中文显示名 | 美国 / 中国 |
currency | 货币符号 | $ / ¥ / NT$ |
currencyCode | ISO 4217 货币代码 | USD / CNY |
thousandsSeparator | 千位分隔符 | ,(美/中)或 .(土/巴) |
InAppPurchaseStr | 内购区块的本地化标题 | In-App Purchases / App内购买 / アプリ内購入 |
locale | HTTP Accept-Language | en-US / zh-CN |
最值得说的是 InAppPurchaseStr 字段——它是爬虫能否正确解析内购区块的关键。
App Store 每个应用的详情页都有一个"内购项目"区块,但这个区块的标题在不同 locale 下是不一样的:
en-US: "In-App Purchases"
zh-CN: "App内购买"
zh-TW: "App內購買"
zh-HK: "App 內購買"
ja-JP: "アプリ内購入"
ko-KR: "앱 내 구입"
pt-BR: "Compras dentro do app"
ar-EG: "عمليات الشراء داخل التطبيق"如果你用英文 In-App Purchases 去匹配所有 locale 的 HTML,你会丢掉 90% 的内购数据。 所以这个 enum 把本地化文案作为一等公民存起来——这是工程上"显式优于隐式"的体现。
6. 数据源:#serialized-server-data 与 open.er-api.com
这个项目能跑通的关键,是找到了 App Store 反爬的"金钥匙"——#serialized-server-data 元素。
打开任意一个 App Store 详情页(比如 https://apps.apple.com/us/app/id6448313449),右键查看源码,搜索 serialized-server-data。你会看到一个 <script> 标签:
<script type="application/json" id="serialized-server-data">
[
{
"data": [
{
"data": {
"title": "ChatGPT",
"lockup": {
"subtitle": "The official app by OpenAI",
"offerDisplayProperties": {
"priceFormatted": "Free"
}
},
"developerAction": {
"title": "OpenAI"
},
"shelfMapping": {
"information": {
"items": [
{"title": "In-App Purchases", "items": [...]}
]
}
}
}
}
]
}
]
</script>这是 App Store 服务端渲染时预先把应用数据以 JSON 形式嵌入 HTML 的实现。客户端 JS 拿到这个 JSON 后直接渲染,不需要再发 API 请求。
这意味着什么?意味着我们做服务端爬虫时,不需要逆向 Apple 的私有 API——只要 Jsoup.selectFirst("#serialized-server-data") 拿到这段 JSON,再用 Fastjson2 解析就行。整个爬虫的核心逻辑就是 4 行:
Element scriptElement = doc.selectFirst("#serialized-server-data");
JSONObject jsonResult = JSON.parseObject(scriptElement.html().trim())
.getJSONArray("data").getJSONObject(0).getJSONObject("data");
String title = jsonResult.getString("title");
String price = jsonResult.getJSONObject("lockup").getJSONObject("offerDisplayProperties")
.getString("priceFormatted");这个设计对爬虫作者是莫大的福音。对比一下:Amazon、淘宝、京东的页面 HTML 里没有这种结构化数据,你必须逆向它们内部的 GraphQL/REST API,搞清楚 token、签名、加密算法。App Store 把 JSON 公开嵌入 HTML 是一种"友好的反爬"——它的目的是让前端 SEO 友好,而不是给爬虫做后门,但客观上让爬虫作者省了 90% 的逆向工作。
汇率数据则用了另一个免费、无 key 的 API:open.er-api.com:
JSONObject result = JSON.parseObject(HttpUtil.get(
StrUtil.format("https://open.er-api.com/v6/latest/{}", currencyCode)
));
Map<String, BigDecimal> resultMap = new HashMap<>();
result.getJSONObject("rates").forEach((key, value) ->
resultMap.put(key, new BigDecimal(Convert.toStr(value)))
);open.er-api.com 提供 161 种货币的实时汇率,无 key 限速 250 次/月(商业用够,个人小项目绰绰有余)。
价格换算算法:
public BigDecimal convertToCny(BigDecimal amount, String currencyCode) {
Map<String, BigDecimal> exchangeRateMap = getExchangeRateMap(
AreaEnum.CHINA.getCurrencyCode()
);
return NumberUtil.div(amount, exchangeRateMap.get(currencyCode), 2, RoundingMode.HALF_UP);
}注意:调用 getExchangeRateMap(CNY) 拿到的是以 CNY 为基准的汇率表(如 USD=0.14, JPY=20.5, TWD=4.6),然后 CNY_amount = original_amount / rate(currency)。
为什么不是 original_amount * cny_rate?因为 open.er-api.com/v6/latest/CNY 返回的 rate 是"1 CNY 等于多少 X"——所以 1 / rate(X) 才是"1 X 等于多少 CNY"。代码用 amount / rate(currency) 一次性把这两个步骤合并了。
7. 缓存策略:Hutool TimedCache + 细粒度锁
整个项目的缓存设计值得拆开看:
// 应用列表缓存 1 天
private final static Cache<String, List<GetAppListResDTO>> APP_LIST_CACHE =
new TimedCache<>(Duration.ofDays(1L).toMillis(), new ConcurrentHashMap<>());
// 应用详情缓存 1 天
private final static Cache<String, List<GetAppInfoResDTO>> APP_INFO_CACHE =
new TimedCache<>(Duration.ofDays(1L).toMillis(), new ConcurrentHashMap<>());
// 锁池
private final static Map<String, Object> LOCK_POOL = new ConcurrentHashMap<>();Hutool 的 TimedCache 是带 TTL 的内存缓存——超过 1 天自动清理。底层用 ConcurrentHashMap 保证线程安全。
细粒度锁是这个项目最值得学的模式。LOCK_POOL 是一个 ConcurrentHashMap<String, Object>,每个 (areaCode, appName) 或 (appId) 单独有一个锁对象:
// 不同请求之间的锁是隔离的
Object lock1 = LOCK_POOL.computeIfAbsent("getAppList-us-ChatGPT", k -> new Object());
Object lock2 = LOCK_POOL.computeIfAbsent("getAppList-cn-ChatGPT", k -> new Object());
// lock1 != lock2,请求 us 和 cn 的 ChatGPT 不会互相阻塞这与"全局 cache 锁"的设计截然不同:
| 模式 | 优点 | 缺点 |
|---|---|---|
| 全局锁(所有请求抢一把锁) | 简单 | 1 个请求爬时,其他 999 个请求都阻塞 |
| 细粒度锁(按 key 锁) | 并发度高 | 锁对象本身要内存;LOCK_POOL 可能膨胀 |
双重检查锁定(double-checked locking)也被用上:
// 锁外先查一次缓存(快路径)
List<X> cache = APP_LIST_CACHE.get(cacheKey);
if (CollUtil.isNotEmpty(cache)) return cache;
synchronized (lock) {
// 锁内再查一次(防另一线程已经填充)
cache = APP_LIST_CACHE.get(cacheKey);
if (CollUtil.isNotEmpty(cache)) return cache;
// ... 实际爬虫
}这是 Java 并发里最经典的模式之一。没有这个双重检查,每个请求都会抢锁,包括那些本来能从缓存直接返回的——这会让锁变成性能瓶颈。
汇率缓存同样用 Hutool TimedCache 1 天 TTL:
private final static Cache<String, Map<String, BigDecimal>> RATE_CACHE =
new TimedCache<>(Duration.ofDays(1L).toMillis(), new ConcurrentHashMap<>());
private Map<String, BigDecimal> getExchangeRateMap(String currencyCode) {
if (Objects.nonNull(RATE_CACHE.get(currencyCode))) {
return RATE_CACHE.get(currencyCode);
}
synchronized (ExchangeRateUtil.class) {
if (Objects.nonNull(RATE_CACHE.get(currencyCode))) {
return RATE_CACHE.get(currencyCode);
}
JSONObject result = JSON.parseObject(HttpUtil.get(
StrUtil.format("https://open.er-api.com/v6/latest/{}", currencyCode)
));
// ... 解析 + 缓存
}
}注意:汇率缓存用的是类级别锁(synchronized (ExchangeRateUtil.class)),不是细粒度锁。这是因为汇率数据有 161 种货币,每种单独锁会带来 161 个锁对象,而汇率换算的请求频率不高,类锁完全够用。
8. 部署:Amazon Corretto 21-alpine + Docker Compose
Dockerfile 用的是 Amazon Corretto 21 + Alpine 的组合:
FROM amazoncorretto:21-alpine
ENV LANG=C.UTF-8
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata && \
cp /usr/share/zoneinfo/${TZ} /etc/localtime && \
echo "${TZ}" > /etc/timezone && \
apk del tzdata && \
rm -rf /var/cache/apk/*
WORKDIR /app
EXPOSE 8080
COPY ./target/app-store-price-*.jar ./app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]关键设计:
- Amazon Corretto 21-alpine —— Amazon 维护的 OpenJDK 21 + Alpine Linux,镜像大小约 250 MB(相比
openjdk:21的 ~500 MB 砍半) TZ=Asia/Shanghai—— 时区设到上海(应用里有日志时间戳 + 缓存 TTL 计算)tzdata安装后立即删除 —— Alpine 默认不带 tzdata,但又需要时区数据;装完立刻删,避免残留EXPOSE 8080—— Spring Boot 默认端口ENTRYPOINT ["java", "-jar", "app.jar"]—— 直接跑 jar,无 shell 包裹
docker-compose.yml 同样极简:
services:
app-store-price:
build: .
image: app-store-price:latest
container_name: app-store-price
restart: unless-stopped
ports:
- "8080:8080"没有数据库 service、没有 Redis service、没有 Nginx service——因为整个应用不需要任何外部依赖。这是一个纯单机爬虫应用的部署。
application.yml 同样只有 3 行:
server:
port: 8080没有数据库连接池、没有 Redis 客户端、没有日志配置。Spring Boot 全部走默认值。
这套"极简部署"的工程含义:
- ✅ 单条命令启动:
docker run -d -p 8080:8080 ghcr.io/hypooo/app-store-price:latest - ✅ 没有外部依赖 → 99% 的故障模式消失
- ✅ 容器崩溃 →
restart: unless-stopped自动拉起 - ⚠️ 但没有水平扩展——所有缓存都在 JVM 堆里,多实例不共享
- ⚠️ 没有持久化——容器重启 = 全部缓存清空,前 1 个小时所有请求都会穿透到 Apple
对一个小工具来说,这种取舍完全合理。但如果你要做"全球 iOS 应用价格趋势分析"——就得换成 Redis + 多实例 + 数据库了。
9. 对独立爬虫项目作者的 5 条工程经验
把整个项目读下来,对想写自己爬虫项目的作者 5 条可复用的经验:
1. 找 #serialized-server-data、__NEXT_DATA__、__NUXT__ 这类 SSR 嵌入数据,是爬现代 SPA 的金钥匙。Amazon、淘宝、Apple、Twitter 全都有这种结构化 JSON 嵌入到 HTML 里——这是 SEO 优化的副产品。先看 5 分钟 HTML 源码再决定是否逆向 API。
2. 细粒度锁 + 双重检查比"全局 cache 锁"性能高 10 倍。ConcurrentHashMap<String, Object> LOCK_POOL + computeIfAbsent + synchronized (lock) + 双重 cache 检查,是 Java 爬虫项目里最经典的并发模式。值得抄到任何需要"同一资源只爬一次"的场景。
3. 把 i18n 文案作为 enum 的一等字段——不要假设"内购区块的标题一定是 In-App Purchases"。Apple、Steam、Netflix 等多语言产品都会按 locale 改文案。爬虫作者把 InAppPurchaseStr 这种本地化字符串存进 enum 是工程上"显式优于隐式"的体现。
4. Java 21 + Spring Boot 4 + Lombok + Hutool 是中等复杂度爬虫项目的最优栈。Python 的 BeautifulSoup/Scrapy 太动态,强类型支持弱;Node 的 axios + cheerio 适合前端但弱类型调试痛苦;Go 的并发模型好但生态小。Java 在"爬虫 + 缓存 + 部署"的综合能力上是当前的 sweet spot。
5. 零数据库部署是爬虫项目的合理取舍——Hutool TimedCache + JVM 内存 + Docker restart policy 可以覆盖 80% 的小工具场景。只有当你需要"跨实例共享"或"数据长期保存"时才引入 Redis/数据库。过度工程是爬虫项目最常见的反模式。
10. 关键资源与延伸阅读
项目本体:
- 在线应用:
https://app.vbr.me - GitHub 仓库:
https://github.com/hypooo/app-store-price - 作者主页:
https://github.com/hypooo
核心技术:
- Alpine.js 官方:
https://alpinejs.dev - Tailwind CSS CDN 文档:
https://tailwindcss.com/docs/installation/play-cdn - Spring Boot 4.1.0:
https://spring.io/projects/spring-boot - Hutool Cache 文档:
https://hutool.cn/docs/core/缓存/缓存介绍 - Jsoup:
https://jsoup.org - Amazon Corretto:
https://aws.amazon.com/corretto/
数据源:
- App Store 公开页面(HTML 内嵌 JSON)
- open.er-api.com 汇率 API:
https://open.er-api.com - exchangerate-api.com(替代):
https://www.exchangerate-api.com
国内镜像(项目使用):
- jsdmirror.com(jsdelivr 镜像):
https://cdn.jsdmirror.com - font.im(Google Fonts 镜像):
https://fonts.font.im
横向对比项目:
- App Store 价格历史追踪:AppShopper —— 第三方历史价格数据库
- iOS 应用比价:AppFigures —— 商业版价格监控
- Apple 官方多区比价:App Store 多区切换
本文基于 GitHub 仓库 hypooo/app-store-price(81 commits,截至 2026-07-11)的真实源码与 app.vbr.me 前端实现。所有代码示例、API 设计、AreaEnum 13 个地区定义、缓存策略均来自仓库原文,未做改动。