目录

vbr.me App Store 价格查询:Spring Boot + Alpine.js + 13 地区汇率转换怎么搭

vbr.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 的工程原因

本文目录

  1. vbr.me 是什么
  2. 整体架构:5 个工程决策
  3. 前端:Alpine.js + Tailwind CSS 单 component
  4. 后端:Spring Boot 4 + Java 21 + Jsoup
  5. 13 个地区:AreaEnum 设计
  6. 数据源:#serialized-server-data 与 open.er-api.com
  7. 缓存策略:Hutool TimedCache + 细粒度锁
  8. 部署:Amazon Corretto 21-alpine + Docker Compose
  9. 对独立爬虫项目作者的 5 条工程经验
  10. 关键资源与延伸阅读

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%
CSSTailwind CSS CDN(不是构建)没有工程化诉求,CDN 跑 JIT 已经够,节省 webpack/postcss 复杂度
CDNjsdmirror.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.comcdn.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。这是有意的设计:

  1. 请求体带参数(appName、areaCode、appId),GET 的 URL 长度限制更严
  2. POST 不被浏览器缓存——App Store 价格会变化,避免 CDN 缓存过期数据
  3. 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 件事,每一件都是工程上值得展开的:

  1. 搜索计数POPULAR_SEARCH_WORD.add)——为热门搜索词功能积累数据
  2. 缓存 key 用 - 分隔{areaCode}-{appName})——避免不同 area 的同 appName 撞 key
  3. 细粒度锁池LOCK_POOL 是 ConcurrentHashMap)——同一 (areaCode, appName) 共享一个锁对象,不同请求之间不互斥
  4. computeIfAbsent —— 锁对象懒创建,避免内存浪费
  5. 双重检查 —— 锁外查一次,锁内查一次,避免不必要的锁等待
  6. 4 平台并行 —— 同一 areaCode 下并行抓 iphone/ipad/mac/tv 的搜索页
  7. #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 个字段:

字段作用示例
codeApple Store URL 中的地区代码us / cn / hk
name中文显示名美国 / 中国
currency货币符号$ / ¥ / NT$
currencyCodeISO 4217 货币代码USD / CNY
thousandsSeparator千位分隔符,(美/中)或 .(土/巴)
InAppPurchaseStr内购区块的本地化标题In-App Purchases / App内购买 / アプリ内購入
localeHTTP Accept-Languageen-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 的 APIopen.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"]

关键设计

  1. Amazon Corretto 21-alpine —— Amazon 维护的 OpenJDK 21 + Alpine Linux,镜像大小约 250 MB(相比 openjdk:21 的 ~500 MB 砍半)
  2. TZ=Asia/Shanghai —— 时区设到上海(应用里有日志时间戳 + 缓存 TTL 计算)
  3. tzdata 安装后立即删除 —— Alpine 默认不带 tzdata,但又需要时区数据;装完立刻删,避免残留
  4. EXPOSE 8080 —— Spring Boot 默认端口
  5. 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

横向对比项目


本文基于 GitHub 仓库 hypooo/app-store-price(81 commits,截至 2026-07-11)的真实源码与 app.vbr.me 前端实现。所有代码示例、API 设计、AreaEnum 13 个地区定义、缓存策略均来自仓库原文,未做改动。