<?xml version="1.0"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Ingram Chen</title>
    <link>https://ingramchen.dev</link>
    <atom:link href="https://ingramchen.dev/feed.xml" rel="self" type="application/rss+xml" />
    <description>Xexex's Java 與其他二三事</description>
    <language>zh_TW</language>
    <pubDate>Mon, 13 Jul 2026 00:00:00 +0000</pubDate>
    <lastBuildDate>Mon, 13 Jul 2026 00:00:00 +0000</lastBuildDate>
    <item>
      <title>Immutable + Native SQL (3)：測試、JSONB、與上線成績單</title>
      <link>https://ingramchen.dev/blog/2026/07/immutable-native-sql-part-3.html</link>
      <pubDate>Mon, 13 Jul 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2026/07/immutable-native-sql-part-3.html</guid>
      <description>&lt;p&gt;&lt;a href=&quot;/blog/2026/07/immutable-native-sql-part-1.html&quot;&gt;第一篇&lt;/a&gt;講為什麼，&lt;a href=&quot;/blog/2026/07/immutable-native-sql-part-2.html&quot;&gt;第二篇&lt;/a&gt;講如何實作，這篇來算帳：測試怎麼寫、讀取怎麼變快、以及上了 production 之後最後獲得了什麼。&lt;/p&gt;
&lt;h3&gt;每條 SQL 都要對真資料庫測 —— 而且只要一層&lt;/h3&gt;
&lt;p&gt;第一篇的結尾說過：每一條 SQL 操作，都需要一個對真資料庫的測試，ORM 不會讓你免於這件事，它只是讓你以為可以。那這些測試怎麼寫才不會變成負擔？&lt;/p&gt;
&lt;p&gt;先看舊世界的標準答案，是&lt;em&gt;兩層&lt;/em&gt;：service test 把 DAO mock 掉，測商業邏輯；再一層 DAO test 對真 DB，測查詢。寫過的人都知道這有多虛。mock DAO 的 service test，測的是「假如 DAO 回傳這個，那麼……」，一整片 stub 設定，測完只證明你的假設跟你的假設一致。CRUD 的應用程式，SQL 就是本體，你把本體 mock 掉是在測什麼？&lt;/p&gt;
&lt;p&gt;為求避免重覆的無效作業，最大化測試的效果，收斂成&lt;strong&gt;一層&lt;/strong&gt;：只寫 Integration Test，針對每一個 use case (service) 來寫，service 裡面到底在做什麼我們不管，只在乎它的結果 (blackbox test)。直接對真的 PostgreSQL 測，真 DAO、真 mapper、真 SQL，&lt;code&gt;@Transactional&lt;/code&gt; 讓每個測試自動 rollback，測試之間互不污染：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;@SpringBootTest
@Transactional
public abstract class DbIntegrationTests {

  // 每個測試都跑在這個瞬間 —— service 讀注入的 Clock，
  // 所以固定住 bean 就固定住所有 timestamp
  public static final Instant TEST_NOW = Instant.parse(&amp;quot;2026-07-11T10:00:00Z&amp;quot;);

  @TestConfiguration
  public static class FixedClockConfig {
    @Bean
    @Primary
    Clock fixedTestClock() {
      return Clock.fixed(TEST_NOW, ZoneOffset.UTC);
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;class ArticleServiceTest extends DbIntegrationTests {

  @Test
  void createPublishCommentThenReadDetailInOneQuery() {
    ArticleDto draft = articleService.createArticle(
        alice, new ArticleEntry(&amp;quot;My great post!&amp;quot;, &amp;quot;Hello world&amp;quot;));

    // 草稿不能留言 —— model 的規則，穿過 service 浮出來
    assertThatThrownBy(() -&amp;gt;
        articleService.addComment(draft.id(), coby, new CommentEntry(&amp;quot;first!&amp;quot;)))
        .isInstanceOf(IllegalStateException.class);

    articleService.publishArticle(draft.id());
    articleService.addComment(draft.id(), coby, new CommentEntry(&amp;quot;頭香&amp;quot;));

    ArticleDetailDto detail = articleService.getDetail(draft.id());
    assertThat(detail.authorName()).isEqualTo(&amp;quot;Alice&amp;quot;);
    assertThat(detail.comments()).hasSize(1);
    assertThat(detail.comments().get(0).createdAt())
        .isEqualTo(TEST_NOW.toEpochMilli());
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一個測試同時涵蓋 service 邏輯 + DAO + mapper + SQL + schema。唯一的代價就是測試很慢，但換來的是更高的信心、測試程式碼的減量。而核心邏輯是在 model，它是純的無副作用，可以用毫秒級的 unit test 全面覆蓋。&lt;/p&gt;
&lt;p&gt;所以整體的測試組合就是：一大票 model test，加一層對真 DB 的 service test。沒有 mock 地獄，也沒有兩層測試互相重覆抄襲。&lt;/p&gt;
&lt;p&gt;這裡也 demo 了 Clock 的使用，將時間當作依賴注入，在測試期間就能固定，也能自由變換 (例如直接往後調一小時)。你將程式裡的副作用經過妥善的整理，在對的層級使用，事半功倍。&lt;/p&gt;
&lt;h3&gt;AI Agent 時代的測試&lt;/h3&gt;
&lt;p&gt;在這個 AI 的時代，寫測試已經不是難事了。叫 AI 全部生成啊！全部覆蓋啊！還講究什麼方法？是沒錯，但也因為 AI，現在的困難點轉移了 ——&lt;/p&gt;
&lt;p&gt;你會去 review 測試嗎？&lt;/p&gt;
&lt;p&gt;憑良心講，正式的程式碼都懶得看了，何況是測試，要求全部寫，那個量真的大到無法承受。更重要的是，AI 很容易寫出&lt;strong&gt;無效的測試&lt;/strong&gt;，很多時候 Agent 都會走捷徑，能跳過、能唬弄它就會這樣寫，因為它收到的 prompt 是要寫測試，測試要過。它就只會滿足這個目標。&lt;/p&gt;
&lt;p&gt;所以怎麼辦？大家就是忙於寫 skill、改 &lt;a href=&quot;http://CLAUDE.md&quot;&gt;CLAUDE.md&lt;/a&gt;，要求測試該怎樣那樣，避免 Agent 犯錯。然後 Agent 不調用 skill，忘記 &lt;a href=&quot;http://CLAUDE.md&quot;&gt;CLAUDE.md&lt;/a&gt; 的內容，關鍵的測試寫成無效，大家就整天抱怨今天模型又降智了，A社退我錢……blah blah。&lt;/p&gt;
&lt;p&gt;Agent &lt;strong&gt;不知道&lt;/strong&gt;寫測試是為了提升維護性，這個人類背後的動機。在它有限的 context 下只會生成要求的程式碼。所以與其在 skill 裡約束，不如在程式碼的語法和架構這個層級上直接限制，讓 Agent 無法作弊。強化語言層面的約束就是要求具備強型別，並且將 lint 調高檢查項目，而架構上就是要 &lt;em&gt;夠硬&lt;/em&gt;，讓它很難造假。&lt;/p&gt;
&lt;p&gt;上一篇介紹的 Immutable model 就是超硬的寫法 —— 你沒辦法構造任何破損的物件來唬弄結果。而這一篇使用真正跑 SQL 的 service 測試，測試要跑得動必須在 DB 建構真實的資料，也無法輕易跳過 (mock 整個都是假的，真的要少用)。&lt;/p&gt;
&lt;p&gt;當然啦，凡事有例外：如果你使用 Mythos 等級的 Agent，現在就是 Fable 5。我不知它怎麼辦到的，它寫的測試品質很高，似乎它已經知道人們 &lt;em&gt;為什麼&lt;/em&gt; 要寫測試了，也許 Fable 還是沒有這層意識，但它表現出來的就是這樣。&lt;/p&gt;
&lt;h3&gt;BONUS：一次 round trip 撈回整個 object graph&lt;/h3&gt;
&lt;p&gt;回到我們的主題，當你接受自由使用 native SQL 後，可以優化的空間會開闊許多。&lt;/p&gt;
&lt;p&gt;ORM 在 2001 年賣的核心價值是 object graph：撈一個 &lt;code&gt;Article&lt;/code&gt;，&lt;code&gt;article.getComments()&lt;/code&gt; 簡單的呼叫，整顆物件圖會自己長出來，很方便。但代價前面講過了：lazy loading、N+1、session……。&lt;/p&gt;
&lt;p&gt;現在是 2026 年，PostgreSQL 自己就會組 object graph：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;// 文章頁：article + 作者 + 全部留言，一條 SQL，一次 round trip
@Select(&amp;quot;&amp;quot;&amp;quot;
    SELECT a.id,
           acc.name AS author_name,
           a.title,
           a.content,
           a.status,
           a.created_at,
           a.published_at,
           COALESCE(
             jsonb_agg(
               jsonb_build_object(
                 &#39;id&#39;, c.id,
                 &#39;commenterName&#39;, com.name,
                 &#39;content&#39;, c.content,
                 &#39;createdAt&#39;, c.created_at
               )
               ORDER BY c.id
             ) FILTER (WHERE c.id IS NOT NULL),
             CAST(&#39;[]&#39; AS jsonb)
           ) AS comments
      FROM article a
      JOIN account acc ON acc.id = a.author_id
      LEFT JOIN comment c ON c.article_id = a.id
      LEFT JOIN account com ON com.id = c.commenter_id
     WHERE a.id = #{id}
     GROUP BY a.id, acc.id
    &amp;quot;&amp;quot;&amp;quot;)
Optional&amp;lt;ArticleDetailDto&amp;gt; findDetailById(long id);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;jsonb_agg&lt;/code&gt; 把一對多的留言在 DB 端聚合成一個 jsonb 陣列，整個查詢回來是&lt;em&gt;一列&lt;/em&gt;，直接 map 進一個扁平的 DTO (jsonb 欄位過一個十行的 MyBatis TypeHandler 反序列化成 &lt;code&gt;List&amp;lt;CommentView&amp;gt;&lt;/code&gt;，完整程式在 demo repo)。&lt;/p&gt;
&lt;p&gt;你寫了 native SQL，你的思考範圍就會擴大到 SQL 資料庫給你的能力，而不是只會用程式的物件去思考，像這樣優化到只剩一個 round trip 不在話下。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;一個頁面需要幾次 round trip，是設計時決定的，不是 runtime 憑感覺長出來的。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;這 SQL 很長，跟 &lt;code&gt;article.getComments()&lt;/code&gt; 相比，耗工與難度也差太多吧？沒錯，所以 ORM 會變成顯學不是沒原因的。相對的，ORM 寫出來的 CRUD 效能差也是這樣來的，魚與熊掌不能兼得。&lt;/p&gt;
&lt;p&gt;但我們現在有 Agent 了啊，我們可以全都要！重覆的苦工讓 Agent 去做就好了。再加上前面提到的真實 SQL 測試守護，效能、快速開發、高維護性好處全拿。&lt;/p&gt;
&lt;p&gt;註：文章內的 SQL 程式碼只是範例，完整的 SQL 請見 demo repo。另外文章直接撈出所有的留言，正常的系統不會這樣設計 (留言太多會爆)，這裡只是示意 jsonb_agg 的使用。&lt;/p&gt;
&lt;h3&gt;上線成績單&lt;/h3&gt;
&lt;p&gt;理論講完了，實績如何呢？我的 20 年老專案，今年上半年完成 Hibernate → MyBatis 的整套遷移。採用的方式不是整個重寫直接換上新版，而是慢慢換，一次一個模組，換完就上線。期間 Hibernate 和 MyBatis 共存了一陣子。&lt;/p&gt;
&lt;p&gt;本來我也希望設計一套 Agent 工作流程，讓它自主的將網站整個重寫，但實際上並沒有做到，最終成品慘不忍睹。只好跟 Agent 一起工作，手把手漸進式的移植 (怎麼安全地走這條路，值得另外寫一篇)。&lt;/p&gt;
&lt;p&gt;換完後的尖峰時刻，同一台機器、同一批流量，前後對照：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;node iowait：23.6% → 1.4%&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;load average (1m)：5.74 → 1.65&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JVM heap 尖峰：177MB&lt;/strong&gt; (給了 2G，只用掉 8.6%)，GC overhead 0.06%&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;iowait 和 load 整個大降。當初上線前擔心得要命，因為 ORM，Hibernate 有神奇的 L2 cache 可以吸收大量的 query，而且在還沒轉移 MyBatis 前，就已經和 Agent 一起優化過 cache 設定，還有針對 Hibernate 產生的 SQL 也優化了好幾輪。&lt;/p&gt;
&lt;p&gt;最終得到的結果壓倒性的好，逐條優化 native SQL 的成果斐然。Agent 很厲害的，SQL 全部攤開後，它可以自由去模擬去壓測，取得很好的成績。&lt;/p&gt;
&lt;p&gt;事後分析，ORM 那種遍歷 object graph 的操作，很容易在看不到的地方造成讀取的壓力，多了還會加成放大。然後再用 L2 cache 去補，越補越大洞。&lt;/p&gt;
&lt;p&gt;換成扁平的 SQL mapping，cache 拔光光，效能居然反而大升。當然，這不是嚴格的 A/B benchmark，就是同一個系統遷移前後的營運數字。但量級擺在那：iowait 差十幾倍不是調參數調得出來的，是讀取模式根本上的不同。&lt;/p&gt;
&lt;p&gt;轉移後，讓人意外的是那個 JVM 吃的記憶體，居然 256MB 就夠跑了 (這個網站的量級是 1M req/day)。這可是被罵翻的 Spring server 啊！Hibernate overhead 比想像中的大，將它去除掉後 JVM 變得超瘦。&lt;/p&gt;
&lt;p&gt;我本來後續還想玩一下 GraalVM，或者是跟風移植到 Rust。看到這個記憶體用量就免了，換成 native AOT binary 就是再降個 100MB 而已，完全無感。&lt;/p&gt;
&lt;h3&gt;結語&lt;/h3&gt;
&lt;p&gt;回到 threads 上那則貼文：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;個人極度偏好 DDD 和 rich domain model&lt;br&gt;
然後我全部寫 native sql, 一點問題也沒有&lt;br&gt;
寫的 entity 還全部是 immutable&lt;br&gt;
FP+DDD+SQL 爽的咧&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;三篇寫完，這四行背後的東西應該都交代了：&lt;/p&gt;
&lt;p&gt;immutable &lt;code&gt;record&lt;/code&gt; 讓 invariant 收進 constructor (FP)；&lt;br&gt;
domain verb 讓 entity 具備業務語言、生命週期 (DDD)；&lt;br&gt;
Entity 異動和 native SQL 熔接在 DAO 裡，在架構層面提升維護性 (SQL)。&lt;/p&gt;
&lt;p&gt;程式碼簡潔、高度內聚、偏好顯式、有效測試 —— 這些軟體工程上的優點，在 Agent 時代依然受用。&lt;/p&gt;
&lt;p&gt;「一點問題也沒有」不是我在吹，是我伺服器上的 iowait 說的。&lt;/p&gt;
&lt;p&gt;文中完整的程式碼範例放在 &lt;a href=&quot;https://github.com/ingramchen/demo-immutable-sql-crud&quot;&gt;demo-immutable-sql-crud&lt;/a&gt;，也備有 skill 直接供 agent 使用。&lt;/p&gt;
&lt;p&gt;本文雖然用老古板的 Java 來解說，但這套思維其實不限 Java 平台。範例也寫了份 TypeScript 版的可以參照，當然啦，那個 ts 版寫得 Java 味很重，你如果是 ts 專家，吸收了本文的精神，改成 idiomatic ts 難不倒你的。&lt;/p&gt;
&lt;p&gt;你選用的 ORM 越薄、越偏向 native SQL 就越好。而越自動、抽象越多層的 ORM，我的建議直接避開，現在選擇很多，又有 AI Agent 加持，沒必要再背負這個債務了。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;本文與 Claude (Fable) 共同撰寫 / co-authored with Claude Fable。&lt;/em&gt;&lt;/p&gt;
</description>
    </item>
    <item>
      <title>Immutable + Native SQL (2)：把物件模型要回來</title>
      <link>https://ingramchen.dev/blog/2026/07/immutable-native-sql-part-2.html</link>
      <pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2026/07/immutable-native-sql-part-2.html</guid>
      <description>&lt;p&gt;&lt;a href=&quot;/blog/2026/07/immutable-native-sql-part-1.html&quot;&gt;上一篇&lt;/a&gt;把「為什麼離開 ORM」講完了，這篇來講怎麼做。完整的範例程式放在 &lt;a href=&quot;https://github.com/ingramchen/demo-immutable-sql-crud&quot;&gt;demo-immutable-sql-crud&lt;/a&gt;，domain 是個迷你的部落格系統：&lt;code&gt;Account&lt;/code&gt;、&lt;code&gt;Article&lt;/code&gt;、&lt;code&gt;Comment&lt;/code&gt;，大家都熟。Stack 是 Java 25 (record) + Spring Boot + MyBatis + PostgreSQL，不過 pattern 跟語言無關，你換別的也一樣成立。&lt;/p&gt;
&lt;h3&gt;每一層有固定的 side-effect (副作用) 預算&lt;/h3&gt;
&lt;p&gt;先看整體長什麼樣。分層本身沒什麼新鮮的，&lt;code&gt;Controller → Service → DAO → Mapper&lt;/code&gt;，老掉牙。新鮮的是每一層分到多少 &lt;em&gt;side-effect 預算&lt;/em&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Side effects&lt;/th&gt;
&lt;th&gt;職責&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;零&lt;/strong&gt;，pure&lt;/td&gt;
&lt;td&gt;建構、複製、計算。沒 I/O，不讀時鐘 (Clock)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DAO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;只有資料庫&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;持久化，還有 (等下會講) entity 的變異跟製造&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;全部&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;transaction、時鐘、編排 DAO、寄信、外部服務、Thread/Async&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Controller&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;decode request → 一個 service call → serialize DTO&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;這張表 Service 塞滿了 side effect，平常大家也是寫得很類似，只是對 side effect 沒有特別的篩選。像是 Clock 如果不是特別注意的話，會到處亂寫。Transaction (TX) 很多人也不管，覺得 TX 不是資料庫的事嗎？那我也可以寫在 DAO 吧？&lt;/p&gt;
&lt;p&gt;是沒有人硬性規定怎麼才是對的。但沒規則就是隨便注入，你確定要這樣寫？維護性怎麼辦？相信要求品質的團隊都會另外設立分派規則，我這裡提供一個思路 ——&lt;/p&gt;
&lt;p&gt;用副作用的種類去區分，按預算分配。&lt;/p&gt;
&lt;p&gt;這個規則很簡單，容易判斷好執行。需求出現了你沒分類過的副作用，那就是先塞在 service，不要覺得能放入 DAO 或是 Model。等 Service 肥大了，受不了了，可以開始重構拆子項，這時也是用副作用去分拆，例如 &lt;code&gt;@Async&lt;/code&gt;、Thread 之類的非同步操作，全部獨立拆出去。至於 TX，對終端用戶來說是個不可分割的單一工作，它綁定使用案例 (use case)，使用案例是 service 層的職責，所以 TX 的管理只能出現在 Service 層，不能歸 DAO 管理。&lt;/p&gt;
&lt;p&gt;層級拆好了，純邏輯全部歸 model 管，它被限定不准有副作用，用最普通的 unit test 就測到滿了，沒有副作用的程式好測到不行。而 SQL 全部沉到 mapper (DAO)，最終 Service 讀起來就是一份短短的使用案例腳本。&lt;/p&gt;
&lt;h3&gt;Entity：immutable，並且具備生命週期&lt;/h3&gt;
&lt;p&gt;上一篇說過，「沒了 ORM，domain 物件會弱化」的問題。我的改良版的 entity 長這樣：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;public record Article(
    long id,
    long authorId,
    String title,
    String content,
    ArticleStatus status,
    Instant createdAt,
    Instant publishedAt) {

  // canonical constructor 擁有 invariant：
  // 每一條建構路徑都走這裡，所以「壞掉的 Article」根本無法存在
  public Article {
    title = Texts.clean(title);
    content = Texts.clean(content);
    if (title.isBlank()) {
      throw new IllegalArgumentException(&amp;quot;article title must not be blank&amp;quot;);
    }
    if (status == ArticleStatus.PUBLISHED &amp;amp;&amp;amp; publishedAt == null) {
      throw new IllegalStateException(&amp;quot;a PUBLISHED article must carry publishedAt&amp;quot;);
    }
    if (status == ArticleStatus.DRAFT &amp;amp;&amp;amp; publishedAt != null) {
      throw new IllegalStateException(&amp;quot;a DRAFT article must not carry publishedAt&amp;quot;);
    }
  }

  // domain verb：不是 setStatus，是一個有業務語意的動作，guard 就寫在裡面
  Article publish(Instant now) {
    if (status != ArticleStatus.DRAFT) {
      throw new IllegalStateException(&amp;quot;only a DRAFT article can be published&amp;quot;);
    }
    return ArticleBuilder.builder(this)
        .status(ArticleStatus.PUBLISHED)
        .publishedAt(now)
        .build();
  }

  // 純決策（資料已在手上）也放 model —— service 只負責問
  public boolean acceptsComments() {
    return status == ArticleStatus.PUBLISHED;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Model 層，尤其是 Entity，是整個 Domain 的核心，它們不是一群資料的集合，它是有生命週期、負責業務邏輯的。上面的 Article，你單單解讀它能做的事 ——「一篇文章，它可以發佈 (需指定時間)，也可以判斷能不能留言」，這就是需求，是可以給 PM/用戶看的規格。沒給的就是&lt;strong&gt;不支援&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果你拿 Entity 當純資料操作，也就是傳統 JPA 的寫法，那你會給一堆 setter：&lt;code&gt;setStatus(newStatus)&lt;/code&gt;、&lt;code&gt;setPublishedAt(time)&lt;/code&gt;。然後需求就會解讀成「一篇文章可以任意切換發佈狀態，有沒有發佈時間無所謂，隨便設定」。想當然爾這個很怪，所以讀程式的人要去 service 層挖資料是怎麼設定的，這種寫法就是會散落在各處。&lt;/p&gt;
&lt;p&gt;範例程式裡的 Article，我們會說它昇華為 Domain Model：它具備領域的操作和判斷，而不是一個只有資料、沒有行為的資料堆。&lt;/p&gt;
&lt;p&gt;接下來看幾個重點：&lt;/p&gt;
&lt;h4&gt;1. Invariant (不變式) 收在 canonical constructor&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;record&lt;/code&gt; 有個殺手級特性：所有建構路徑，不管是 static factory、&lt;code&gt;withX&lt;/code&gt; 複製、還是 MyBatis 從 DB 撈回來的 hydration，全部匯流到同一個 canonical constructor。資料清洗消毒和保護就寫在這裡，意思是每一個存在過的 &lt;code&gt;Article&lt;/code&gt; instance，都保證是乾淨的、合法的。&lt;/p&gt;
&lt;p&gt;你仔細推敲，會發現這個 Entity 你無論如何都創建不出有發佈時間，但停在草稿的狀態。也創建不出內文沒有被消毒的 (例如帶 &lt;code&gt;&amp;lt;script&amp;gt;alert...&lt;/code&gt;) Article。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;不是「記得在 service 檢查」，是讓「壞掉的狀態」根本表達不出來。表達不出來的 bug，你不用費心思再去處理。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;你的 Domain Model 被 Invariant 完全保護，你在其他層級做任何操作都不用再另外檢查，當你拿到 &lt;code&gt;Article&lt;/code&gt; 就是直接用了，什麼 if 都不用寫。你要是像傳統的方式把驗證寫在某個 service method 裡，那其他建構路徑 (另一個 service、測試、migration script) 隨時可以繞過去，做一個髒掉的 entity 塞進 DB。&lt;/p&gt;
&lt;p&gt;要達到這種效果，就是讓 Model (Entity) 變成 Immutable：異動任何欄位只能重新建構一個 instance，逼它一定要經過 canonical constructor。未來需求怎麼變，你都不會踩到破損資料的 bug。&lt;/p&gt;
&lt;h4&gt;2. 異動資料是含業務邏輯的操作 (domain verb)，不是 setter&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;publish(now)&lt;/code&gt; 是一個業務動作。它自帶前置條件 (只有草稿能發佈)，而且 status 跟 publishedAt 兩個欄位是&lt;em&gt;同時改變&lt;/em&gt;的。沒有 &lt;code&gt;setStatus()&lt;/code&gt;，也沒有 &lt;code&gt;setPublishedAt()&lt;/code&gt;，所以根本不存在「改到一半」的中間態。&lt;/p&gt;
&lt;p&gt;範例的程式在重新建構 &lt;code&gt;record&lt;/code&gt; 時用了 &lt;a href=&quot;https://github.com/Randgalt/record-builder&quot;&gt;RecordBuilder&lt;/a&gt; 這類 compile-time APT 來處理，如果是 Kotlin 直接有免費的 &lt;code&gt;copy()&lt;/code&gt; 可用。&lt;/p&gt;
&lt;h4&gt;3. Model 沒有副作用，尤其是 Clock (時鐘)&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;publish(Instant now)&lt;/code&gt; 的時間是&lt;em&gt;參數&lt;/em&gt;傳進來的。時鐘是 side effect，那是 Service 的事 (注入一個 &lt;code&gt;Clock&lt;/code&gt;)。這樣 model 就純到底了，測試不用 mock 任何東西：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;@Test
void publishGuardsItsOwnPrecondition() {
  Article published = draft().publish(NOW);

  assertThatThrownBy(() -&amp;gt; published.publish(NOW))
      .isInstanceOf(IllegalStateException.class);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;沒有 Spring、沒有 DB、沒有 mock，跑起來毫秒級。整個 entity 的行為，invariant、guard、verb，全部這樣測完。&lt;/p&gt;
&lt;h4&gt;4. Model 含有核心業務邏輯，具備高度內聚 (Cohesion)&lt;/h4&gt;
&lt;p&gt;試想你現在要加一個新狀態 &lt;code&gt;DELETED&lt;/code&gt;，這是很常見的 soft delete 做法。enum 就這麼加一個新的值進去。然後呢？在傳統的 JPA Entity 寫法，開始排查所有相關的程式了，誰會動到 status？誰需要驗 status……blah blah。你就查吧，看什麼時候會查完，看你的 AI Agent 會不會漏查某一個 service 或甚至 controller (有人亂寫)。&lt;/p&gt;
&lt;p&gt;而你看這個具備 Domain 邏輯的 Article，你直接加一個 delete 的 verb (加點邏輯判斷，例如只有草稿能刪除...etc)，然後掃一圈這個 class 就全部查完了。因為架構上只允許你在 Model 層做異動，程式碼自然具備高度內聚的特性。AI Agent 很厲害沒錯，但最怕掃不到相關的程式碼。現在只要掃同一個檔案就好，token 還花得超少，天差地遠。&lt;/p&gt;
&lt;h4&gt;5. Immutable 帶來的額外好處&lt;/h4&gt;
&lt;p&gt;Entity 會到處跑，你在每一層都看得到它。在 service 層，還會忽然被丟到另一個 thread 裡 (&lt;code&gt;@Async&lt;/code&gt;)。你的 Entity 如果可以隨便 mutate，頭就大了，誰能夠拍胸脯說自己能寫好 multithread 的 code？有人會記得下 &lt;code&gt;synchronized&lt;/code&gt; 嗎？會 dead lock 嗎？程式碼維護成本直接暴增。&lt;/p&gt;
&lt;p&gt;你如果堅守 object 都是 immutable 的話，在 thread 間隨便傳都不會壞。service 只有直白白的傳遞，不會看到各種和業務不相關的程式碼。&lt;/p&gt;
&lt;p&gt;還有，你知道 &lt;code&gt;hashCode()&lt;/code&gt; 要穩定嗎？如果你將 entity 丟入 HashMap，然後半途去改 hashCode() 用到的狀態，bug 就來了。平常誰會注意這種小事？但等到發生了就查不出來了，放幾包乖乖都沒用的。&lt;/p&gt;
&lt;p&gt;Immutable 將很多奇奇怪怪的病灶直接消滅，顧好業務邏輯就好。&lt;/p&gt;
&lt;h3&gt;Keystone：Entity 異動只發生在 DAO 層級&lt;/h3&gt;
&lt;p&gt;好，entity 是 immutable 的，&lt;code&gt;publish()&lt;/code&gt; 回傳一個新 instance。那問題來了，誰負責把這個新 instance 存進 DB？這就是整套做法的 keystone，也是跟 Hibernate 世界完全相反的一條規則：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Entity 的異動只發生在 DAO 裡，跟持久化完全熔接且不可分割。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;先回憶一下 Hibernate 的世界：service 把 entity 撈出來，就地 mutate (&lt;code&gt;article.setStatus(...)&lt;/code&gt;)，然後 dirty checking 在 commit 的時候自動 flush。變異在 service，持久化是隱式的。所以才會有那個經典恐怖故事：「你以為你 save 了，其實沒有」。&lt;/p&gt;
&lt;p&gt;新世界把這個關係整個翻過來：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;@Repository
public class ArticleDAO {

  // domain verb 只在這裡被呼叫，貼著它的 UPDATE
  public Article publish(Article article, Instant now) {
    Article published = article.publish(now);  // 異動：產生新 instance
    mapper.updatePublished(published);         // 持久化：特化並單一用途的 UPDATE
    return published;                          // 回傳與 DB 同步的 entity
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;// MyBatis Mapper —— 這個 domain verb 改哪幾個欄位，UPDATE 就只碰哪幾個欄位
@Update(&amp;quot;&amp;quot;&amp;quot;
    UPDATE article
       SET status = #{status}, published_at = #{publishedAt}
     WHERE id = #{id}
    &amp;quot;&amp;quot;&amp;quot;)
int updatePublished(Article article);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;沒有 dirty checking，資料要進 DB 只有一條路：一個有名字的 DAO method，對應一條特定的 SQL。「到底存了沒？」看 code 就知道。沒呼叫 DAO 就是沒存，呼叫了就是存了，結構上根本沒有第三種可能。而且一個 state change = 一個 DAO verb = 一條 SQL = 一個可測試的單位，連命名都自動對齊業務語言 (&lt;code&gt;dao.publish(...)&lt;/code&gt;，而不是 &lt;code&gt;dao.update(entity)&lt;/code&gt;)。&lt;/p&gt;
&lt;p&gt;為什麼要熔接在一起，而且這樣好多重覆的程式碼不是？真笨！如果是我就抽個共用的……&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;就是要這個笨。每一種資料庫異動就是配一條客製獨一的 SQL 給它，改多少欄位、多少 where 完全是精準打擊，不多也不少。你可以 audit 所有的 SQL、可以獨立優化每一條而不受干擾，你不會不小心改到不該改的欄位。所有 SQL 全部攤開，你的 AI Agent 一條都不會漏查。&lt;/li&gt;
&lt;li&gt;前面提到，當你拿到一個 &lt;code&gt;Article&lt;/code&gt;，你可以完全相信它是完整的狀態。但 Entity 背後其實還有一個狀態：「持久化」。完整狀態應該連它一起算 —— service 層拿到的 instance，除了自己的欄位外，持久化狀態也要&lt;strong&gt;完全同步&lt;/strong&gt;到資料庫。所以異動跟持久化熔接在一起是必要條件，不可違反。&lt;/li&gt;
&lt;li&gt;將兩者合併在同個 method 內還有個好處：model 剛才改了什麼欄位，它的精準 SQL 要怎麼下？就在上一行，不用特別去找。Mapper 裡的 SQL 就 1:1 照做，維護上沒有困難。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;DAO 也是 factory&lt;/h3&gt;
&lt;p&gt;同一條規則也管到「生產物件」這件事：&lt;strong&gt;別讓半成品 entity 在 DAO 外面亂跑&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;public Article create(long authorId, ArticleEntry entry, Instant now) {
  Article toInsert = Article.create(authorId, entry, now);      // 剛建構完，id = -1，transient 半成品
  return toInsert.withId(mapper.insertReturningId(toInsert));   // INSERT … RETURNING id
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Service 傳進來的是&lt;em&gt;欄位值&lt;/em&gt; (Entry 加時間)，拿回去的是一個帶著真 id 的 persisted entity。那個 &lt;code&gt;id == -1&lt;/code&gt; 的 transient 半成品，只是為了 INSERT 的 bind 而存在，生命週期不會跑出這個 method —— 跟 Keystone 是同一個精神，所以連生成 entity 都鎖進 DAO。DAO 不再是純 repository，它是 &lt;strong&gt;repository + factory 的混合體&lt;/strong&gt;，製造跟入庫是同一件事。&lt;/p&gt;
&lt;p&gt;這樣就推得出一件很划算的事：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Service 手上的任何 entity，&lt;em&gt;由建構保證&lt;/em&gt;是合法的、而且與 DB 同步的。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;你想拿到一個 entity，只有一條路：DAO 回傳給你。而 DAO 回傳的東西不外乎三種：剛異動+持久化完的、剛 INSERT 完的、或是從 DB 撈出來過了一次 canonical constructor 的。反正每一種都乾淨，而且都跟 DB 同步。這份信任是架構給你的，不是靠紀律硬撐的。&lt;/p&gt;
&lt;h3&gt;原料 → 成品 → 包裝&lt;/h3&gt;
&lt;p&gt;Model 層的型別不是只有 entity 一種，它其實是一條產線：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;型別&lt;/th&gt;
&lt;th&gt;角色&lt;/th&gt;
&lt;th&gt;特徵&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ArticleEntry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;原料&lt;/td&gt;
&lt;td&gt;資料袋，帶驗證 annotation (&lt;code&gt;@NotBlank&lt;/code&gt;、&lt;code&gt;@Size&lt;/code&gt;)，controller 收貨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Article&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;成品&lt;/td&gt;
&lt;td&gt;immutable record，domain verb + invariant，上面講的主角&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ArticleDto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;包裝&lt;/td&gt;
&lt;td&gt;純資料出貨：時間轉 epoch millis、由 &lt;code&gt;article.toDto()&lt;/code&gt; 生產&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Entry 進來，entity 的 constructor 負責製造 (sanitize 就是在這一步做的)，Dto 出去。Controller 兩頭都不碰業務：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;@PostMapping
public ArticleDto create(@RequestParam long authorId,
    @Valid @RequestBody ArticleEntry entry) {
  return articleService.createArticle(authorId, entry);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Dto 就只是資料，沒有 session，沒有 proxy。上一篇講的那整類 detached-entity bug，在這個世界裡根本活不下去。&lt;/p&gt;
&lt;h3&gt;那 Service 還剩什麼？&lt;/h3&gt;
&lt;p&gt;剩編排，就這樣，很瘦：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;@Service
@Transactional(rollbackFor = Exception.class)
public class ArticleService {

  public CommentDto addComment(long articleId, long commenterId, CommentEntry entry) {
    Article article = articleDao.getById(articleId);      // I/O：service 的事
    if (!article.acceptsComments()) {                     // 決策：model 的事
      throw new IllegalStateException(&amp;quot;comments are only open on a published article&amp;quot;);
    }
    return commentDao.create(article, commenterId, entry, clock.instant()).toDto();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;欄位驗證？entity constructor 的事。業務 guard？model verb 的事。SQL？mapper 的事。Service 只做它獨佔的那幾種 side effect：transaction 邊界、時鐘、跨 DAO 編排。每個 method 讀起來，就是那個使用案例本身。&lt;/p&gt;
&lt;h3&gt;下集預告&lt;/h3&gt;
&lt;p&gt;實作的細節講完了，&lt;a href=&quot;/blog/2026/07/immutable-native-sql-part-3.html&quot;&gt;下一篇&lt;/a&gt;來實戰：每條 SQL 對真資料庫的測試怎麼寫 (而且只要&lt;em&gt;一層&lt;/em&gt;測試就夠)、怎麼用 &lt;code&gt;jsonb_agg&lt;/code&gt; 一次 round trip 撈回整個 object graph，還有這套架構在我那個二十年的 production 系統上，換來的實際數字。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;本文與 Claude (Fable) 共同撰寫 / co-authored with Claude Fable。&lt;/em&gt;&lt;/p&gt;
</description>
    </item>
    <item>
      <title>Immutable + Native SQL (1)：AI 時代還談 CRUD？</title>
      <link>https://ingramchen.dev/blog/2026/07/immutable-native-sql-part-1.html</link>
      <pubDate>Sat, 11 Jul 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2026/07/immutable-native-sql-part-1.html</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; 這三篇系列文講解 native SQL 如何搭配 Immutable Entity 使用。最快就是直接看程式：&lt;a href=&quot;https://github.com/ingramchen/demo-immutable-sql-crud&quot;&gt;demo-immutable-sql-crud&lt;/a&gt;，裡面也有 TypeScript 版可以參考。&lt;/p&gt;
&lt;p&gt;覺得太長的話，將網址丟給你最信任的 Agent，叫它總結這三篇的精華。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;前陣子我在 &lt;a href=&quot;https://www.threads.com/@ingramchen/post/DadXd82CVgz&quot;&gt;threads 上發了一則貼文&lt;/a&gt;：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;個人極度偏好 DDD 和 rich domain model&lt;br&gt;
然後我全部寫 native sql, 一點問題也沒有&lt;br&gt;
寫的 entity 還全部是 immutable&lt;br&gt;
FP+DDD+SQL 爽的咧&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;引來了一些討論和疑問。一則貼文塞不下完整的說明，我想還是寫成文章，把這套寫法好好的整理出來。先說了，我已經這樣寫快十年了，所以不是什麼很先進厲害的方法，這也是這篇文一直難產的原因之一，因為價值偏低。&lt;/p&gt;
&lt;h3&gt;都 2026 年了，還在談 CRUD？&lt;/h3&gt;
&lt;p&gt;CRUD 大概是這個行業被寫到最爛的題目。每個 framework 的 getting started 是一個 CRUD，每個 ORM 的文件是一個 CRUD，每個 bootcamp 的第一個作業還是一個 CRUD。現在都 AI Agent 時代了，程式多半是 agent 在寫，這時候還討論一個 CRUD 的寫作風格和 pattern，呃……吃飽太閒？&lt;/p&gt;
&lt;p&gt;是這樣，但不是這樣。&lt;/p&gt;
&lt;p&gt;AI 模型寫不出它認知以外的程式。模型的能力來自訓練資料，你叫它寫一個 Java CRUD，它產出的八成就是 JPA、&lt;code&gt;@Entity&lt;/code&gt;、&lt;code&gt;@OneToMany&lt;/code&gt;、lazy loading 那一套 —— 因為公開世界的程式碼就長那樣。模型不會發明新的寫法，它只會重組它看過的東西。這套 immutable + native SQL 的寫法在 ORM 盛行的年代很少見，少見，就表示不在訓練集裡。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;少見但有效的寫法，對這個世界還是有助益的：它會變成 AI 模型的新養料。你不寫出來 (成 blog)，模型永遠不會這樣寫。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;另一個方向想：當年為什麼會有 ORM？因為工程師不想重複做工。同一張 table，要寫 insert、寫 update、寫十幾種 select，打字打字再打字。ORM 把這些 boilerplate 全部吃掉，代價是引進一層又厚又充滿魔法的間接層。在這十幾年裡，這個交易是划算的。&lt;/p&gt;
&lt;p&gt;但現在打字的是 agent。boilerplate 的成本趨近於零，那我們還需要那層魔法嗎？以前「太費工、不可能」的選項，最近一個一個被重新打開 —— TypeScript compiler 用 Go 重寫了，Vite 把 bundler 換成了 Rust 的 Rolldown，連 Bun 都開始用 Rust 整個重寫。工具鏈可以整個推倒重來，那肥大複雜的 ORM 是不是也該被重新審視一次？&lt;/p&gt;
&lt;h3&gt;十二年前的遺憾&lt;/h3&gt;
&lt;p&gt;2014 年我寫過一篇 &lt;a href=&quot;/blog/2014/01/move-away-from-hibernate.html&quot;&gt;Move away from Hibernate&lt;/a&gt;，當時我把公司專案的 Hibernate 拔掉，改用 Spring 的 JdbcTemplate 寫純 SQL。那篇的結尾是這樣的：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;沒了 ORM，缺點是 domain 物件弱化了。…… 我很想念 Hibernate 帶來的便利與完整的物件模型，不過我不後悔。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;當時的認知是：要純 SQL，就得犧牲物件模型，domain 物件退化成資料的搬運工。不過沒多久我就開始導入 Kotlin 了，Kotlin 的思維還是比較傾向 Functional Programming，它的 API 預設都是不可變 —— Immutability everywhere。但查了一圈，大家都跟你說 JPA + Kotlin，你的 entity 就要 mutable。&lt;/p&gt;
&lt;p&gt;我就不死心，在那邊揉啊搓的，想辦法將 FP 的精神，套用在 Spring 這種傳統的框架內。我想好好管理 side effect，又要 native SQL，又要好測試，又要……&lt;/p&gt;
&lt;p&gt;最後總算生出一套規範和寫法，一用就是好幾年，那個 Kotlin 專案現在也長到 100 個 table 了。也不算小了吧？經過長年的實戰應該確定能套用在複雜的專案上了。&lt;/p&gt;
&lt;p&gt;今年上半年，Claude 大爆發，我把手上維護了二十年的 side project (純 Java，非 Kotlin)，裡面的 Hibernate 整個拔掉，換成 MyBatis + 純 SQL。這次不一樣了：entity 不但沒有弱化，還全部變成 immutable 的 &lt;code&gt;record&lt;/code&gt;，有完整的 domain 行為、有 self-enforced invariant，是一個貨真價實的 Rich Domain Model。FP + DDD + SQL 同時成立，一點都不衝突。&lt;/p&gt;
&lt;p&gt;十二年前欠的物件模型，這次要回來了，而且不用 ORM。&lt;/p&gt;
&lt;h3&gt;為什麼離開 ORM&lt;/h3&gt;
&lt;p&gt;準確來說是 JPA/Hibernate。ORM 也是有很輕量好用的，但 Hibernate 是功能最強大、也因此是最難維護的 ORM。&lt;/p&gt;
&lt;h4&gt;1. 隱式行為 (implicit behavior)，而且會隨版本變&lt;/h4&gt;
&lt;p&gt;Hibernate 的 flush 時機、dirty checking、cascade、lazy loading、L1/L2 cache、fetch strategy，全部是隱式的，而且歷代版本都改過預設值。同一份 source code，升個版，跑出來的 SQL 就不一樣了。這種變化在 code review 裡看不見，unit test 也常抓不到，因為它要跑在真的資料庫上、遇到真的 flush 時機才會現形。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;你以為你 save 了，其實沒有。它是隱式的，而且哪一版開始行為改變，你不知道。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;2. 可攜性的前提死了&lt;/h4&gt;
&lt;p&gt;ORM 誕生 (Hibernate, 2001 年) 的核心賣點是跨資料庫可攜。但現在很少人在換資料庫了，雲端時代，產品都是做成 SaaS，跟你收流量和訂閱費。買斷產品回去讓你換不同資料庫裝？不是說沒有，但就算是買斷，很多也是給你整包 docker 或 VM image，內含資料庫了。選了 PostgreSQL 就是 PostgreSQL，哪有輕易再切換的。而且營運到一定規模，誰敢換不同的資料庫？就算是 AI 時代你也不敢動。&lt;/p&gt;
&lt;p&gt;但 ORM 的抽象稅你還是 100% 在繳，可攜性的紅利，領到 0%。然後 ORM 要優化的時候 (全文搜尋、materialized view、JSONB...etc) 大家都跟你說回去寫 native SQL 就好，那到底在可攜什麼？我還不是要手寫客製的 SQL，那用你幹嘛？&lt;/p&gt;
&lt;h4&gt;3. ORM 隱藏了 I/O 的成本&lt;/h4&gt;
&lt;p&gt;N+1 是經典案例。你的程式只是寫個 for-loop，在 loop 中讀取某個 association，然後 N+1 就藏在後面跑出來了。你不仔細去算實際產生的 SQL，不太容易發現，尤其是你接手別人的程式。改成寫純 SQL，你就會看到有菜鳥在 loop 裡面呼叫 DAO，code review 一眼就看到；ORM 的 lazy loading 立意很好，但 code base 一大就難以管理，難以察覺。&lt;/p&gt;
&lt;p&gt;不要以為 AI 時代這件事就會解決。Agent 的 thinking effort 你調得夠高嗎？不夠高它跟人一樣會漏掉。context 快炸了嗎？context 快滿了 AI 的思考會下降的。你的程式碼抽象隱藏的東西越少，你和 AI 就越容易發現問題。ORM 把成本藏起來的同時，把這個訊號也一起刪掉了。&lt;/p&gt;
&lt;h4&gt;4. 深關聯 (deep association) 是 anti-pattern，而 ORM 誘導你寫深關聯&lt;/h4&gt;
&lt;p&gt;因為 ORM 讓深關聯「很容易」，你就會去設計深關聯。看看這行你就懂了：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ShopOrder.getItem().getProduct().getVendor().getAddress()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;很漂亮的物件關係，ORM 真方便啊！剛學完 Hibernate 第一年的我對這個功能讚嘆不已。&lt;/p&gt;
&lt;p&gt;然後過一段時間你才會學到 model 之間太過耦合&lt;br&gt;
→ 隱式 query 在背後亂飛&lt;br&gt;
→ 設 lazy 來優化 → &lt;code&gt;LazyInitializationException&lt;/code&gt;&lt;br&gt;
→ 有些地方要 eager fetch、有些要 lazy……呃，你發現你在和 framework 對抗，它不是來幫你，是扯你的後腿。&lt;/p&gt;
&lt;p&gt;最後因為耦合度太高，測試超難寫，然後你就不寫了。叫 AI 寫也是花很多 token 浪費在建立 fixture，寫得又臭又長，誰想 review？&lt;/p&gt;
&lt;p&gt;你手寫 SQL 很快就會發現高耦合的 SQL 寫起來笨重，非報表類的居然寫的這麼複雜，聞到這股異味自然就會放棄這種設計。&lt;/p&gt;
&lt;h4&gt;5. ORM 會幫你檢查型別，保證 field 和 column 對齊。手寫 SQL 你只能靠 Test 補&lt;/h4&gt;
&lt;p&gt;這是 ORM 派，對手寫 SQL 的標準反駁：schema 改了你會忘記改程式中欄位的映射。這缺點是真的，但 ORM 有一模一樣的問題，ORM 也會有個地方在設定欄位映射，你設錯了還是一樣錯，也是要等 query 真的跑了才知道。結論殊途同歸：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;每一條 SQL 操作，都需要一個對真資料庫的測試。ORM 不會讓你免掉這件事，它只是讓你以為可以。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;我早期信任 ORM 產生的 SQL，所以有一陣子 DAO 都是用 mock 在測，結果好幾次上了 production 後才爆掉。persistence 的測試沒真的去跑 SQL 真的是白寫了，那個測試根本不能抓到 bug，也不能當防護網，完全是無效測試。&lt;/p&gt;
&lt;p&gt;還有一個流派是測試用 SQLite，正式用 PostgreSQL/Oracle...etc。這個我也歸在 anti-pattern，你不用同一版的資料庫跑測試，是哪來的信心正式環境會過啊？更何況就算用了 ORM，還是有一堆優化的 native SQL 要寫，那些用 SQLite 跑？當然這個雷我也踩過了，很痛啊。&lt;/p&gt;
&lt;p&gt;ORM 給你的型別檢查是有幫助的，但省不了多少工。database 是一個巨大的 side effect 機器，只有實測才能驗證你的 CRUD 是不是真的對。&lt;/p&gt;
&lt;p&gt;手寫是苦工，但是我們現在有 Agent 了，你看過 Claude Code 怎麼查程式嗎？下 grep 去查啊：&lt;code&gt;username|Username|user_name&lt;/code&gt; 嘩啦啦的拉出一大串，你寫的 native SQL 隨便也命中，漏改映射的機率很低。假設命中了三條，AI Agent 可以直接深入分析，看看修改後的 SQL 對功能和效能上的影響。改完了也只動到這三條，其他完全不變。你用 ORM 就要開始賭這次修改的影響範圍了。&lt;/p&gt;
&lt;p&gt;總結一句，AI 時代的成本結構，又一次站在顯式 (explicit SQL) 這邊。&lt;/p&gt;
&lt;h3&gt;下集預告&lt;/h3&gt;
&lt;p&gt;「為什麼」講完了。&lt;a href=&quot;/blog/2026/07/immutable-native-sql-part-2.html&quot;&gt;下一篇&lt;/a&gt;進入「怎麼做」：immutable &lt;code&gt;record&lt;/code&gt; entity 怎麼設計、invariant 怎麼收進 constructor 讓壞狀態根本無法存在，以及整套做法的 keystone ——&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;entity 的變異只發生在 DAO 裡；DAO 不只是 repository，它同時是 factory。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;範例會用 Java (record + MyBatis + PostgreSQL) 示範，不過這套 pattern 跟語言無關 —— immutable entity、invariant 進 constructor、mutation 鎖進 DAO，在 TypeScript、Go、Kotlin 上一樣成立。demo 的程式也會有一份 TypeScript 版。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;本文與 Claude (Fable) 共同撰寫 / co-authored with Claude Fable。&lt;/em&gt;&lt;/p&gt;
</description>
    </item>
    <item>
      <title>轉生到異世界騎單車</title>
      <link>https://ingramchen.dev/blog/2017/07/enter-another-world-bike.html</link>
      <pubDate>Mon, 10 Jul 2017 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2017/07/enter-another-world-bike.html</guid>
      <description>&lt;p&gt;標題殺人啦~~~&lt;/p&gt;
&lt;p&gt;嗯哼… 正經點說，這只是一篇入坑單車的心得，不是往常的 Java 技術文，被標題砍傷的可以直接左轉去附近診所就醫…&lt;/p&gt;
&lt;h3&gt;10 years ago&lt;/h3&gt;
&lt;p&gt;我記得台灣是在 2008 年開始突然間盛行單車的，那時討論和開的車店都很多。早在那時，我的前同事就很迷單車，想要推我入坑，為此，還特地借車，帶我這宅男去河濱晃一晃。當下騎完的感想是河濱的夜景真不錯，夜風吹起來涼涼的蠻舒服的。&lt;/p&gt;
&lt;p&gt;然後就沒有然後了。&lt;/p&gt;
&lt;p&gt;沒有打到我的甜蜜點，宅男還是不會出門的，我還是持續做原本的運動，每週固定的室內游泳。並沒有加入單車一族。&lt;/p&gt;
&lt;h3&gt;直到去年六月，我的腳跟中了...&lt;/h3&gt;
&lt;p&gt;每週固定游泳是一件很困難的事，我堅持了幾年終於放掉了，最近三年真的變成一條白胖胖的 x 了。長期游泳留下來的好身材消失了，不過也沒有太懊悔啦，因為這是長達三年的時間漫漫消磨掉而不自覺的。&lt;/p&gt;
&lt;p&gt;去年六月的時候去買新鞋子，買跟之前同牌子一樣號碼，有點緊但想說一陣子就會鬆了吧？就忍著繼續穿。忍到某一天我的左腳得了跟踺炎…&lt;/p&gt;
&lt;p&gt;啊！沒想到會遇到這種倒楣事，這不是大病但不太能走路，上下班變得有點麻煩。於是乎就騎 ubike 代步，騎腳踏車時腳跟不用支撐體重，受傷的地方就不會痛了，不錯的方案。通勤一陣子發現騎腳踏車還蠻有趣的嘛，而且身體的體能從病夫狀態有點回復成正常人了。對，以後通勤都騎單車上下班，固腳還能練體能 -- 我心裡這樣打算著。&lt;/p&gt;
&lt;h3&gt;Brompton&lt;/h3&gt;
&lt;p&gt;ubike 什麼都好，就是把手太髒，不太能適應，而且我們公司的位置是熱門地段，常常要等 ubike。每天都騎，一段時間就不想再騎 ubike 了，會想要有自己的單車。這時的我並不知道我腳踏的再也不是單車的踏板了，而是一個黑洞…&lt;/p&gt;
&lt;p&gt;為什麼買到 brompton 就不提了，我買的時候也不知道原來這是很貴的小折 (折疊車)。網路上查了一些車，看造型和功能 brompton 都是我喜歡的，隔幾天我就去車店牽回家了…&lt;/p&gt;
&lt;p&gt;哎呀，這 brompton 真是方便、高雅、又好騎的小折，騎自己的車通勤就是爽。不過一路上 brompton 的車好少啊，幾天才遇得到一輛。可能是太貴的車吧？不過小折在台灣前幾年已經紅過了，那時候肯砸錢在小折的人比較多，現在遇不到正常吧？騎車是個可以獨樂的運動，我也喜歡一個人，所以也沒這麼在意，繼續開心通勤…&lt;/p&gt;
&lt;h3&gt;Pokemon Go&lt;/h3&gt;
&lt;p&gt;曾經紅遍大街小巷的 Pokemon Go，現在還有人記得嗎？它已經發行一年了吧。這遊戲對許多人都有著不同的意義：AR 模式創新、寶可夢的 IP 吸引童年回憶、從老到少人人都愛玩、會跑到大老遠地方慢慢農、會跑到從沒去過的小公園抓寶…&lt;/p&gt;
&lt;p&gt;我也跟風玩了這遊戲，但這遊戲對我的影響大大的不同。一切都很巧似的，我七月剛買單車，八月就出寶可夢，而這遊戲要孵蛋啊！要到處去找巢穴！為了這遊戲，我便騎著閃亮亮的 brompton 開始越騎越遠，人生進入了奇怪的模式。&lt;/p&gt;
&lt;h3&gt;Hunting&lt;/h3&gt;
&lt;p&gt;我不喜歡出門、沒興趣去景點玩、也對食物沒愛、不喜人群。簡直是集各種宅男基因於一身… 不過 Pokemon Go 卻能讓我去那些野外的地點，不論它是不是景點還是鳥不生蛋，我都有十足的動機前往那裡 &lt;em&gt;狩獵&lt;/em&gt; 。&lt;/p&gt;
&lt;p&gt;我本來以為我只是在抓怪湊圖鑑而已，後來開始察覺自己喜歡抓路上單隻的，而不喜歡去巢穴抓。我打開雷達，發現瓦斯球或是臭泥在不遠處，算一下剩餘時間 (那時的怪只出現15分鐘) ，可行的話就快馬加鞭，啊，不是，騎著我的 brompton ，衝啊~~~ 熱血的去抓怪。&lt;/p&gt;
&lt;p&gt;放假的時候不是遠征就是在近處抓怪，而且是靠自己的雙腳踩到那裡，抓到那隻怪，而那隻怪就是獎勵。騎單車對我來說不再是移動、通勤了。我是在滿足內心裡原始的狩獵本能 -- 只有騎單車沒有這個感覺，純綷騎機車去抓怪也不會有。兩者合一才點燃我熊熊的烈火。&lt;/p&gt;
&lt;h3&gt;Game Over&lt;/h3&gt;
&lt;p&gt;六月腳痛才被迫騎單車，沒多久就遇到寶可夢，直接變身為獵人騎著單車穿梭在都市叢林裡到處狩獵。可惜沒多久就破關了，一代寶可夢都抓齊了… Game Over，破關的那一刻雖然很爽但沒多久就覺得莫落。&lt;/p&gt;
&lt;p&gt;寶可夢雖然沒了，但車子還在啊，我好想騎更多，於是乎開始繼續我的狩獵 -- 這一次被狩獵的，是各大知名單車路線。&lt;/p&gt;
&lt;p&gt;一台小折能騎多遠呢？我沒概念，我一開始只把目標定在環台北的河濱一週而已 (60km) ，這路線安全，而且騎不動隨時可以徹退，就讓我慢慢把體力練回來吧。&lt;/p&gt;
&lt;p&gt;60km 對當時的我真的好遠啊，我一開始真的騎不完，這段期間雖然騎車抓了不少怪，體力還是不夠。這時候就會發現自己真的是 &amp;gt;40 歲的大伯了，再加上荒廢三年沒運動更是慘。&lt;/p&gt;
&lt;h3&gt;公路車 TCR&lt;/h3&gt;
&lt;p&gt;練了幾週後，我可以騎半圈 40 km 左右了，不過騎到研究院路的爬坡我直接投降… 我很怕爬坡的。&lt;/p&gt;
&lt;p&gt;然後我就買公路車了。&lt;/p&gt;
&lt;p&gt;哈哈，好像跳太快了，不過當時就是這樣。那時心想，難道環河濱一圈就卡在這段坡嗎？不如換大車直接攻克吧！爬了一下文 -- 爬坡的話要買公路車，捷安特的車貴的很貴，但入門的碳纖維車在預算內，沒想太多就直接刷卡帶回家了，這是人生第二台車。&lt;/p&gt;
&lt;p&gt;新手騎公路車就是一連串的腰酸背痛，好在多騎幾次自然就好轉了。公路車真的好快啊，隨便踩都比我的小折時速快 5公里，好爽！沒多久我也正式騎完了河濱一圈，不過那段才 150公尺高的坡真是爬得我哇哇叫，哭爹喊娘的硬撐才騎完。&lt;/p&gt;
&lt;h3&gt;覺醒新的屬性&lt;/h3&gt;
&lt;p&gt;人很奇怪。&lt;/p&gt;
&lt;p&gt;人真的很奇怪。&lt;/p&gt;
&lt;p&gt;我超奇怪的啦。&lt;/p&gt;
&lt;p&gt;被一個小坡虐待之後，我隔天心裡想著是再去騎一次。&lt;/p&gt;
&lt;p&gt;不是很累很喘嗎？不是腳都沒力了？不是心臟都快爆了？不是會回想起當年當兵被操到死的不好回憶.... 嗎？&lt;/p&gt;
&lt;p&gt;征服了人生的第一段坡，我還想 &lt;em&gt;狩獵&lt;/em&gt; 其他的短坡、徒坡、超長坡、台灣的高山…&lt;/p&gt;
&lt;p&gt;從此，我居然變成了一個只騎爬坡不騎平路的 Climber。每一次出去騎，沒有騎到坡我就覺得今天沒有騎到車...&lt;/p&gt;
&lt;h3&gt;轉生到異世界騎單車&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Status Open!&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; HP:  100
 MP:   50
ATK:   58
DEF:   30
DEX:   98
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;轉生到異世界的主角，到了中古世紀與混雜遊戲觀的世界裡，通常喊一下 &lt;code&gt;開啟狀態&lt;/code&gt; 就可以看到自己的能力數值化，有體力值，有攻擊力，有熟練度... blah blah...&lt;/p&gt;
&lt;p&gt;hm ?&lt;/p&gt;
&lt;p&gt;跳針了嗎，怎麼突然插這一段？&lt;/p&gt;
&lt;p&gt;這跟單車沒什麼關係，只是我的腦袋把這兩個元素混合了。這一兩年異世界轉生系的小說、漫畫、動畫非常多啊。如果你有接觸日本 ACG 的話，應該也有類似的發現。劇情通常是主角們穿越到了異世界大顯神威，還附加一堆遊戲般的數值和技能，讀者很容易就上癮…&lt;/p&gt;
&lt;p&gt;我開始騎公路車後也覺得自己轉生到異世界了，不過不是我能在那裡大顯神威，而是整個世界觀大變，好像到了新的國度般。身為一個宅男沒出過什麼門，騎去不同的山頭上真是新鮮的體驗啊，說的 &lt;em&gt;中二&lt;/em&gt; 一點就是冒險者發現新世界的感覺。這是其一。&lt;/p&gt;
&lt;p&gt;其二，我也能 &lt;code&gt;status open&lt;/code&gt; 了，這單車界好多數值啊！&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/2017/07/wko4.jpg&quot; alt=&quot;wko4&quot;&gt;&lt;br&gt;
圖說: 數據多到滿出來，已經花掉的曲線圖&lt;/p&gt;
&lt;p&gt;看看這圖裡的數據，嘖嘖嘖真是不得了。而且這些數值讓騎單車挺像 RPG 遊戲的…&lt;/p&gt;
&lt;p&gt;有遊戲裡的攻擊力嗎？&lt;br&gt;
有！你踩踏的功率 (以瓦數計) 就是你的攻擊力，功率越大速度越快，裝備功率計就能看到這值。&lt;/p&gt;
&lt;p&gt;有 HP 嗎？&lt;br&gt;
有！TSS (Training Stress Score) 訓練負荷分數就是你運用的血量，用到破錶就沒力了。使用功率計加上 FTP 的測量就能取得這數值。&lt;/p&gt;
&lt;p&gt;有 DEX 嗎？&lt;br&gt;
有！你的踏頻的轉速和順暢度就是 DEX。裝個踏頻器在曲柄上就能量測。&lt;/p&gt;
&lt;p&gt;有 STA 嗎？&lt;br&gt;
有！最大攝氧量 VO2Max 就是你的體力值，心肺好什麼都強！功率計搭配心率帶就能估算你的 VO2Max。&lt;/p&gt;
&lt;p&gt;那... 我可以裝備武器嗎？&lt;br&gt;
有！單車和輪組就是你的武器，RPG 有銅劍、鐵劍、秘銀劍。單車界有鋁車、鋼管車、碳纖維車，輪組更是多到數不完。新手用的銅劍 100G，到了遊戲後期幣值通膨，秘銀劍變 10000G。單車界也是一樣。高級品比新手款貴幾十倍！&lt;/p&gt;
&lt;p&gt;防具咧？！&lt;br&gt;
有！頭盔、護手、皮甲、靴子... 在單車界就是安全帽、手套、車衣褲、卡鞋，從上到下可以穿的都有人賣，產品琳瑯滿目，比你玩的任何一款遊戲都還多！&lt;/p&gt;
&lt;p&gt;該不會連藥水都有吧？&lt;br&gt;
有！因為騎單車的運動時間太久，需要定時補給，所以有專門的能量膠可以補你的 HP。一包一口就吃完，還挺貴的。&lt;/p&gt;
&lt;p&gt;呼~~~~&lt;/p&gt;
&lt;p&gt;這些數據和行頭，進入公路車界後就會慢慢地、自然地出現在你的眼界裡。我這個程式設計師本來就是個數據控，所以該買的量測設備我都買了，我騎車上山爬坡，碼錶上就得顯示十個數據讓我參考，說是數據奴隸也不為過。不過這些諸多遊戲般的特徵，也是公路車更加吸引我的原因。&lt;/p&gt;
&lt;p&gt;有各式各樣的裝備可以升級也是單車與其他運動不太一樣的地方，可以滿足敗家的欲望，有些車友甚至沒有升級換車就沒有騎車動力的。我個人嘛… 也有一點類似的症頭。升級的樂趣我會去享受的，但花大錢純綷買虛榮這種事我還是做不到。&lt;/p&gt;
&lt;h3&gt;精神時光屋&lt;/h3&gt;
&lt;p&gt;想要爬坡爬的好，苦練是跑不掉的。自從愛上爬坡之後，便開始從入門的坡段開始騎，坡度緩的還可以硬撐上去，但遇到爬不上的坡 (爬到一半得下來牽車...)，就知道自己的不足了…&lt;/p&gt;
&lt;p&gt;不夠力，就得排課練習，從這時刻起，我騎單車的方式又開始變了。本來我只是個假日車手，喜歡去騎不同山頭享受征服的感覺，現在變成一週要騎五天，其中四天是在晚上的訓練台渡過的。&lt;/p&gt;
&lt;p&gt;在訓練台上騎車就是自個兒苦哈哈的騎，沒什麼樂趣可言。好在，這一、二年有個叫 Zwift 的虛擬騎乘軟體可玩。Zwift 可以讓將你單車在訓練台上的功率輸出，轉換到它們建構的虛擬環境裡，而且可以多人連線。具體一點說，就是你裝 zwift 軟體，登入後，裡面就有你這位騎士，也有其他人，你在訓練台上踩踏，你的騎士就會前進，軟體裡的場景也會一起移動。&lt;/p&gt;
&lt;p&gt;透過 Zwift 你可以和別人一起虛擬騎乘，一起同樂。也可以在上面照課表練功。這個軟體真是太棒了！我能斷言如果沒有它我根本沒辦法在訓練台上練習，更不用說想要征服更高的山頭了。&lt;/p&gt;
&lt;p&gt;在 Zwift 跟著團隊練習會讓你有很大的動力進步，單純的就是有人可比較，有人逼著你前進。而自己練習時，它的課程也變化很多，騎起來不會無聊，也很有挑戰性，有實值的幫助。沒有 zwift 練習的車友多半都是看電視邊騎訓練台，不然太無聊，但因為太輕鬆了，這種練法進步不快。&lt;/p&gt;
&lt;p&gt;訓練台上練車，在單車界裡戲稱這是 精神時光屋 (語自七龍珠) 。為什麼？因為認真騎訓練台一小時抵得過在外面練二、三個小時。原因是訓練台上的阻力是持續的，你不踩踏就會停了。這不像在外面騎，停止踩踏車子還是會繼續滑，你無意識的會偷懶放鬆。&lt;/p&gt;
&lt;h3&gt;夜深人靜時&lt;/h3&gt;
&lt;p&gt;我喜歡爬坡&lt;br&gt;
我喜歡單車有像遊戲一樣多的數值&lt;br&gt;
我喜歡去買裝備升級&lt;br&gt;
我喜歡征服新的路段和山頭&lt;br&gt;
我喜歡破自己的個人紀錄&lt;br&gt;
我喜歡減了 20 kg 後的自己&lt;/p&gt;
&lt;p&gt;但是練車好辛苦…&lt;/p&gt;
&lt;p&gt;這些體驗對我來說都是很新奇的，我從小到大玩了很多領域，像是打電動、寫程式、玩 MIDI、玩相機… 等等等，從來沒有把任何運動當成是興趣，變成生活的重心。這回一頭栽入單車界讓我的生活作息完全改變，簡直像是變成另一個人似的。&lt;/p&gt;
&lt;p&gt;我的人生好像重來了一次般，我 &lt;em&gt;轉生&lt;/em&gt; 了吧？&lt;/p&gt;
&lt;p&gt;面對這巨大的改變，有時候夜深人靜時，坐在床上，看著架在一旁的單車，不免想著我在幹嘛？！半年前還是個宅在家寫程式的宅男，怎麼現在的自己每週都要騎五次車？在外面騎很爽就算了，在訓練台上折磨自己是在幹嘛？而且有時候訓練強度太高，練得真的快死掉了… (測 FTP 時會崩潰的)&lt;/p&gt;
&lt;p&gt;我後來稍微知道自己的動力是什麼了，大概是自我實現 --&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;滿足原始的狩獵本能&lt;/li&gt;
&lt;li&gt;突破自己的極限帶來的成就感&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我想任何可以自我實現的活動，有機會的話我都會一頭栽進去吧！只是我剛好找到單車運動這個 &lt;em&gt;媒介&lt;/em&gt; ，能讓我好好的發揮。&lt;/p&gt;
&lt;h3&gt;小結&lt;/h3&gt;
&lt;p&gt;公路車已經有百年歷史，輪不到我這個不到一年的新手跟大家介紹，我像是劉姥姥進大觀園，發現很多奇妙的體驗，寫一下新手心得而已。我真摯的期望自己能好好的繼續騎下去，可以連續騎個五年、十年，或是更好，變成後半人生的一部份。我不會後悔進入公路車這個異世界，也不會懊悔自己怎麼不早點入坑，畢竟十年前的單車環境並沒有吸引我，留住我的要素。&lt;/p&gt;
</description>
    </item>
    <item>
      <title>Kotlin 的 data class 會對你的程式產生什麼樣的化學反應</title>
      <link>https://ingramchen.dev/blog/2017/06/kotlin-data-class.html</link>
      <pubDate>Sun, 18 Jun 2017 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2017/06/kotlin-data-class.html</guid>
      <description>&lt;p&gt;Kotlin 最近紅啊！對這個語言&lt;a href=&quot;/blog/2011/11/2011-11-05.html&quot;&gt;我 2011 年&lt;/a&gt;時就有在看了，不過後來放生，因為 Java8 真是不錯用。直到它出了 1.0 後，它宣稱向前相容和永續維護，有了這項我最看重的承諾，它才進入我的口袋名單，計畫開始試用。&lt;/p&gt;
&lt;p&gt;真正開始導入的時間點是 Kotlin 出 1.1 版，1.1 版的重大變更是加入了 coroutine，那時我已寫過一陣子 &lt;a href=&quot;https://www.dartlang.org/articles/language/await-async&quot;&gt;dart async/await&lt;/a&gt; 功能，認定 async/await 必定是 client side 開發未來的趨勢，而且真的是很好寫。1.1 加入了 coroutine 我就掉進這個坑了。&lt;/p&gt;
&lt;p&gt;結果 coroutine 試了之後發現還在實驗階段而已，只好又先放著，不過我們 Android 的開發已經開始寫 Kotlin 了。又過了幾個月 Google I/O 發表了 Google 要將 Kotlin 作為 Android 的官方語言…&lt;/p&gt;
&lt;p&gt;哇塞！This is huge!!&lt;/p&gt;
&lt;p&gt;突然間我的信心大增，原本只是嘗鮮跟個風而已，現在可好，時代的輪子開始轉動了，而且還大轉特轉！我給自己和團隊定了一個潛規則 -- 從現在開始所有新的 &lt;code&gt;.java&lt;/code&gt; 檔都要是 &lt;code&gt;.kt&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;一切都從 data class 開始&lt;/h3&gt;
&lt;p&gt;好了，導入的廢話說完了，我們來進入正題。Kotlin 有個最直覺最簡單的功能，是導入的團隊和開發者第一個要學，就是 data class&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;data class Account(val id:Long, val name:String)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上面是個帳號 data class，裡面有兩個基本欄位，然後 compiler 會替你生成 property 和 equals()/hashCode()/copy() 等三個 method。相比 Java 的實作，大概是 1:20 這樣的程式碼行數比，一口氣少了 20 行以上啊。&lt;/p&gt;
&lt;p&gt;這… 能簡化成這樣還不狂用嗎？我肚子裡的程式蟲開始大鬧了！&lt;/p&gt;
&lt;p&gt;ps. 注意本文不會討論 data class 的語法和簡介，這篇的主題是運用 data class 後，引發的一連串程式設計的 &lt;em&gt;化學變化&lt;/em&gt;。&lt;/p&gt;
&lt;h3&gt;data class apply to DTO&lt;/h3&gt;
&lt;p&gt;首先是 DTO (Data Transfer Object)，這個是 pattern 是處理資料 serialize/deserialize 時常用的技巧，無論是遠端的 API 呼叫，或是儲存到資料庫，如果你選用的 json library 是 &lt;a href=&quot;https://github.com/FasterXML/jackson&quot;&gt;jackson&lt;/a&gt; 。你可以加上 kotlin module:&lt;/p&gt;
&lt;p&gt;gradle:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;compile &amp;quot;org.jetbrains.kotlin:kotlin-reflect:1.1&amp;quot;
compile &amp;quot;com.fasterxml.jackson.module:jackson-module-kotlin:2.8.8&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;jackson ObjectMapper 設定一下:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;ObjectMapper().registerModule(KotlinModule())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接下來的你可以簡單的 deserialize 你的 data class&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;val json = &amp;quot;&amp;quot;&amp;quot;
{
  &amp;quot;id&amp;quot;: 14,
  &amp;quot;name&amp;quot;: &amp;quot;ingram&amp;quot;
}&amp;quot;&amp;quot;&amp;quot;
val account = objectMapper.readValue(json, Account::class.java)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;咦？好像沒什麼特別的啊？！有什麼好提的？重點在如果用 Java 寫 &lt;code&gt;Accont&lt;/code&gt; 這個 DTO 會是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;public class Account {
  @JsonCreator
  public Account(
    @JsonProperty(&amp;quot;id&amp;quot;) long id, 
    @JsonProperty(&amp;quot;name&amp;quot;) String name) {...}     
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你必須一個個替所有的欄位加上 annotation，因為 Java 並不會保留 parameter 的名字！所以 reflection 時拿不到。這實在是太痛苦了，尤其是 DTO 的物件很多時。搞到後來都想自己寫工具產生了。&lt;/p&gt;
&lt;p&gt;不過 Kotlin 就不一樣了，Kotlin compile 時會加寫 meta data，將 parameter name 另外存，所以可以用 kotlin-reflect 撈出來，而這就是 jackson kotlin module 裡做的事。Java8 也可以加寫 parameter name 啦，但這個在 Android 不能用。&lt;/p&gt;
&lt;p&gt;這裡只是以 jackson 為例，其他的 library 應該也有做類似的事，用了 Kotlin 就是大大的省功啊！&lt;/p&gt;
&lt;p&gt;個人認為光是 data class 套用在 DTO 就值得導入 Kotlin 了。如果你們團隊對導入 Kotlin 有所疑慮，那就先限定使用 data class 在 DTO 之類的 class 就好了，因為就算 Kotlin 真的不合預期，要將 data class 全轉回 .java 並不會花多少時間。&lt;/p&gt;
&lt;h3&gt;data class apply to Entity&lt;/h3&gt;
&lt;p&gt;DTO 很自然的可以套用 &lt;em&gt;data&lt;/em&gt; class。那麼 Entity 呢？Entity 是指有識別 id 的物件:&lt;/p&gt;
&lt;p&gt;java JPA:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;@Entity
public class AccountEntity {
  @Id
  private long id;
  private String name;
  public boolean equals(Object that) {
    if (that == null) return false;
    return that.class == AccountEntity.class 
         ? id == ((AccountEntity)that).id
         : false;
  }
  public int hashCode() { return Long.hashCode(id); }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如你所見 Entity 在 JPA 的環境下可能欄位是 mutable 的，然後 equals 只要是識別 id 相同就是相等的。&lt;/p&gt;
&lt;p&gt;data class 可以用在 JPA Entity 嗎？說實在的我沒研究，data class 和 JPA 相衝的點是 data class 沒有 default constructor。不過你可以加個 plugin 解決&lt;/p&gt;
&lt;p&gt;gradle:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apply plugin: &amp;quot;kotlin-jpa&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;plugin 會替你生 default constructor 給 Entity 用，而這個 constructor 只有 reflection 才能看的到。不過 data class 預設的 equals/hashCode 可能不是你要的 (它會比全部的欄位，而不是只有 id)，你還是得自己 override。&lt;/p&gt;
&lt;p&gt;至於我個人的做法比較極端了，因為我已經選擇不再用 ORM framework 了。我選擇用 Spring jdbc template 之類的簡化過工具來處理 ORM，而不是一個具備複雜 session/cache/life cycle 的 framework 像是 JPA/Hibernate。&lt;/p&gt;
&lt;p&gt;從 JPA 解脫後我的 Entity 一直都是 Immutable Object，也讓所有欄位 equals，換句話說就是個 data class&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;data class AccountEntity(val id:Long, val name:String)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;哇，跟 DTO 很像啊，在實務上用 immutable Entity 有沒有問題？，而所有欄位都納入 equals 有沒有問題？我的答案都是無。Immutable Entity 其實讓管理物件的 lifecycle 簡單很多，沒有 mental cost (意思就是你看的這段程式碼，看著這個物件，還要去想它背後是不是 detached session，還是它欄位剛剛有沒有被改過什麼的，一些隱藏在背後的成本)。我寫了幾年現在比較傾向這種設計了，套上 Kotlin 的 data class 更是如魚得水。&lt;/p&gt;
&lt;p&gt;我建議各位可以試試套用 immutable 的想法在 Entity 上，也就是換上 data class。如果你是用 JPA，記得要加 plugin。&lt;/p&gt;
&lt;p&gt;題外話：從 kotlin-jpa plugin 的設計就可以了解 kotlin 是相當務實的語言。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;什麼？JPA 不能用嗎？那就開個後門讓你用吧！&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;很多語言設計師都很在意 purity，不會想開後門這種髒東西。kotlin 開發團隊的想法就是 -- &amp;quot;沒關係！很難用我們就改吧！&amp;quot; 骨子裡是商業 IDE 的開發商就是不一樣。&lt;/p&gt;
&lt;h3&gt;How data class affect your API design&lt;/h3&gt;
&lt;p&gt;接下來討論的是 data class 如何影響你的 API 設計，上面提的都是 class 本身，如果在 method (API) 這個層級使用 data class 會有什麼化學反應呢？&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;class Messenger {
  fun sendText(receiverId:Long, text:String)
  fun sendData(receiverId:Long, data:Map&amp;lt;String, String&amp;gt;)
  fun broadcastText(topic:String, text:String)
  fun broadcastData(topic:String, data:Map&amp;lt;String, String&amp;gt;)
}

//example usage:
messenger.sendText(13L, &amp;quot;Hello world&amp;quot;)
messenger.broadcastText(&amp;quot;notice&amp;quot;, &amp;quot;Hello world&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大概解說一下，這個 kotlin class 上有四個 method，從結構來看，它可以送文字訊息給收的人 &lt;code&gt;sendText(receiver, text)&lt;/code&gt; ，也可以送 data &lt;code&gt;sendData(receiver, data)&lt;/code&gt; 。然後它也有廣播給某個主題文字訊息和 data 的功能。(&lt;code&gt;broadcast*&lt;/code&gt; ，有訂閱這個 topic 的人都收的到這個廣播)。&lt;/p&gt;
&lt;p&gt;ok，應該不會很難懂吧？&lt;/p&gt;
&lt;p&gt;自從有了 data class 這個武器之後，我的設計開始變成這樣了：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;sealed class Destination {
  data class Receiver(val id:Long): Destination()
  data class Topic(val name:String): Destination()

  //後面的 `:Destination()` 是 kotlin 繼承的語法
}

class Messenger {
  fun sendText(destination:Destination, text:String)
  fun sendData(destination:Destination, data:Map&amp;lt;String, String&amp;gt;)
}

//example usage:
messenger.sendText(Receiver(13L), &amp;quot;Hello world&amp;quot;)
messenger.sendText(Topic(&amp;quot;notice&amp;quot;), &amp;quot;Hello world&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sealed&lt;/code&gt; class 是指這個 class 只有以下這些我定義的 subclass，別人不能再繼承，換句話說，&lt;code&gt;Destination&lt;/code&gt; 這個 class 一定只會有兩種 subclass，一個是Receiver, 一個是 Topic 。有 sealed 這個功能可以確保在 compile 時期就能抓到不可預期的 subclass。沒有這個保護，用戶或是接手維護的人，如果亂繼承亂用 API，變成要等到 runtime 才爆掉才發現有 bug。&lt;/p&gt;
&lt;p&gt;這個範例裡，我將 receiver 和 topic 給 &lt;em&gt;union&lt;/em&gt; 了起來，抽像成一個 &lt;code&gt;Destination&lt;/code&gt; class 來代表。其實這種 API 設計在各大 message broker 的 API 都看的到，好像也沒什麼特別的吧？不過 data class 沒幾行就能實作出 union type，對比 Java 需要加寫的程式碼，使用 Kotlin 語言會有點鼓勵這樣的 API 設計。&lt;/p&gt;
&lt;p&gt;ps. 如果語言有 union type 的功能，那可能 API 會變成吧&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;//這是假想中的 union type 功能: (Long|String)
fun sendText(destination:(Long|String), text:String)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不過 kotlin 沒打算加 union type，所以就是 sealed data class 拿來替代&lt;/p&gt;
&lt;h3&gt;data class 魔人&lt;/h3&gt;
&lt;p&gt;一旦起了 union type 的頭，就一發不可收拾了：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;sealed class Payload {
  data class Text(val content:String): Payload()
  data class Data(val json:Map&amp;lt;String,String&amp;gt;): Payload()
}

class Messenger {
  fun send(destination:Destination, payload:Payload)
}

//example usage:
messenger.send(Receiver(13L), 
               Text(&amp;quot;hello world&amp;quot;))
               
messenger.send(Topic(&amp;quot;notice&amp;quot;), 
               Data(mapOf(&amp;quot;url&amp;quot; to &amp;quot;4gamers.com.tw&amp;quot;))

//`mapOf()` 是 kotlin 裡類似 map literals 的東西
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;哇！傳送的東西也抽出來變 Payload，API 只剩一個 method 了。&lt;/p&gt;
&lt;p&gt;hmmm.... 好像開始走火入魔了喔？！但先這樣吧。&lt;/p&gt;
&lt;p&gt;之後，隨著需求的增加，這個 messenger 要可以設定發出去的訊息會不會在手機聲響，會不會彈 notification 小窗出來，要不要是私密的留言，等等功能…&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;class Messenger {
  fun send(destination:Destination, 
           payload:Payload,
           soundFile:String?,
           showNotification:Boolean,
           secret:Boolean)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;這時候你就會感謝自己當初把四個 method 合併成一個了，因為你只要 refactor 一個 method 就行。當然啦，不是把 method 合併都一定是對的，例如其實用 API 的開發者寫起來有點不順，他要額外使用 Destination 和 Payload 兩個 class ，而不是僅僅是 messenger 上的不同 method。&lt;/p&gt;
&lt;p&gt;結束了嗎？no no no，data class 魔人哪有這麼遜，讓我們再繼續看下去…&lt;/p&gt;
&lt;h3&gt;Design smell: Too many parameters&lt;/h3&gt;
&lt;p&gt;從歷史的需求變更來看，這個 method 怎麼一直橫向發展啊，意即選項越來越多，parameters 越來越長。寫程式久的人自然而然的可以聞到一股 &lt;em&gt;臭味&lt;/em&gt; 了。要改善這個問題就是套用 Parameter Object refactor:&lt;/p&gt;
&lt;p&gt;第一種解:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;class Messenger {
  data class Option(
     val soundFile:String? = null,
     val showNotification:Boolean = false,
     val secret:Boolean = false
  )
  fun send(destination:Destination, 
           payload:Payload,
           option:Option)
}
//example usage:
messenger.send(Receiver(13L), 
               Text(&amp;quot;hello world&amp;quot;),
               Option(secret = true))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我們將額外的選項抽到單一 data class 去了，未來如果 messenger 又要擴充功能，那就加到 &lt;code&gt;Option&lt;/code&gt; 裡面的欄位，這樣 &lt;code&gt;send()&lt;/code&gt; 的 signature 就不會一直改來改去，也不會有太長參數的臭味。&lt;/p&gt;
&lt;p&gt;第二種解:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-scala&quot;&gt;class Messenger {
  data class Event(
     val destination:Destination, 
     val payload:Payload,
     val soundFile:String? = null,
     val showNotification:Boolean = false,
     val secret:Boolean = false
  )
  fun send(event:Event)
}

// example usage:
messenger.send(Event(
                  Receiver(13L), 
                  Text(&amp;quot;hello world&amp;quot;),
                  secret = true)
              )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第二種解法是整團 parameter 全抽到一個 Event 的 data class 去了。這比第一種解更極端一點。&lt;/p&gt;
&lt;p&gt;問題來啦，如果是你，你會選哪個？&lt;/p&gt;
&lt;h3&gt;data class give you more options&lt;/h3&gt;
&lt;p&gt;先不論要選哪個，從這個 messenger 的 API 套用了 data class 後，它的特徵和演化都不一樣了。這在原 Java 界是不太可能發生的，因為 Java 裡要寫 union type、要寫 data class 太繁鎖了，大部份的 Java 開發者都是選擇用 method overload 來解決吧。&lt;/p&gt;
&lt;p&gt;在 kotlin 裡，你多了很多可能性，因為開 data class 太便宜。&lt;/p&gt;
&lt;p&gt;上面兩種解法其實各有優點，第一種 Option class 是不錯的解法，API 很清楚很容易了解。而第二種，你注意到了嗎，它是 Command pattern，如果 API refactor 成 command pattern 後續就會享受它的好處 (例如可以送不同 event 了，或是 API 的用戶方便用 event-driven 的方式運用你的 API)。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Kotlin data class 不只是在 DTO/Entity 上有用處，它會開始影響整個程式碼的 API 設計和風格&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;小結&lt;/h3&gt;
&lt;p&gt;本文討論了 Kotlin 的 data class 在實務上運用時，產生的優點和一些問題。這些內容是我寫了一兩個月開始真正碰到的。一開始導入 data class 就是 DTO/Entity 用的很爽，用太爽接下來我就魔人化了，到處狂用 data class。而那個 Messenger 其實是我寫 Firebase Cloud Message 時遇到的設計考量。&lt;/p&gt;
&lt;p&gt;在 API 導入 data class，最大的目標還是提供 compile 時期的保護。上述的 &lt;code&gt;Destination&lt;/code&gt; 或是 &lt;code&gt;Payload&lt;/code&gt; 有型別保護，使用者不能亂傳任何資料，亂傳就是 compile 不過，而不是等到 runtime 才爆掉。&lt;/p&gt;
&lt;h3&gt;考試&lt;/h3&gt;
&lt;p&gt;上面的 Messenger 的例子裡，最後的兩個解都不錯，不過呢… 如果 secret 這個選項，只有在單一人收訊息才有意義時怎麼辦？(你想，訊息會廣播到主題內的所有人，其實這設為私密訊息一點意義也沒有吧？通常是送給個人才有意義，送給 Receiver(id))。&lt;/p&gt;
&lt;p&gt;這個需求一來，option 裡面放secret 欄位就不大對了，如果有人送廣播訊息但卻設了 secret=true 那就是 runtime 時期噴 exception，沒辦法 compile 時期就先抓到這個 bug。&lt;/p&gt;
&lt;p&gt;此題怎解？&lt;/p&gt;
&lt;p&gt;在 &lt;a href=&quot;http://kaif.io/d/yeOMO050jl&quot;&gt;kaif.io&lt;/a&gt; 討論這篇文章&lt;/p&gt;
</description>
    </item>
    <item>
      <title>Migrate to Ubuntu 16.04</title>
      <link>https://ingramchen.dev/blog/2016/06/migrate-to-ubuntu-1604.html</link>
      <pubDate>Sun, 19 Jun 2016 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2016/06/migrate-to-ubuntu-1604.html</guid>
      <description>&lt;p&gt;Ubuntu 16.04 (xenial) 已經推出兩個月了，不過到了這週才有機會嘗試著從 14.04 升到 16.04。原因是卡在 Vagrant 遲遲不支援新的 ubuntu cloud image。現在 DevOps 的工作流程多半是先在本機端嘗試用 Vagrant 配置新的 OS，等全部驗證過後，才會開始在 cloud 上實行。某方面來說，Vagrant 變成了一個工作流程上的新的瓶頸，它如果不支援，你就無法進行下一步。&lt;/p&gt;
&lt;p&gt;所幸，Vagrant 1.8.3 終於發行了，正好 16.04 的毛邊也修的差不多了吧，該來實際試試，從 14.04 LTS 轉移到 16.04 LTS。&lt;/p&gt;
&lt;h3&gt;Ubuntu cloud image for vagrant 的變更&lt;/h3&gt;
&lt;p&gt;先從 &lt;code&gt;Vagrantfile&lt;/code&gt; 開始，配置 Ubuntu cloud image:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Vagrant.configure(2) do |config|
  # omit...

  config.vm.box = &amp;quot;ubuntu-16.04&amp;quot;
  config.vm.box_url = &amp;quot;https://cloud-images.ubuntu.com/xenial/current/xenial-server-cloudimg-amd64-vagrant.box&amp;quot;

  # omit...
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Vagrant 本身也有做 xenial 的 box，但因為我們的目標是配置到 cloud ，所以會希望本機的測試環境越像 cloud 裡的越好。所以我們這裡使用 Ubuntu 本家自己做的 cloud image。注意，16.04 的 cloud image for vagrant 和 14.04 的 &lt;em&gt;不一樣&lt;/em&gt;。在 14.04 的時代，vagrant up 之後，預設帳號是 &lt;code&gt;vagrant&lt;/code&gt;，你也有 &lt;code&gt;/vagrant&lt;/code&gt; 這個共享路徑可用。這一次的 cloud image 加了 Cloud-init，預設帳號也變成 &lt;code&gt;ubuntu&lt;/code&gt;，然後 &lt;code&gt;/vagrant&lt;/code&gt; 目錄也不見了。如果你的配置流程裡依賴這兩者，那就要跟著改變。我們整理一下 16.04 cloud image 的變更：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;預設帳號變成 ubuntu&lt;/li&gt;
&lt;li&gt;套用 Cloud-int&lt;/li&gt;
&lt;li&gt;移除 &lt;code&gt;/vagrant&lt;/code&gt; 目錄&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Ansible 動不了&lt;/h3&gt;
&lt;p&gt;好不容易 &lt;code&gt;vagrant up&lt;/code&gt; 啟動了 VM，下一步就是開始配置主機上的軟體設定，我們是選用 Ansible 這套 python 的工具來進行配置。不過，很不幸的，Ubuntu 16.04 預設只安裝 python3 ，所以第一步 Ansible 就跑不起來。在 Ansible 支援之前，我們必須先安裝回一些必要的套件，在你的 playbook 第一行裡，要加上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- hosts: all
  gather_facts: false
  tasks:
    - name: ensure python 2, aptitude installed for ansible
      raw: apt install python aptitude -y -q
      become: true

- hosts: foo
  # rest of other roles, tasks...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;這個前置作業安裝了 python2 和 aptitude。除了 python2 之外 Ansible 的 &lt;code&gt;apt&lt;/code&gt; 模組也需要 &lt;code&gt;aptitude&lt;/code&gt; 的 debian 套件，而這個在 16.04 裡被移掉了，所以我們也要裝回去。16.04 的變更整理如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;預設只安裝 python3.5，沒有 python2&lt;/li&gt;
&lt;li&gt;更新至新的 &lt;code&gt;apt&lt;/code&gt; 指令，不再依賴 &lt;code&gt;aptitude&lt;/code&gt; (&lt;a href=&quot;https://www.maketecheasier.com/apt-vs-apt-get-ubuntu/&quot;&gt;more detail&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;nginx 支援 http2&lt;/h3&gt;
&lt;p&gt;預設的 Ubuntu repository 裡的 nginx 已經升到 1.10.0 了，這是相當新的版本，原生支援 http2。你只需要在原本的 nginx ssl 設定檔上，追加 &lt;code&gt;http2&lt;/code&gt; 的 flag 即可。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {
  server_name .kaif.io;
  listen 443 ssl http2;  # &amp;lt;-- http2 here!

  # omit...
}   
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;支援 http2 是升級到 Ubuntu 16.04 很大的動機。原因是前一版 14.04 裡 openssl 太舊，不支援 ALPN 協定，Chrome 瀏覽器又放棄了 NPN 的舊協定，&lt;a href=&quot;https://ma.ttias.be/day-google-chrome-disables-http2-nearly-everyone-may-31st-2016/&quot;&gt;詳細&lt;/a&gt;。你要嘛自己編譯 nginx ，要嘛就是升級到 16.04 才能解決。選哪個方法就看各公司的政策，升了 OS 問題就直接解決了。&lt;/p&gt;
&lt;p&gt;anyway, 如果你不是用 Ubuntu 原生的 repository 裡的 nginx，而是用傾向 nginx 的 repo。記得將 &lt;code&gt;trusty&lt;/code&gt; 字串換成 &lt;code&gt;xenial&lt;/code&gt;，以下是 Ansible 的指令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Install nginx stable repository
  apt_repository: repo=&#39;deb http://nginx.org/packages/ubuntu/ xenial nginx&#39; state=present
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;MySQL 5.5 升到 5.7&lt;/h3&gt;
&lt;h4&gt;MySQL strict mode&lt;/h4&gt;
&lt;p&gt;這個改超大！MySQL 5.7 最大的改變是預設開啟 strict 模式 (&lt;a href=&quot;http://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sql-mode-changes&quot;&gt;詳細文件&lt;/a&gt;)。我在升級時，就踩到 strict 模式裡的 &lt;code&gt;ONLY_FULL_GROUP_BY&lt;/code&gt; 的問題，以前像是下面的 SQL 是可以用的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sql&quot;&gt;select distinct(topic) 
  from forum 
 order by id desc 
 limit 10; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 &lt;code&gt;ONLY_FULL_GROUP_BY&lt;/code&gt; 模式下，order by 的項目也必須出現在 select 裡才行。這不是 MySQL 的錯，而是 ansi 就是這樣規定，只是以前我們可以在 MySQL 裡亂寫… 。這個問題視應用程式而定，運氣好的話，只有幾個 SQL 需要改寫，運氣不好的話 (比方說沒有 test)，那只好開大絕先關閉 strict 模式，在你的 &lt;code&gt;my.cnf&lt;/code&gt; 設定檔裡加上 &lt;code&gt;sql_mode=&amp;quot;&amp;quot;&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[mysqld]
sql_mode = &amp;quot;&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我個人當然不建議各位這樣做了，MySQL 過去就是因為鬆散的檢查機制才會讓大家垢病這麼久，好不容易開了 strict 模式回到正軌，又把它關掉那就是走回老路了。建議的作法是先不要急著升級 OS 了，然後將 strict mode 一個個打開，看看哪邊壞了就修正，你很有可能會發現過去潛藏已久的 bug 也說不定。全部搞定後，再進行升級。&lt;/p&gt;
&lt;p&gt;除了 &lt;code&gt;ONLY_FULL_GROUP_BY&lt;/code&gt; 之外，我個人的專案裡還有踩到 timestamp 預設值的問題。過去可以這樣寫：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sql&quot;&gt;CREATE TABLE forum (
  id          BIGINT PRIMARY KEY,
  create_date TIMESTAMP NOT NULL DEFAULT 0  /* 錯誤 */
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;timestamp 預設值其實最小是 &lt;code&gt;1970-01-01&lt;/code&gt; ，&lt;code&gt;0000-00-00&lt;/code&gt; 的話是不合理的數值，所以修改成 &lt;code&gt;1970-01-02&lt;/code&gt; 即可。&lt;/p&gt;
&lt;h4&gt;MySQL 目錄結構變更&lt;/h4&gt;
&lt;p&gt;原本在 14.04 裡，MySQL 的相關檔案只放在 &lt;code&gt;/var/lib/mysql&lt;/code&gt; 裡。而我們如果佈署到 cloud 時，通常會配置另外的硬碟，掛在 &lt;code&gt;/mnt/&lt;/code&gt; 或是 &lt;code&gt;/volume/&lt;/code&gt; 下，然後將 MySQL 的資料檔搬到新的目錄，類似這樣的步驟：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sh&quot;&gt;# 這是 pseudo 的步驟：
mkdir /mnt/lib
mv /var/lib/mysql /mnt/lib
ln -s /mnt/lib/mysql /var/lib/mysql

# 最後再修改 /etc/apparmor.d/usr.sbin.mysqld 裡的權限
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原本只要搬一個目錄，在 16.04 裡，變成了四個目錄：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/var/lib/mysql
/var/lib/mysql-files
/var/lib/mysql-keyring
/var/lib/mysql-upgrade
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;這四個我都跟著搬了，整個完整的步驟可&lt;a href=&quot;https://blogs.msdn.microsoft.com/azureossds/2016/04/25/migrating-mysql-database-from-os-disk-to-data-disk-on-azure-linux-vm/&quot;&gt;參考這裡&lt;/a&gt;。另外，與 14.04 不同的是，改完 apparmor 權限後先要重啟一次，才能重開 mysql service，不然不會生效。&lt;/p&gt;
&lt;h3&gt;Postgresql 9.3 升到 9.5&lt;/h3&gt;
&lt;p&gt;相對於 MySQL，Postgresql 就好升很多，&lt;code&gt;postgresql.conf&lt;/code&gt; 設定檔改幾行就行了。我遇到的是 &lt;code&gt;checkpoint_segments&lt;/code&gt; 這個參數被拿掉了，換成用 &lt;code&gt;max_wal_size&lt;/code&gt; 取代，你可以在&lt;a href=&quot;https://www.postgresql.org/docs/9.5/static/release-9-5.html&quot;&gt;手冊&lt;/a&gt; 上查到怎麼調整。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;postgresql.conf&lt;/code&gt; 兩個版本間的差異很小，你可以參考我的 &lt;a href=&quot;https://github.com/kaif-open/kaif/commit/a918a68492e51425cfffaec962e326b10a0c7139?diff=split#diff-4b77bb172af84ede9da192f29a683161L27&quot;&gt;merge commit&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;MySQL 的升級是減少錯誤，Postgresql 的升級則是增加功能，苦等以久的 &lt;em&gt;&lt;a href=&quot;https://wiki.postgresql.org/wiki/UPSERT&quot;&gt;upsert&lt;/a&gt;&lt;/em&gt; 終於在 9.5 上可以使用了。原本在過去你只能用類似 hack 的方式：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sql&quot;&gt;  WITH Upsert 
    AS (
            UPDATE Foo 
               SET voteCount = voteCount + 1 
             WHERE id = 33 
         RETURNING * 
       ) 
INSERT 
  INTO Foo 
       (id, voteCount) 
SELECT 33, 0 
 WHERE NOT EXISTS (SELECT * FROM Upsert) ;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;現在改成&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sql&quot;&gt;      INSERT 
        INTO Foo 
             (id, voteCount) 
      VALUES (33, 0) 
 ON CONFLICT (id) 
   DO UPDATE 
         SET voteCount = Foo.voteCount + 1 ;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直覺很多，而且這個 upsert 還是 atomic/transactional safe，爽！Postgresql 9.5 還新增很多 &lt;a href=&quot;http://michael.otacoo.com/postgresql-2/postgres-9-5-feature-highlight-new-jsonb-functions/&quot;&gt;json 的功能&lt;/a&gt; ，這個版本是個很值得的升級。&lt;/p&gt;
&lt;h3&gt;那怎麼升級主機… ？&lt;/h3&gt;
&lt;p&gt;Ubuntu 本身是有指令可以讓你直接從升級 14.04 -&amp;gt; 16.04。但線上服務中的雲端主機我們通常不這樣升，一來是直接升級的指令只會帶來更多問題 (你之前修改的參數在不知不覺中被覆蓋掉)，二來，一旦扯到資料庫的變動，就需要有計畫性的慢慢移，而且要規畫停機時間。&lt;/p&gt;
&lt;p&gt;我建議是佈署一台新的 16.04 主機，先備份舊的，然後再將服務和資料遷移過去，這樣反而乾淨很多，而且下線時間可以縮到最短。如果過去沒有實行自動化佈署的話，這也是個重頭開始的好機會。&lt;/p&gt;
&lt;p&gt;如果不是雲端主機，而是實體伺服器。如果是我就暫時先不升了，14.04 LTS 繼續撐著用。不過應用程式面到是可以先準備好，在測試環境 (vagrant) 裡先試試有什麼問題，提早做好準備。實體主機總是會有遷移的一天 (硬體壞掉，公司換政策… )，等到那一天再一併進行升級的作業。&lt;/p&gt;
&lt;h3&gt;小結&lt;/h3&gt;
&lt;p&gt;Ubuntu 已經吃下近 60% 的雲端 OS 的市場了，過去我們有很長的時間是使用 Amazon Linux (CentOS based)，現在通通轉到 Ubuntu 上了。14.04 LTS 我們使用上很滿意，正常的服役中，而網路上的資料也多到滿出來，不怕找不到答案，這真是正確的選擇 (大家都同意的)。&lt;/p&gt;
&lt;p&gt;對於 16.04，我們則是先完成評估再慢慢的正式上線，靠著 Vagrant 加上 Ubuntu cloud image，模擬線上的環境來測試。Vagrant 很方便，但最近它的釋出紀錄似乎不太好 (上一次是 2015 年底，最近一次是 2016/06，差了半年)，它的母公司似乎不再愛它了？短時間內這不會是問題，因為 OS 升級的週期更長，但看到 Vagrant 變成導入新流程的瓶頸總是不太妙… 希望未來能看到改進。&lt;/p&gt;
&lt;p&gt;Ubuntu 16.04 最大的改變是 systemd ，不過我沒有討論，因為我還搞不懂它在幹嘛… 所幸的是所有舊有的 service 都能運作正常，因此它不是升級的瓶頸，可以放到之後再處理。反到是應用程式面需要調整，本文列出了我升 MySQL/Postgresql/nginx 時實際遇到的問題和新功能的使用，我自己的小專案有完整的 &lt;a href=&quot;https://github.com/kaif-open/kaif/tree/master/kaif-deploy&quot;&gt;ansible playbook&lt;/a&gt; (&lt;a href=&quot;http://kaif.io&quot;&gt;kaif.io&lt;/a&gt;) 給各位參考。&lt;/p&gt;
&lt;p&gt;我們需要等 OS 升級，才能開始享用新版軟體帶來的新功能嗎？當然不是，我們也能在舊版 OS 安裝最新的 Postgresql/nginx...etc ，只是我個人不偏好這樣做罷了。改版底層的軟體茲事體大，Ubuntu LTS 的釋出週期是 2 年，所以我評估的週期也變成兩年一次，這個步調對我個人來說剛剛好。&lt;/p&gt;
</description>
    </item>
    <item>
      <title>thumbor tutorial</title>
      <link>https://ingramchen.dev/blog/2015/12/thumbor-tutorial.html</link>
      <pubDate>Sun, 06 Dec 2015 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2015/12/thumbor-tutorial.html</guid>
      <description>&lt;p&gt;今天跟大家介紹一個好用的縮圖伺服器 &lt;a href=&quot;https://github.com/thumbor/thumbor&quot; title=&quot;thumbor&quot;&gt;thumbor&lt;/a&gt; ，我們已經在線上用了一陣子，還真是方便的服務，我想即使現在用不著，也應當加入你的工具箱裡。&lt;/p&gt;
&lt;p&gt;thumbor 是一個 python based 的 server，原理大致上是你有一個圖源的伺服器，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://example.com/foo/my-image.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只要將圖源的網址經過 thumbor 後，就能得到任意裁切大小的新圖&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://my-thumbor.foo.com/200x100/http://example.com/foo/my-image.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;my-thumbor.foo.com&lt;/code&gt; 是 thumbor 的網址，後面接上 &lt;code&gt;200x100&lt;/code&gt; 的路徑，最後再加上圖源網址，這樣回傳的圖檔就是 200x100 大小的新圖了。&lt;/p&gt;
&lt;p&gt;ok，就是這麼簡單的概念，看到這裡你也能想像 thumbor 的最大用途是什麼了：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;thumbor 能夠即時的產生任意大小的縮圖，適合不同的使用情境&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;縮圖 (thumbnail) 這種概念已經很久了，用戶上傳一張照片，讓伺服器裁切成幾份小圖也是工程師常做的任務。幾年前還好，大概只要裁一兩份小圖就夠網頁用了，但現在有各種不同尺吋的手機，還有平板，而桌機也有了 hi-dpi 的需求。瞬時一兩張小圖不夠用了，而且手機還是用 3/4G 網路，裁了太大的圖用戶就是等等等.... 等個沒完沒了，而且也吃掉用戶網路的用量 (現在 4g 沒吃到飽了)。&lt;/p&gt;
&lt;p&gt;這些新的需求浮現，所以像 thumbor 這樣專屬處理縮圖的應用就變得很重要了。透過 thumbor，我們可以滿足像是下列的情境：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;當用戶是用 4&amp;quot; 小手機，就餵給他 100x200 的小圖&lt;/li&gt;
&lt;li&gt;當用戶是 6&amp;quot; 高檔 iPhone，就給他高解析 200x400&lt;/li&gt;
&lt;li&gt;當用戶用的是橫著放的平版，就給他 200x100 不同長寬比例的圖&lt;/li&gt;
&lt;li&gt;… 等等。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;安裝 thumbor&lt;/h3&gt;
&lt;p&gt;下面將介紹如何安裝 thumbor 伺服器到虛擬機裡 (vagrant)，請預先安裝好 &lt;a href=&quot;https://www.vagrantup.com/downloads.html&quot;&gt;vagrant&lt;/a&gt;、&lt;a href=&quot;https://www.virtualbox.org/wiki/Downloads&quot;&gt;virtualbox&lt;/a&gt;、以及部署工具 &lt;a href=&quot;http://docs.ansible.com/ansible/intro_installation.html&quot;&gt;ansible&lt;/a&gt; 。詳細的安裝步驟就不提了，前面的連結都有詳細的安裝法。&lt;/p&gt;
&lt;h4&gt;1. 啟動新的 VM (ubuntu 14.04)&lt;/h4&gt;
&lt;p&gt;開啟個目錄 &lt;code&gt;thumbor-tutorial&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sh&quot;&gt;$ mkdir thumbor-tutorial
$ cd thumbor-tutorial
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;建立 vagrant 的設定檔 &lt;code&gt;Vagrantfile&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-ruby&quot;&gt;Vagrant.configure(2) do |config|
  config.vm.provider &amp;quot;virtualbox&amp;quot; do |v|
    v.memory = 512 
  end
  config.vm.box = &amp;quot;ubuntu-14.04&amp;quot;
  config.vm.box_url = &amp;quot;https://cloud-images.ubuntu.com/vagrant/trusty/current/trusty-server-cloudimg-amd64-vagrant-disk1.box&amp;quot;
  config.vm.define &amp;quot;thumbor&amp;quot; do |v|
    v.vm.network &amp;quot;private_network&amp;quot;, ip: &amp;quot;192.168.33.99&amp;quot;
  end
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;建立好後，啟動 vagrant vm&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sh&quot;&gt;$ vagrant up

# 等一陣子，沒錯誤的話就能 ssh 進 vm
$ vagrant ssh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;vagrant up 的指令按照 Vagrantfile 設定檔的指示，建立一個 512MB ram 的 linux VM，安裝 ubuntu 14.04，它的 ip 是 &lt;code&gt;192.168.33.99&lt;/code&gt;。&lt;/p&gt;
&lt;h4&gt;2. 設定 ansible 自動化安裝 thumbor&lt;/h4&gt;
&lt;p&gt;成功後，接下來就是要使用部署工具 &lt;code&gt;ansible&lt;/code&gt; 幫我們自動化安裝 thumbor&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sh&quot;&gt;# 先下載別人寫好的 thumbor 部署設定
$ ansible-galaxy -p roles install savagegus.thumbor

# 成功後會下載到 `roles` 這個目錄裡，會有兩個設定
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;ansible-galaxy 裡有許多別人貢獻、預先做好的安裝設定，很多常見的應用都已經有人做好了，thumbor 也不例外，這裡我們使用的是 &lt;a href=&quot;https://github.com/jivesoftware/ansible-thumbor&quot;&gt;savagegus.thumbor&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;接下來是指定 ansible 部署 thumbor 到 vagrant 的 VM 裡。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先新增 &lt;code&gt;inventory&lt;/code&gt; 檔，裡面是放要安裝的主機設定，這裡是指向 剛剛建立的 vagrant vm &lt;code&gt;192.168.33.99&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;thumbor ansible_ssh_host=192.168.33.99

## 指定所以設定的主機都使用 vagrant 這 user、及其 private ssh key
[all:vars]
ansible_ssh_user=vagrant
ansible_ssh_private_key_file=&#39;.vagrant/machines/thumbor/virtualbox/private_key&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意：此設定檔後半段關於 user/key 的設定只限於 vagrant 使用，正式上線時不需要。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;再來建立安裝 thumbor 的設定檔 &lt;code&gt;site.yml&lt;/code&gt; (在 ansible 的術語叫 playbook)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-yml&quot;&gt;- hosts: thumbor 
  roles:
    - role: savagegus.thumbor
      sudo: True
      thumbor_results_storage: thumbor.result_storages.file_storage
      thumbor_bind_address: &#39;0.0.0.0&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安裝設定本身很短，因為大部份都交給剛才下載的 &lt;code&gt;savagegus.thumbor&lt;/code&gt;  處理好了。不過，原本的設定只能從 localhost 連進 thumbor 伺服器 (按設定檔它希望你經過 nginx)，這裡我們覆蓋掉預設值，讓所有網址都能直接連進 thumbr 伺服器，方便測試。&lt;/p&gt;
&lt;h4&gt;部署 thumbor&lt;/h4&gt;
&lt;p&gt;呼~ 要開始正式部署了，一個 ansible 指令搞定：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sh&quot;&gt;$ ansible-playbook -i inventory site.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果一切正常無誤的話，你會看到 ansible 一步步下載、安裝、設定 thumbor 所有需要的套作。&lt;code&gt;savagegus.thumbor&lt;/code&gt; 這個 role 也包含了安裝 nginx 伺服器，所以你也會看到 nginx 的安裝過程。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;thumbor 是個 python 套件，理論上只要 &lt;code&gt;pip install thumbor&lt;/code&gt; 就應該能裝完，但是我們要部署的是一個伺服器，伺服器總是要設許多權限、log 檔位置、啟動 script… 等等諸多繁雜的工作，這些工作就是讓 ansible 來做了，有興趣可以看看 &lt;code&gt;roles/savagegus.thumbor/tasks/main.yml&lt;/code&gt; 就知道部署 thumbor 的所有細節&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;安裝完後，可以在瀏覽器測試 thumbor 是否正常運作：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 正常的話會看到 `WORKING` 的結果
http://192.168.33.99:8888/healthcheck

# 正常的話會看到一張 200x200 的圖
http://192.168.33.99:8888/unsafe/http://dummyimage.com/200x200.png

# 注意上述的測試都是跳過 nginx，直接測 thumbor 本身 (在 port 8888)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果有任何問題的話，可以用 &lt;code&gt;vagrant ssh&lt;/code&gt; 連進 VM，查看 &lt;code&gt;/var/log/upstart/thumbor-worker-8888.log&lt;/code&gt; 錯誤訊息&lt;/p&gt;
&lt;h3&gt;使用 thumbor&lt;/h3&gt;
&lt;p&gt;讓我們來試試幾個 thumbor 的功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;範例的源圖網址:&lt;br&gt;
&lt;a href=&quot;https://http.cat/100.jpg&quot;&gt;https://http.cat/100.jpg&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;crop 成 200x300 圖片：&lt;br&gt;
&lt;code&gt;http://192.168.33.99:8888/unsafe/200x300/https://http.cat/100.jpg&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;轉成 png：&lt;br&gt;
&lt;code&gt;http://192.168.33.99:8888/unsafe/filters:format(png)/https://http.cat/100.jpg&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;圓角 + 灰階：(產生的網址太長，下面多加了斷行)&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;  http://192.168.33.99:8888/unsafe/
         filters:round_corner(40,255,255,255):grayscale()/
         https://http.cat/100.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;thumbor 的基本功能是縮圖與裁切，你也看到了在網址上加上 &lt;code&gt;/200x300/&lt;/code&gt; 這種大小就能很簡單做到，但 thumbor 還有強大的濾鏡功能，上面就展示了轉換格式，以及裁圓角與加上灰階等等進階功能，而且能任意組合，你可以在 thumbor 的 wiki 上查到更多的&lt;a href=&quot;https://github.com/thumbor/thumbor/wiki/Filters&quot;&gt;濾鏡說明&lt;/a&gt; 。濾鏡的語法就是在路徑上加上 &lt;code&gt;/filters:foo:bar/&lt;/code&gt; 而已，非常簡單。&lt;/p&gt;
&lt;h3&gt;secure thumbor&lt;/h3&gt;
&lt;p&gt;眼尖的你可能發現了，上面的測試網址都帶有 &lt;code&gt;/unsafe/&lt;/code&gt; 的路徑，這在 thumbor 裡是指網址不需經過驗證，能直接轉圖。這在測試環境下很方便，不過正式上線的話，網址沒驗證就很容易被別人惡用 (thumbor 是個任意轉圖、快取的伺服器，沒驗的話別人可以偷接)。&lt;/p&gt;
&lt;p&gt;thumbor 提供兩個方式驗證：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;第一個是限定圖源網址，比方說你只限定上游的圖源必須是你自家的網站，這樣就能保證不能偷接。我們可以修改 thumbor 的設定檔&lt;/p&gt;
&lt;p&gt;&lt;code&gt;roles/savagegus.thumbor/templates/thumbor.conf.j2&lt;/code&gt;:&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-yml&quot;&gt;## ...etc

ALLOWED_SOURCES = [
  &#39;s3.amazonaws.com&#39;,
  &#39;http.cat&#39;
]

## ...etc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;預設值是不限任何網站，而上面的範例我們修改成限定圖源網站必須是 aws s3 與 http.cat。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;第二個是直接驗證網址本身，我們需要設定一個私有鍵，並且關閉 /unsafe/ 的功能，我們可以在&lt;/p&gt;
&lt;p&gt;&lt;code&gt;roles/savagegus.thumbor/templates/thumbor.conf.j2&lt;/code&gt; 裡，關閉 unsafe 的功能：&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-yml&quot;&gt;## ...etc

ALLOW_UNSAFE_URL = False 

## ...etc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;私有鍵的設定檔則放在 &lt;code&gt;roles/savagegus.thumbor/templates/thumbor.key.j2&lt;/code&gt; 裡，長度不居。下面的值僅供本文範例使用，請不要直接抄用在你的正式主機上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Vzt9X7LHbcyGsaAzVLV54rfuEpEQWspbynaRZeq9+WCBfC7iFAiSThYHI79zwKt
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;建議：你可以用 &lt;code&gt;openssl rand -base64 48&lt;/code&gt; 指令產生高強度的任意字串，這裡的 48 是 byte 長度&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;好了，ansible 設定檔改完之後，就是再部署一次進 vagrant vm：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sh&quot;&gt;$ ansible-playbook -i inventory site.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一鍵搞定部署！爽！而新的 thumbor.key 則會安裝到 &lt;code&gt;/etc/thumbor.key&lt;/code&gt; 路徑&lt;/p&gt;
&lt;p&gt;部署完畢之後，剛才上述範例的那些 &lt;code&gt;/unsafe/&lt;/code&gt; 網址不能再使用了，用了 thumbor 只會吐 status 400 bad request 給你。你必須使用加上驗證碼的網址，像是剛才的 crop 200x300，就會變成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://192.168.33.99:8888/lNmQrS_bz-er8L6nIO1qFPgGOm4=/
       200x300/https://http.cat/100.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;unsafe 那段需要填入經 &lt;a href=&quot;https://en.wikipedia.org/wiki/Hash-based_message_authentication_code&quot;&gt;HMAC&lt;/a&gt; 算過的驗證碼。如此一來，沒有私有鍵的人便無法任意產生合法的 thumbor 網址。&lt;/p&gt;
&lt;h3&gt;thumbor-url 與 API&lt;/h3&gt;
&lt;p&gt;前述的驗證碼需要經過一定手續計算而得，為了方便測試，thumbor 有提供 command line 工具 &lt;code&gt;thumbor-url&lt;/code&gt; 可以產生驗證網址。因為我們的 thumbor 裝在 vagrant 裡，所以要進入 vagrant vm 內去執行&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sh&quot;&gt;#進入 vm
$ vagrant ssh   

#進入後，執行 thumbor-url 產生網址路徑
$ thumbor-url -l /etc/thumbor.key /200x300/https://http.cat/100.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果一切正常的話，terminal 會輸出&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;URL:
/lNmQrS_bz-er8L6nIO1qFPgGOm4=/200x300/https://http.cat/100.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只要將該路徑前面冠上 thumbor 的主機就是個合法的 thumbor 網址。&lt;/p&gt;
&lt;p&gt;command line 工具只限測試使用，正式在 app 裡運用時，必須透過 API 的幫忙，不然自己算 HMAC 太累了。thumbor 網站列出了&lt;a href=&quot;https://github.com/thumbor/thumbor/wiki/Libraries&quot;&gt;很多平台的函式庫&lt;/a&gt; ，例如 android /java 用的 &lt;a href=&quot;http://square.github.io/pollexor/&quot;&gt;Pollexor&lt;/a&gt;，使用上就非常簡潔：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;Thumbor.create(&amp;quot;http://192.168.33.99:8888/&amp;quot;, 
               &amp;quot;... thumbor private key...&amp;quot;)
       .buildImage(&amp;quot;https://http.cat/100.jpg&amp;quot;)
       .crop(10, 10, 90, 90)
       .resize(40, 40)
       .smart()
       .toUrl();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pollexor 採用 fluent API 設計概念，裁剪及套用濾鏡都相當的直覺好用。&lt;/p&gt;
&lt;h3&gt;smart thumbor&lt;/h3&gt;
&lt;p&gt;thumbor 的縮圖最強的地方是它有智慧的功能。一般在裁剪圖時，大小不符合就是剪裁圖的正中央，而 thumbor 內建進階的圖形辨識功能，可以偵測人臉的位置或是影像的核心部位。要開啟這個功能，我們要替 thumbor 安裝 opencv 函式庫&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在  &lt;code&gt;roles/savagegus.thumbor/tasks/main.yml&lt;/code&gt; 加上 opencv 的安裝&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-yml&quot;&gt;- name: install pgmagick deps
  ...

# 加上 opencv 的安裝  
- name: install opencv deps
  apt: pkg={{item}}
  with_items:
    - libcurl4-openssl-dev
    - libopencv-dev
    - libmagick++-dev
    - graphicsmagick
    - python-opencv

- name: install pgmagick
  ....
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;然後，到 &lt;code&gt;roles/savagegus.thumbor/templates/thumbor.conf.j2&lt;/code&gt; 裡打開人臉辨識與重點偵測的功能&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-yml&quot;&gt;DETECTORS = [
      &#39;thumbor.detectors.face_detector&#39;,
      &#39;thumbor.detectors.feature_detector&#39;
    ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;設定好後，一鍵完成部署：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-sh&quot;&gt;$ ansible-playbook -i inventory site.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署完後，就可以在裁切圖時加上 &lt;code&gt;/smart/&lt;/code&gt; 這個功能，正確裁剪到人臉的位置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;圖源 &lt;a href=&quot;https://http.cat/405.jpg&quot;&gt;https://http.cat/405.jpg&lt;/a&gt;  (750x600)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/img/2015/12/405_org.jpg&quot; alt=&quot;原圖&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;無 smart，裁切成 300x100 -&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/img/2015/12/405_no_smart.jpg&quot; alt=&quot;無 smart&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://192.168.33.99:8888/UsXPegr1vNw-sUO5wJKpq9Wy8FM=/
       300x100/https://http.cat/405.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;開啟 smart，同樣裁切成 300x100&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/img/2015/12/405_smart.jpg&quot; alt=&quot;開啟 smart&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://192.168.33.99:8888/HZBfomhL9aMALrBdHEhM_asJMs8=/
       300x100/smart/https://http.cat/405.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上面的簡單測試可以看出 smart 裁圖的中心移到了人臉上。有了智慧裁圖之後，運用就更自由了，例如在手機上，就能裁成直的圖，而在平板上，就能裁成橫的，都不會失去太多圖片的核心部位。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;注意，thumbor 產生過的網址會快取在磁碟一份，之後就不會再重新算一次，所以如果想看同網址但不同的結果的話，要清除 &lt;code&gt;/tmp/thumbor/result_storage&lt;/code&gt; 路徑&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;其他&lt;/h3&gt;
&lt;p&gt;這裡展示的步驟，只是可以安裝到 VM 裡測試而已，雖然 ansible 已經讓部署簡化許多，但要正式上線的還有許多設定需要調整，例如圖片快取的暫存路徑是設在 /tmp/ 下，這一定不夠放，需要額外的安排。另外圖片快取過期的時間你也要考慮一下，thumbor 是可以設定圖片的過期時間 (會反應在 http cache control header 上)，過期後再存取一次也會重新產生圖，不過過期的圖片卻不會自動刪除，所以跑久了磁碟很快就佔滿了，這也要自行處理。&lt;/p&gt;
&lt;p&gt;本文所展示的 thumbor 功能只是其中一小部份而已，例如 thumbor 本身也可以當圖源網站 (圖上傳到 thumbor 本身，後面可接資料庫)，這部份我本身沒有實務經驗，所以這裡我就不提了。更多的資料請見 &lt;a href=&quot;https://github.com/thumbor/thumbor&quot;&gt;thumbor github&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Real world thumbor&lt;/h3&gt;
&lt;p&gt;我已經在不同的服務上正式的使用 thumbor。兩個服務用途和目的相差很大。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;案例一：縮圖給手機 app 用&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;這個應用是用戶會上傳自己拍的圖，而且一次多張。當圖片是一張、二張、三張時，呈現的編排方式會不同，所以需要直裁切、橫裁切兩種變化，透過 thumbor 之後，就能即時產生不同方向的裁圖，達到最佳顯示效果和下載速度。而且，像是圖片的編排方式其實設計師常常會更動設計的，有了 thumbor 之後便能應付未來臨時變動的設計。這個應用裡，我們也開啟了 smart 功能，因為大部份的用戶圖片都是自拍，而 thumbor 框到臉的機率大概在 8 成上下，還不錯。&lt;/p&gt;
&lt;p&gt;這個服務的部署架構是這樣的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  AWS cloud front    (CDN)
        v
 EC2 load balancer   
        v
 3 * thumbor server  (ec2)
        v
      AWS S3         (圖源) 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最前面掛了 cloud front CDN，後面則是經過 load balancer，最後才到 thumbor，thumbor 雖然裝了三台，但因為前端有 CDN 擋著，所以流量很低，不需要裝到三台這麼多。只是一般  high availiabilty 的架構都是習慣以 3 台做為基本單位去部署的，所以我們才會運用到三台。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;案例二：最佳化 png 圖檔&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;另外一個服務是我個人的 pet project，用戶的使用習慣是上傳遊戲的截圖，所以大約有 40% 的圖片都是 png。png 圖檔超級大啊，截個圖隨便都是超過 500KB，不僅下載慢、頻寬也都吃光光了。&lt;/p&gt;
&lt;p&gt;為了應付這個問題，特別去找縮小 png 圖的工具，這個領域裡 &lt;a href=&quot;https://pngquant.org/&quot;&gt;pngquant&lt;/a&gt; 是最知名的工具了，它的作法是將 24bit png 轉成 8bit png ，然後用特別的演算法和 dither 讓圖片看起來幾乎一模一樣。經過這道手續，原本 600KB 的 png 大小就降成 1/3，200KB。這實在是差很多。&lt;/p&gt;
&lt;p&gt;我原本打算是在上傳圖片的過程中就先用 pngquant 處理好再上傳的 (Java app server)，可惜 pngquant 並沒有 java 版本，這表示要去寫 JNI 或是呼叫外部 process 去處理了，這寫起來會死人。後來往 thumbor 的方向去找，發現只要裝 &lt;a href=&quot;https://github.com/thumbor/thumbor-plugins&quot;&gt;thumbor-plugins&lt;/a&gt; 就行了，pngquant 的整合早已有人寫好。&lt;/p&gt;
&lt;p&gt;於是就改了一下 ansible 設定檔，加進 plugins，然後一鍵搞定部署，線上就用了起來。這個 pet project 的架構是這樣的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;     Cloudflare    (CDN)
         v
       nginx
         v
   thumbor server   
         v
       AWS S3      (圖源)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因為是 pet project，所以用了免費的 CDN cloudflare，上線之後，從 cloudflare 的統計資料來看，每日的流量從 ~270GB 下降到 ~200GB 左右，真是不錯的結果，當然用戶下載圖的時間也整整少了 2/3，很有感。而 pngquant 最佳化的圖雖然有失真，但其實不把兩張圖並在一起看根本就看不出差別，這結果很滿意啊。&lt;/p&gt;
&lt;h3&gt;結語&lt;/h3&gt;
&lt;p&gt;thumbor 是個好工具！加進自己的工具箱裡，未來遇到跟圖片有關的問題，自然就多了一個解決的選項。本篇的範例可在 &lt;a href=&quot;https://github.com/ingramchen/thumbor-tutorial&quot;&gt;github clone&lt;/a&gt; 下來自己玩，而 pngquant 的設定則是在另一個 &lt;a href=&quot;https://github.com/ingramchen/thumbor-tutorial/tree/pngquant&quot;&gt;branch&lt;/a&gt; 供各位參考。&lt;/p&gt;
</description>
    </item>
    <item>
      <title>你有幾張雲端帳單？</title>
      <link>https://ingramchen.dev/blog/2015/03/cloud_invoice.html</link>
      <pubDate>Mon, 09 Mar 2015 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2015/03/cloud_invoice.html</guid>
      <description>&lt;p&gt;這週是月初，很多帳單都是這段期間寄來。本來嘛，就是日常瑣事，也沒什麼在意的。&lt;/p&gt;
&lt;p&gt;不過這周收到了一張新 google cloud engine 的帳單&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/2015/03/gce.png&quot; alt=&quot;gce&quot;&gt;&lt;/p&gt;
&lt;p&gt;然 mail 往下滑，又看到另一張 AWS 帳單&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/2015/03/aws.png&quot; alt=&quot;aws&quot;&gt;&lt;/p&gt;
&lt;p&gt;這是小錢，不是什麼全世界都驚呆了的大事。&lt;/p&gt;
&lt;p&gt;不過我還是恍神了一下，蛤?&lt;/p&gt;
&lt;p&gt;一張帳單還沒什麼，兩張了就開始有感了。大部份的人每月都會收到水費電費，這是生活基本消費。進入網路時代了，就多了 ADSL 之類的網路費。而現在是 mobile 當紅，人手一支smartphone，也開始背著新的 3G/4G 帳單。&lt;/p&gt;
&lt;p&gt;接著我也開始有兩份 &lt;code&gt;雲端帳單&lt;/code&gt; 了，每月都要繳。&lt;/p&gt;
&lt;p&gt;這是個新趨勢嗎? 當然我是開發者，所以會背著這種真正雲端主機的費用。一般人應該是偏雲端服務為主，像是很多人會買 DropBox Pro ，月繳 310 元。如果是設計師的話，也許會訂購個 Flickr Pro，年繳 750 元，又有可能買了 Adobe Creative Cloud，每月繳個 960 元。你現在有幾張雲端帳單呢？&lt;/p&gt;
&lt;p&gt;形形色色的新雲端帳單開始進入生活，它們的價位控制在每月 1000 元以下，剛好落在痛與不痛之間，讓你付的起，又能讓它們賺最多錢。不過，每張都不痛，但統統加起來就不一樣了，一個人能負擔的起幾張這種帳單呢？&lt;/p&gt;
&lt;p&gt;也許未來的某一天月初，每個人都會收到個五張雲端帳單也說不定，但我猜屆時五張加起來大概在二、三千元左右吧。因為科技會進步，競爭也更會激烈，付費自然會更低廉。那時大家也見怪不怪，就跟現在要繳 3G 費用一樣吧。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://kaif.io/z/compiling/debates/bWJxzKvyqj&quot;&gt;在 kaif 裡討論這篇文章&lt;/a&gt;&lt;/p&gt;
</description>
    </item>
    <item>
      <title>kaif.io 初登場</title>
      <link>https://ingramchen.dev/blog/2015/02/introducing_kaif.html</link>
      <pubDate>Mon, 02 Mar 2015 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2015/02/introducing_kaif.html</guid>
      <description>&lt;p&gt;人生能有幾個 side project 呢？我不知道！不過我已經做到第三個了，這一次是和 &lt;a href=&quot;https://kaif.io/u/koji&quot;&gt;koji&lt;/a&gt; 合作的新網站服務 &lt;a href=&quot;https://kaif.io&quot;&gt;kaif.io&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/2015/02/kaif_logo.png&quot; alt=&quot;kaif logo&quot;&gt;&lt;br&gt;
圖說：kaif logo&lt;/p&gt;
&lt;p&gt;kaif 是怎麼樣的服務呢？它是個 &lt;a href=&quot;http://www.reddit.com&quot;&gt;reddit&lt;/a&gt; clone。這樣講大家就應該了解 kaif 能做什麼了吧？ 它就是貼網站連結，然後大伙按讚推文，就會產生熱門新聞榜的網站。一整個 web 2.0 old school：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/2015/02/kaif_home.png&quot; alt=&quot;kaif 靠眾人投票決定首頁新聞&quot;&gt;&lt;br&gt;
圖說：kaif 靠眾人投票決定首頁新聞&lt;/p&gt;
&lt;h3&gt;Why ?&lt;/h3&gt;
&lt;p&gt;我每天早上起床，手機打開，開始滑啊滑的。我最先滑的網站是 &lt;a href=&quot;https://news.ycombinator.com&quot;&gt;Hacker News&lt;/a&gt;。然後接著滑 &lt;a href=&quot;http://reddit.com/r/programming&quot;&gt;reddit programming&lt;/a&gt; ，最後才滑 twitter 這類的社群網路。它們是我的營養早餐，只不過都是西式的。西式吃多了，你就會想吃中式的，尤其是身在台灣。&lt;/p&gt;
&lt;h3&gt;那麼台灣的 hacker news/reddit 在哪？&lt;/h3&gt;
&lt;p&gt;沒有！過去曾經有過 hemidemi，也有推推王，但都倒光了！即使現在還存活著，走向也不是我想要的。我知道現在大家都在哪討論 -- 都在 Facebook 裡。&lt;/p&gt;
&lt;h3&gt;Facebook 不是公眾的論譠，是小圈圈&lt;/h3&gt;
&lt;p&gt;Facebook 就是在自己的社交圈內，聊大小事。好像很公開，但其實內容沒幾個人看見，也沒辦法凝聚各界的討論。然後更慘的是 Facebook 鎖國，基本上你在 Facebook 上發表的言論，用 google/yahoo/bing 什麼的通通查不到。台灣的交流就通通鎖在 Facebook 裡，馬的個王八蛋！&lt;/p&gt;
&lt;h3&gt;kaifa &amp;amp; kaifang&lt;/h3&gt;
&lt;p&gt;開發與開放，就是這個新網站的主軸，故名為 kaif。我希望台灣有個新網站，討論的內容都是公開的，而且輕易取得；我也希望台灣能有個網站，能有深度、廣度的討論，向 hacker news 看齊。找老半天找不到，那就自己寫一個吧。&lt;/p&gt;
&lt;h3&gt;stackoverflow 興起，傳統技術論壇式微&lt;/h3&gt;
&lt;p&gt;kaif 的共同製作人，是 koji，&lt;a href=&quot;http://www.javaworld.com.tw/jute/&quot;&gt;台灣 Javaword 論壇&lt;/a&gt;的站長。在我的部落格裡不適合替他發言。不過，他身為技術論壇的站長，也親身感受到，由於 stack overflow 的興起，大家都漸漸不在論壇上討論了。原本技術論壇就是發問者居多，有了人氣之後，自然就會引發更深度的討論。結果現在大家都在 stack overflow 直接找到答案了，自然也不去論壇。&lt;/p&gt;
&lt;p&gt;第一代網頁論壇，就是很知名的類 phpBB 的論壇 ，它的模式走不下去了。所以我也開始去找所謂的新世代論譠。找到三家，其中一家居然準備要收了 (論譠很難撐的)。另外比較知名的，就是 &lt;a href=&quot;https://discourse.org&quot;&gt;discourse.org&lt;/a&gt; 。兩年前曾在 hacker news 上引起大家的討論與關注。不過兩年後的現在，它也是沒什麼大的起色。而且他們專注在技術與 UX 的細節，論譠模式並沒有和過去有太大的差異。&lt;/p&gt;
&lt;h3&gt;人潮聚集的演進&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;-&amp;gt; TELNET BBS 
  -&amp;gt; phpBB 
    -&amp;gt; reddit (news aggregate, web 2.0) 
      -&amp;gt; Facebook (social network)
        -&amp;gt; Whatsapp/Line (mobile)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上面是粗略的近代電腦社群的演進過程。現在已經進入第五代 mobile 了。我一開始也是很有野心的想要做第六代，來個破天荒、驚天霹靂、The Next Big Thing.... blah blah。但經過半年來的思考，還是沒什麼好想法，再這樣下去什麼都沒有，還是沒有我心目中想要，那集思廣意，互想分享切磋的媒介。我最後退一步想，與其去追尋那不著邊際的第六代，不如就回到每天都要吃的營養早餐來的實在點 -- 回到那第三代，靠著眾人推噓而成就的 news aggregator。&lt;/p&gt;
&lt;p&gt;當然，hacker news 和 reddit 的成功不是單單因為他們設計網站的機制很好就起的來的，不然過去台灣類似的網站也應該會成功啊。kaif 只是選擇了 reddit 牌的地基，蓋不蓋的成大廈又是另外一回事了。不過，最少現在地基已經打好，也蓋了第一層樓，可以開始搬進去住了。萬事起頭難，kaif 這新服務很急促的在一個月多開發生出來，完全是靠業餘的時間：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/2015/02/kaif_git_activity.png&quot; alt=&quot;一個月來的 git commit activity&quot;&gt;&lt;br&gt;
圖說：一個月來的 git commit activity.&lt;/p&gt;
&lt;p&gt;這一兩個月真是沒日沒夜的都在開發，這留給下一篇再討論吧。&lt;/p&gt;
&lt;h3&gt;小結&lt;/h3&gt;
&lt;p&gt;新服務 &lt;a href=&quot;https://kaif.io&quot;&gt;kaif.io&lt;/a&gt; 總算誕生啦，我沒有解釋太多 kaif 本身服務的內容，因為它跟 hacker news/reddit 都是一個樣，應該不難理解。反而都是在說明為什麼要建一個這麼舊式 (笑) 的網站。請有興趣的人，到 &lt;a href=&quot;https://kaif.io/account/sign-up&quot;&gt;kaif.io  註個冊&lt;/a&gt;，貼貼自己有興趣的網頁；對認同的言論，去按按那顆灰色的三角型，投個票。有問題可以先看看 &lt;a href=&quot;https://kaif.io/z/kaif-faq&quot;&gt;官方的FAQ&lt;/a&gt;，如果有其他疑問或是建議，可以到 &lt;a href=&quot;https://kaif.io/z/sysop&quot;&gt;站務版&lt;/a&gt; 討論。目前的討論區不多，只有幾個技術類的。如果自己想建新討論區的，可以到 &lt;a href=&quot;https://kaif.io/z/zone&quot;&gt;討論區相關事務&lt;/a&gt; 申請，大致上都會受理。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://kaif.io/z/sysop/debates/bMJOlBeEdF&quot;&gt;在 kaif 裡討論這篇文章&lt;/a&gt;&lt;/p&gt;
</description>
    </item>
    <item>
      <title>邏輯歸何處</title>
      <link>https://ingramchen.dev/blog/2015/02/logic_where.html</link>
      <pubDate>Tue, 24 Feb 2015 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2015/02/logic_where.html</guid>
      <description>&lt;p&gt;最近因為又開始搞新的 pet project，所以又開始了設置專案 -&amp;gt; 開發 -&amp;gt; 初次部署這樣的循環。光是設置專案，也就是基礎建設像是設定檔、gradle build scripts、資料庫之類的設定，就搞了我快一個禮拜。其實 java 建構基礎設置，現在有個工具叫 &lt;a href=&quot;https://jhipster.github.io/&quot;&gt;jhipster&lt;/a&gt;，幾個指令就可搞定了。不過我這人偏偏沒辦法接受 jhipster 產生的空白專案，因為它產生的設定和我想要的有一段距離。只好苦哈哈的自己慢慢刻...&lt;/p&gt;
&lt;p&gt;好了，設置專案這檔事未來有機會再談。今天談的是第二步驟，開發需求時，邏輯該擺在哪一層。每個物件的責任歸屬，隨著需求明確之後，才能夠 modeling、以及分派職責。不過在設置開發的初期，大方向及階層是可以先規劃好的。一個傳統的 Java server 應用程式，我們通常會這樣分層：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- Controller
- Service
- DAO (Data access object)
- Model (POJO)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;當然這是大方向區隔。除了這四層，通常還會有像是 Task 類 (專做 batch job)，專門處理 security 的工具等等。Front end js 的部分，現在百花齊放，不過大致上也脫離不了 MVC 的準則，這裡就先不提它們。&lt;/p&gt;
&lt;p&gt;回到剛才的四層，這我已經寫了很多年了，大概也知道誰該負責什麼，相信有些經驗的人對分辨層級沒有困難。不過，企業邏輯該擺哪困擾我很久了，看個例子：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;// Type 1: 在 service 層產生
public class ArticleService {
   public Article create(String author, String content) {
      User user = userDao.findByName(author);
      checkPermissionForCreatingArticle(user);

      Article article = new Article();
      article.setAuthor(user);
      article.setContent(HtmlUtil.escape(content));
      article.setCreateTime(Instant.now());
      
      articleDao.save(article);
      return article;
   }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;這是一個典型的 service method，功能是建立文章，這 method 的實作有善盡各個層級的職責，像是這個 service 就管了權限、資料的保護、生成與儲存，想必使用它的 Controller 不需要煩惱這些邏輯。而這個 method，我們也看不到任何資料庫的操作，所以 Dao 也是有藏好它的實作。&lt;/p&gt;
&lt;p&gt;這些是家常便飯，沒什麼。那麼看看下面的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;// Type 2: 在 Dao 層產生
public class ArticleService {
   public Article create(String author, String content) {
      User user = userDao.findByName(author);
      checkPermissionForCreatingArticle(user);
      return articleDao.insertArticle(user, content);
   }
}

class ArticleDao {
   public Article insertArticle(User author, String content) {
      Article article = new Article();
      article.setAuthor(user);
      article.setContent(HtmlUtil.escape(content));
      article.setCreateTime(Instant.now());
      long generateId = jdbcHelper.insertObject(article);
      article.setId(generatedId);
      return article;
   }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;這是另一種變型，你可以看到我把 entity 物件 &lt;code&gt;Article&lt;/code&gt; 的產生放到 Dao 層去了。Dao 裡實際的資料庫操作，我們略過不提，暫時用個 &lt;code&gt;jdbcHelper&lt;/code&gt; 代表。不過，不管底層換什麼，大致上會產生個 unique ID，然後再設回給 entity。再來看看別的寫法：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;// Type 3: Model 層發生

public class ArticleService {
   public Article create(String author, String content) {
      User user = userDao.findByName(author);
      checkPermissionForCreatingArticle(user);
      Article article = Article.create(user, content);
      return articleDao.insert(article);
   }
}

public class ArticleDao {
   public Article insertArticle(Article article) {
      long generateId = jdbcHelper.insertObject(article);
      article.setId(generatedId);
      return article;
   }
}

public class Article {
   public static Article create(User author, String content) {
      return new Article(
         author, 
         HtmlUtil.escape(content), 
         Instant.now());
   }
   
   private Article(User author, 
                   String content, 
                   Instant createTime) {
     //略過 constructor fields...
   }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;又換另外一種寫法了，這次是新增的邏輯歸在 entity 本身，讓 Service 層使用。當然也可以將呼叫 &lt;code&gt;Article.create()&lt;/code&gt; 這個 method 從 Service 層搬到 Dao 層去，這又是第四種寫法了。&lt;/p&gt;
&lt;p&gt;好了，困擾我的問題是，哪個做法才是對的？上面三種做法，我都在各個專案裡使用過，而且是混合使用，一下用 type1，一下用 type3... 甚至更多種的小變型。雖然是混著寫，不過到是沒有因為用了哪一種，而出過大錯。這有可能是因為我寫的是 Java，它的 IDE 和 compiler 太強大，小問題不容易發生；或者是我們團隊在 service 層都有完整的 Unit Test 保護；又或者是因為有 pair programming，小錯誤不容易犯，才能安然渡過。&lt;/p&gt;
&lt;p&gt;沒出包過不代表未來不會出包，而且開發時沒有固定的規則容易卡住，卡在到底放哪邊才好這種問題上。另外，程式碼其實還蠻難追的，尤其是專案變大後，混著多個風格的程式，讀起來也累，維護的人也是無所適從，這可不是長遠之計。&lt;/p&gt;
&lt;p&gt;趁著這一次的新專案開發，我有機會再重新思考一次這個問題：邏輯歸何處？&lt;/p&gt;
&lt;h3&gt;邏輯歸何處&lt;/h3&gt;
&lt;p&gt;我的結論是都可以。&lt;/p&gt;
&lt;p&gt;What ?&lt;/p&gt;
&lt;p&gt;都可以不是代表可以隨便寫，只是代表沒有所謂的 &lt;em&gt;唯一的位置&lt;/em&gt; 。你可以根據邏輯的適用範圍調整，或是根據使用的 framework 調整。像上面的 type 2 寫法，就適合純 JDBC 的 Dao，而 type 3 則是合適 hibernate/JPA 這類的 DAO。&lt;/p&gt;
&lt;p&gt;但是，有一個最大的前題：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;不論你的邏輯放在何處，在任何時候，你的物件都要是完整的&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;這意思是，當有別的開發者，使用你設計的 Model，不論他怎麼呼叫，他都不會拿到一個破損的物件。比方說上面的 &lt;code&gt;Article&lt;/code&gt;，你不能設計成建構物件後，但是卻沒有作者這種違反事實的邏輯發生。按這個規則，其實上面的 type 1 範例是錯的。因為它開放 &lt;code&gt;Article&lt;/code&gt; default constructor，以及一堆 setter 給別層級的程式呼叫。&lt;/p&gt;
&lt;p&gt;所以理想的 Model 物件，應該類似 type 3 的 &lt;code&gt;Article&lt;/code&gt; 那樣。把 constructor 關起來，提供安全的 static factory method 給外面呼叫。如果能進一步將 Model 物件做成 &lt;a href=&quot;/blog/2014/03/uuid-as-primary-key.html&quot;&gt;immutable&lt;/a&gt;，那是更好的了，因為怎麼樣使用都不會壞。&lt;/p&gt;
&lt;p&gt;接下來輪到 Dao。Dao 的真正職責是物件的儲藏，這也是為什麼也叫 &lt;code&gt;Repository&lt;/code&gt;。我們從倉庫取出物品，使用後，再放回，等著下次使用，物件的生命週期有很大一部份都跟 Dao 習習相關。Dao 這層要確保經過 CRUD的操作後，你拿到的 Entity 也是完整的，合乎邏輯的。例如 Article 上的 ID。ID 在大部份應用裡都是由資料庫產生，所以 Dao 經手過的 Model 物件，要保證 ID 不能是 null，要保證 ID 在整個系統裡是唯一。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;完整物件&lt;/code&gt; 這個規則下，我們可以這樣設計 Entity 與 Dao：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;/**
 * 這兩個 class 的目錄結構：放在同個 package 下
 * 
 * src/main/java/.../article/Article.java
 * src/main/java/.../article/ArticleDao.java
 */
public class Article {
   public static Article create(
         User author, String content, Instant createTime) {
     Objects.requireNonNull(author);
     Objects.requireNonNull(content);
     Objects.requireNonNull(createTime);
     return new Article(
       null,
       author, 
       HtmlUtil.escape(content), 
       createTime);
   }
   
   // constructor 的 scope 是 package private，可以給
   // 同一層的 ArticleDao 呼叫
   Article(Long id, 
           Long author, 
           String content, 
           Instant createTime) {
     // 略過欄位
   }

   // setter 也是 package scode
   setId(Long id) {this.id = id);
}

public class ArticleDao {

   public Article create(
         User author, String content, Instant createTime) {
      Article article = Article.create(author, content,
                                       createTime);
      Long id = jdbcHelper.insert(article);
      //setId() 是 package scoped
      article.setId(id);
      return article;
   }

   //實際的 SQL 實作略過，不過可以看出 Dao 呼叫了 Article 的 
   //package scoped constructor
   public Article findById(long id) {
      ResultSet rs = jdbcHelper.query(&amp;quot; SELECT * FROM...&amp;quot;); 
      return new Article(
        rs.getLong(&amp;quot;id&amp;quot;),
        // .... 略過
      );
   }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ok，這範例有點長，有幾個重點：&lt;/p&gt;
&lt;h4&gt;Article Model 完整的保護&lt;/h4&gt;
&lt;p&gt;任何外面的 method 使用 &lt;code&gt;Article&lt;/code&gt; 時它都會拿到完整 Article。因為 &lt;code&gt;Article.create&lt;/code&gt; 好好的封裝了邏輯，它確保該有的資料都有，&lt;code&gt;content&lt;/code&gt; 也有經過安全的 html escape。&lt;/p&gt;
&lt;h4&gt;ArticleDao 與 Article 同 package&lt;/h4&gt;
&lt;p&gt;而這裡的 Dao，在操作過程裡，會呼叫 Article 的 package scope &lt;code&gt;setId()&lt;/code&gt; 及 constructor，這兩個呼叫其實是穿過 Article 的封裝直接呼叫的，是不安全的操作，所以在設計上，我將 Dao 和 model 物件放在同個 package 下，在 package 這個層級封裝。外面的人不管怎麼呼叫 Dao，他拿到的都是個完整的，有正確 ID 的 Article，而它也不能亂改亂破壞我傳給他的物件。&lt;/p&gt;
&lt;h4&gt;ArticleDao 作為 Entity Factory&lt;/h4&gt;
&lt;p&gt;Article 這個 entity 現在已經是建全的物件，所以放在哪使用都是 ok:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;// 第一種，在 Dao 建立 Entity，也就是上面的範例，再重覆一次做為比較：

public class ArticleService {
  public Article create(String author, String content) {
     //...
     Article article = 
        articleDao.create(user, content,
                          Instant.now());
     //...
  }
}

public class ArticleDao {
   public Article create(
         User author, String content, Instant createTime)
      Article article = Article.create(author, content,
                                       createTime);
      //insert db and setId() ... etc
   }
}

////////////////////////////////////////////////////////

// 第二種，在 Service 呼叫：

public class ArticleService {
  public Article create(User author, String content) {
     //...
     Article article = Article.create(user, content,
                                      Instant.now());
     articleDao.insert(article);                                      
     //...
  }
}

public class ArticleDao {
   public void insert(Article article)
      //insert db and setId() ... etc
   }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 Model 與 Dao 都能產生完整物件後，上面這兩種擺法就都可以了，沒有唯一解。不過第一種做法賦予了 Dao &lt;code&gt;Entity Factory&lt;/code&gt; 這樣的角色，Dao 不再只是倉庫，而且還是工廠了。換句話說 entity 的整個生命週期：生成、讀出、修改、死亡都是歸 Dao 統管。大部份的情況下，我個人比較偏好第一種 Dao 兼任 factory 的設計，偶而混用第二種做法。不過這就像大括號要不要斷行一樣，見人見智，團隊裡有統一的風格就好。&lt;/p&gt;
&lt;h4&gt;Article.createTime 放在哪建立&lt;/h4&gt;
&lt;p&gt;這是題外話，不過這也是煩了我很久的問題之一：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;prettyprint lang-java&quot;&gt;class Article {

   static Article create_1(
         User author, String content, Instant createTime) {
     return new Article(..., createTime);
   }

   static Article create_2(
         User author, String content) {
     Instant createTime = Instant.now();
     return new Article(..., createTime);
   }
}   
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上面的 createTime 一種是外面給的，另一種是自己產生。而這問題在 Dao 也有，createTime 該是 Dao 自己產生，還是由 service 層傳過來？&lt;/p&gt;
&lt;p&gt;我的結論是，時間是個非常難控制的 side effect，所以 Model 和 Dao 這兩層都不該自己建立，該從上一層傳過來。如果有寫 Dao Unit Test 的經驗，就會了解時間從外面傳讓測試好寫太多 (資料庫查詢常常會有時間的排序)。&lt;/p&gt;
&lt;p&gt;從時間這個問題延伸下去，我衍生出了一個守則：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;任何難以預測的 side effect，都不該由 Model 或 Dao 層處理&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;舉些例子吧，像是 &lt;code&gt;Article.content&lt;/code&gt; 的 HtmlUtil.escape() 這個邏輯該放在哪呢？escape 的邏輯可能很複雜，但是它沒有 side effect，它是 pure function，傳入 A 就得到 B，不管呼叫幾次。所以，它可以放在 Article 這個 model 裡，自己處理。&lt;/p&gt;
&lt;p&gt;但是像 &lt;code&gt;User.passwordHash&lt;/code&gt; 這樣的案例呢？用戶的帳號物件裡會放 hash 過的 password，以供登入時驗證比對。而這個 hash 因為安全性的要求，就算是同個密碼，每次算出來結果都要不一樣。而這就不是 pure function 了，所以 passwordHash 就會是由 service 層算好傳給 User/UserDao。&lt;/p&gt;
&lt;p&gt;ps. 如果你設計的 password hash 同個密碼算出來的都一樣，那你該上&lt;a href=&quot;http://en.wikipedia.org/wiki/Bcrypt&quot;&gt;思過崖&lt;/a&gt;，面壁思過個一年好好反省。如果你設計的 password 是存明碼，那也不用面壁了，直接跳崖吧！&lt;/p&gt;
&lt;h3&gt;總結&lt;/h3&gt;
&lt;p&gt;階層式架構是非常常見的設計，今天討論的是 controller/service/dao/model 這樣的四層架構。熟練的開發者，會懂得將處理 HttpServletRequest 的邏輯留在 Controller 層，不會洩露到 Service 層；也會將 SQL 語法留在 Dao 這一層，不會到處流竄。這是很直覺，很容易做到的。本文討論的則是企業邏輯，尤其是物件的生成這部份，該放在何處才是最佳解。&lt;/p&gt;
&lt;p&gt;我的結論是放在何處並不是最重要的，重點是不論從哪一層存取物件，該物件都要合乎邏輯，而且強韌不易損壞。為達這個目標，大部份邏輯會一直下放，下放到 Model 層，而 Model 層穩固了，上面的層級自然也不容易壞。而 Dao 則是設計成與 Model 同個 package，雖然這違反了許多人的做法(大部份的人習慣所有 dao 自成一個 package)，不過，畢竟 Dao 掌管了物件的生命週期，與 Model 習習相關，它們要放在一起設計，一起使用。&lt;/p&gt;
&lt;p&gt;另外也提到了 side effect 相關的邏輯該放在何層。理想情況下，希望能在 model 層處理。但是難以預測的 side effect 會讓 Model/Dao 過於依賴外部資源，而且測試非常困難。因此我的建議是，非 pure function 的邏輯應該屬於負責 integration 的 Service 層。&lt;/p&gt;
&lt;p&gt;如果能夠達到這些目標，那麼剩下的只是風格問題，可以隨各個團隊偏好調整了。&lt;/p&gt;
</description>
    </item>
    <item>
      <title>Durable Queue by Cassandra </title>
      <link>https://ingramchen.dev/blog/2015/01/durable-queue-by-cassandra.html</link>
      <pubDate>Tue, 13 Jan 2015 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">/blog/2015/01/durable-queue-by-cassandra.html</guid>
      <description>&lt;p&gt;&lt;a href=&quot;/blog/2015/01/an-operator-wakes-up-at-4am.html&quot;&gt;上個月我踩到&lt;/a&gt;了 Cassandra anti pattern - durable queue，結果 server 後來爆掉了，實在是很慘。後來我們變更了設計，來避開這個問題。今天來分享整件事的來龍去脈，以及目前是怎麼解決的。&lt;/p&gt;
&lt;h3&gt;Durable queue 是什麼&lt;/h3&gt;
&lt;p&gt;先來個名詞解釋：Durable queue 是什麼意思？&lt;/p&gt;
&lt;p&gt;Queue 裡面放的是訊息，按新增的順序排序。存取的方向是 FIFO 。而 durable 的意思 queue 裡面放的訊息，如果還沒 &lt;em&gt;用掉&lt;/em&gt; (acknowledge)，它就不會消失，即使是 server 重開了。所以 durable queue 的實作通常會儲存到 disk。&lt;/p&gt;
&lt;p&gt;一個典型的 durable queue 是這樣的操作的：假設一開始 server 裡 queue 存有四個訊息 a,b,c,d。Client 先讀了前三筆 a,b,c 處理，然後回傳給 server 說處理好了 (acknowledge)，server 就刪掉 a,b,c。接著 client 再跟 server 問，這時 server 應該要回傳剩下的 &lt;code&gt;d&lt;/code&gt;，client 之後用完又再刪除。如此重覆下去。&lt;/p&gt;
&lt;p&gt;為方便討論，以下文章寫到 queue 時都是指 durable queue。&lt;/p&gt;
&lt;h3&gt;Queue 的應用廣泛&lt;/h3&gt;
&lt;p&gt;Queue 其實在一個應用中很常見的，例如 &amp;quot;有多少封信還沒收下來&amp;quot; 這樣的案例。但是 Cassandra 官方卻告訴我們，它不適合處理這樣的需求。不能滿足常見的需求實在有點糟糕啊。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;http://www.datastax.com/dev/blog/cassandra-anti-patterns-queues-and-queue-like-datasets&quot;&gt;Cassandra 官方的說法&lt;/a&gt;是你該用專屬的 queue 解決方案取代，例如 RabbitMQ 或 ActiveMQ 等等。但就我所知，這類型擅長的方案大多是固定數量的 queue (最多上千個 queue 吧)，通常是用在後台流程。它們無法解決用戶端應用的 queue。&lt;/p&gt;
&lt;p&gt;回到 &amp;quot;有多少封信還沒收下來&amp;quot; 這個案例，這個應用會有多少個 queue？有多少用戶就有多少個！換句話說，就是百萬以上這個量級。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;ps: 這裡提的 queue 數量不是一個 queue 內可以放多少個訊息，而是有多少個不同的 queue 。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;用戶導向 Queue 的特徵&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Queue 數量跟用戶數一樣多，百萬、千萬以上&lt;/li&gt;
&lt;li&gt;讀取次數比寫入多 (例如常常檢查有沒有新信...etc)&lt;/li&gt;
&lt;li&gt;用戶離線時，queue 必須能自動從記憶體中釋放&lt;/li&gt;
&lt;li&gt;每個 queue 內的訊息量少&lt;/li&gt;
&lt;li&gt;client 通常是離線的，非即時&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;第三點要特別說明一下：如果沒在用的 queue 不從系統中釋放，那麼每個註冊過的用戶，都需要配置資源給 queue，這樣既浪費又撐不住，良好的設計應該只會配置記憶體給最近有使用的 queue。&lt;/p&gt;
&lt;h3&gt;任務導向 Queue 的特徵&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Queue 的數量跟任務數差距不大&lt;/li&gt;
&lt;li&gt;讀取和寫入數通常 一比一&lt;/li&gt;
&lt;li&gt;Queue 本身持續保持在記憶體中 (queue 內的訊息則不一定)&lt;/li&gt;
&lt;li&gt;每個 queue 內的訊息量大&lt;/li&gt;
&lt;li&gt;Client 與 server 間通常保持持續連線，即時&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;舉例來說：你的系統要接金流系統，每一次用戶下單都會送給銀行。為了避免漏單，或是調節流量，你會設計一個專門的 queue 來處理所有同類型的交易。像這個例子裡 queue 的總數就不大，例如服務提供三種付費方式，接十家銀行，那麼相乘後 queue 也才 30 個。&lt;/p&gt;
&lt;h3&gt;用戶導向 Queue 的解決方案&lt;/h3&gt;
&lt;p&gt;上面提到的兩種 queue 的應用，它們都是 durable queue，但最大的差別在用戶導向是非即時的，而且 queue 數量多，而任務導向的則相反。後者可以用類似 RabbitMQ 方案來解決，但前者呢？我找了很久都沒找到有下列特徵的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;HA - High Availabilty (任何節點掛了，還能讀取訊息，且可 &lt;em&gt;寫入&lt;/em&gt; )&lt;/li&gt;
&lt;li&gt;Horizontal scale out (幾千萬用戶數的 queue 自然要可以水平延展，分散在不同節點)&lt;/li&gt;
&lt;li&gt;用戶離線時，queue 必須能自動從記憶體中釋放&lt;/li&gt;
&lt;li&gt;發展成熟&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;結論就是找不到現成的方案，最後只好自己寫。&lt;/p&gt;
&lt;h4&gt;以 Redis 實作用戶導向 Queue&lt;/h4&gt;
&lt;p&gt;參考網路上的做法，大多是直接用 Redis。Redis 的 List 或是 Sorted Set 很適合拿來當 queue 用，而且速度超快。但是 Redis 並不滿足我第三點的要求：要能從記憶體中釋放，除非你自己寫個 background job，每天將閒置過久的 queue 寫到 disk，然後真的要用時還要從 disk 讀進 redis，非常費功夫。另外，現階段 Redis 要同時做到 HA 和 scale out 也是很困難的，它直到 3.0 版才開始有 Redis cluster，現在還在 RC 的階段，效果未知，離成熟還有一段時間。&lt;/p&gt;
&lt;h4&gt;以資料庫實作用戶導向 Queue&lt;/h4&gt;
&lt;p&gt;用資料庫去實作用戶導向 queue 是可行的，因為大部份的情況下這個 queue 都是非即時的，等用戶自己來撈。單純用資料庫來做的話，也直接滿足第三點，因為要求訊息時都直接去資料庫查詢，所以沒有配置記憶體的問題，只是查詢會慢一點。我想 Postgres / MySQL 之類的傳統 RDBMS 都是可以勝任的，只差在 HA / scale out 也是很難搞定，不像 NoSQL 中的 Cassandra 這麼簡單。&lt;/p&gt;
&lt;h3&gt;Cassandra Durable Queue Anti-pattern&lt;/h3&gt;
&lt;p&gt;有關 RDBMS 當 queue 來用我沒有什麼經驗，我們公司目前部署比較大的 DB 只有 Cassandra，所以只討論 Cassandra 的部份。首先，先大概說明一下 Cassandra 為什麼不能做 durable queue：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cassandra 俱備 high availabilty 的特性。HA 的條件是某些節點掛了，整體服務還能照常運作，包含寫入資料&lt;/li&gt;
&lt;li&gt;在運行中掛掉的那個節點，不會收到應該寫入的資料，所以它儲存的資料一直都是錯的。直到它回復後，才能從其他的備份節點修補資料。&lt;/li&gt;
&lt;li&gt;修補的過程中，如果是新增的資料不見了，只要從備份裡複製一份回來就好了。但如果是被刪除的資料呢？修補時反而會把該被刪的資料救回。&lt;/li&gt;
&lt;li&gt;所以 Cassandra 的設計裡，刪除這個動作會存下來，而不是直接殺掉該筆。這個動作叫做墓碑，&lt;a href=&quot;http://www.datastax.com/documentation/cassandra/2.0/cassandra/dml/dml_about_deletes_c.html&quot;&gt;tombstone&lt;/a&gt;。內部實作是新增一筆 &lt;code&gt;DeletedColumn&lt;/code&gt;  (&lt;a href=&quot;http://stackoverflow.com/questions/11516234/tombstone-physical-location-in-cassandra&quot;&gt;參考資料&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;刪除資料時的範例：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;//假設一開始有四筆
[a, b, c, d]

//後來刪除了 a,b,c，可能會有幾種情況：

// (1) a, b, c 被 DeletedColumn 蓋掉 (用 &#39; 表示)
[a&#39;, b&#39;, c&#39;, d]
 
// (2) 原來的 a,b,c 還存在別的檔案不動，多了三筆 DeletedColumn
[a&#39;, b&#39;, c&#39;] [a, b, c, d]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;不論刪除後的狀態如何，儲存在 disk 的資料都是變多的，即使只剩下 &lt;code&gt;d&lt;/code&gt; 一筆有意義&lt;/li&gt;
&lt;li&gt;因此如果用 Cassandra 來實作 durable queue，就會像上面的範例，處理更多的訊息後，實際儲存的資料不減反增，裡面一堆墓碑。假設這個 queue 有過 10000 筆訊息，而其中的 9999 筆已經處理掉了，最後 Cassandra 內會存成：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;[a1&#39;, a2&#39;, a3&#39;, ... , a9998&#39;, a9999&#39;, a10000]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;當資料變成這樣後，用戶如果要取得那尚未處理的&lt;code&gt;a10000&lt;/code&gt; ，Cassandra 得先掃過那 9999 筆的墓碑，才可以肯定只剩下一筆，並且回傳給 client。這個繁重的 disk IO 就不用說了。&lt;/li&gt;
&lt;li&gt;還沒完，由於 Cassandra 要維持資料的一致性，所以它需要比對各個節點的資料是否一致，所以它從 A 節點查詢出 10000 筆，B 節點也查詢出 10000 筆，然後在 heap 裡比對差異。明明只是為了讀那一筆出來，但是 heap 沒多久就爆了，這實在是太慘了。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;以上就是 Cassandra 無法處理 durable queue 的根本原因。整個來龍去脈看下來，始作俑者就是墓碑這個設計，但它也是為了達到 high availability、又要維持一定的完整性 (consistency)，才會存在的。&lt;a href=&quot;http://en.wikipedia.org/wiki/CAP_theorem&quot;&gt;CAP 理論&lt;/a&gt;中的 CA 互斥關係，迫使系統在設計上做了妥協，也失去了一些能力。&lt;/p&gt;
&lt;h3&gt;摒棄刪除的概念&lt;/h3&gt;
&lt;p&gt;用戶導向的 queue 沒有現成的解決方案，想要借助 Cassandra 來實作 durable queue 又窒礙難行。那怎麼辦？山不轉路轉，換個設計看看能不能避開墓碑囉。&lt;/p&gt;
&lt;p&gt;那麼就讓 queue 內的訊息一直累積吧，不刪除資料自然就不會有墓碑。我們可以設計一個指標性質的新 table，它用來存放最後 &lt;em&gt;讀取點&lt;/em&gt;。讀取點存的是最後一次讀取的訊息 ID (一般是用 &lt;a href=&quot;http://en.wikipedia.org/wiki/Universally_unique_identifier#Version_1_.28MAC_address_.26_date-time.29&quot;&gt;Type 1 UUID&lt;/a&gt;，按時間順序產生的一種 UUID，精準度到 microsecond) 。&lt;/p&gt;
&lt;p&gt;讀取點的使用流程大概是這樣：client 每次要求 queue 內的訊息，都從最後讀取點開始讀出，處理完訊息後，再將讀出訊息中最後一筆當作新的讀取點寫回。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//每次讀三個訊息的話，讀到最後會有四次的讀取點 1,2,3,4
[a, b, c, d, e, f, g, h, i, j, k] //所有訊息
       ^        ^        ^     ^
       1        2        3     4  //讀取點
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;當然這不是什麼新設計，很多系統的 queue 都是這樣實作的。只是這個作法有違常理：Queue 中處理過的訊息你會想殺掉，因為何必讓用不到的資料繼續佔空間佔資源呢？現在這個額外讀取點的做法，是用空間換取效能，這是為了避開墓碑而做的妥協。&lt;/p&gt;
&lt;p&gt;改成這個作法後：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不會踩到 anti pattern 了，可以維持一定的效率&lt;/li&gt;
&lt;li&gt;每次讀 queue 裡的訊息，首先要查詢讀取點 table，然後才能查詢訊息 table，反而要兩個 round trip 了，比較慢&lt;/li&gt;
&lt;li&gt;訊息會一直累積，佔資源&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Table rotation&lt;/h3&gt;
&lt;p&gt;為了解決無用訊息一直累積的問題，可以將訊息依日期間隔隔開儲存，然後一段時間一口氣刪除已經過期的資料，例如每個月都建一個新 table&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE MyQueue_2014_12 (...)
CREATE TABLE MyQueue_2015_01 (...)
CREATE TABLE MyQueue_2015_02 (...)
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每當新增訊息到 queue 時，就選擇當月的 table 寫入 。如果 queue 裡的訊息最多只替用戶保持一個月，那麼到了，譬如2015 年 2 月時，就可以將 2014 年 12 月的 table &lt;code&gt;MyQueue_2014_12&lt;/code&gt; 直接 drop 掉。因為是整個 drop，沒有墓碑的問題。&lt;/p&gt;
&lt;h3&gt;Bucket rotation with TTL&lt;/h3&gt;
&lt;p&gt;除了開新的 table 外，也可以在主鍵這個層級做區隔 -- 透過設立一個新的 column 做為時間的區段 (bucket) 。這個作法比較進階了，可能需要實際用過 Cassandra 才看得懂，不過我盡可能解釋。我們可以設計 queue 的 table 如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE MyQueue (
  user_id TEXT,
  bucket TEXT,   -- &#39;2014_12&#39;, &#39;2015_01&#39;... etc
  message_id TIMEUUID,
  data TEXT,
  PRIMARY KEY ((user_id, bucket), message_id)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上面範例的主鍵由 user_id (用戶)、bucket (時間的區段)、及訊息的序號  (time based UUID) 三個組成。&lt;/p&gt;
&lt;p&gt;本文討論的主題是用戶導向的 queue，所以主鍵會包含用戶的 ID，每個用戶各自存自己的訊息。主鍵中的第二個則是訊息的建立時間所在的區段，如果按月隔開的話，它的值就是 &lt;code&gt;2014_12&lt;/code&gt;, &lt;code&gt;2015_01&lt;/code&gt; ，依此類推，每個月變一次。這兩個欄位設定為 Cassandra 的 partition key，也就是說，同個用戶，而且同個月份的訊息，才會存到同個 Cassandra 的節點上，差任何一個鍵，都有可能放在不同節點。這樣的分配能避免有一台節點資料過度集中，產生 hot spot。&lt;/p&gt;
&lt;p&gt;最後一個主鍵是訊息 ID，它的型別是 Type-1 UUID，message_id 這裡設為 cluster key，所以 Cassandra 在儲存時會按照這時間順序存在 disk。而 queue 在讀取時通常也是按時序讀取，當 Cassandra 內部進行查詢時，disk seek 只需一次就可以找到位置，並且用 disk 中最快的循序讀取一次讀完要求的訊息數，非常有效率。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//範例資料：
use_id  |  bucket   | message_id 
----------------------------------------------------------------
&#39;userA&#39;   &#39;2014-12&#39;   71493f30-99ba-11e4-a97b-1387d4d6544b
&#39;userA&#39;   &#39;2014-12&#39;   74ee0440-99ba-11e4-a97b-1387d4d6544b
&#39;userA&#39;   &#39;2015-01&#39;   8d059e80-99ba-11e4-a97b-1387d4d6544b
&#39;userB&#39;   &#39;2014-12&#39;   8eb4ae10-99ba-11e4-a97b-1387d4d6544b

// 查詢的範例，WHERE 要指定當月或上個月的 bucket
SELECT * 
  FROM MyQueue
 WHERE user_id = &#39;userA&#39;
   AND bucket = &#39;2014-12&#39;
 LIMIT 10  
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上面是大概資料的樣子。像這樣的結構就沒辦法像前面 table rotation 的範例那樣，可以一口氣殺掉整個月的過期資料。因為 Cassandra 在刪除時不能只指定部份主鍵，像下面這樣是不行的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 不合法的 CQL
DELETE FROM MyQueue WHERE bucket = &#39;2014-12&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不過 Cassandra 有提供 TTL (time to live) 的功能，就是當時間過期了，該筆資料會自動刪除：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 5356800 = 2 * 31 * 86400秒 = 二個月
INSERT 
  INTO MyQueue 
       (user_id, bucket, message_id, data)    
VALUES (&#39;userA&#39;, &#39;2014_12&#39;, now(), &#39;d1..&#39;)   
 USING TTL 5356800;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;USING TTL&lt;/code&gt; 就是指定該筆資料何時會被刪掉，設定 TTL 後，過期的訊息就會自動被刪除，不用花額外的功夫去清理。當然這個作法就會產生墓碑了。不過，由於我們每次查詢時都會指定時間區段 (bucket)，只要能夠避開那個會有墓碑的區段就沒事了。&lt;/p&gt;
&lt;p&gt;在這個範例裏，queue 裡的訊息只保留一個月，所以查詢訊息時，我們只會查詢本月和上個月的 bucket 而已。前兩個月的 bucket 再也不會碰到。因此即使刪除它而產生了許多墓碑也是無礙。注意這裡的 TTL 要設兩個月 (需求是保留一個月，但 TTL 要設兩倍的區段)&lt;/p&gt;
&lt;p&gt;利用 bucket 隔開來避免墓碑的問題是算是進階的 &lt;em&gt;hack&lt;/em&gt;，要運用這個技巧你必須非常了解 &lt;a href=&quot;http://www.datastax.com/documentation/cql/3.0/cql/ddl/ddl_compound_keys_c.html&quot;&gt;partition key, cluster key&lt;/a&gt;, tombstone 完整的運作原理。本文只做了簡單的說明，它其實有很多眉角的。&lt;/p&gt;
&lt;h3&gt;Rotation with foward insert&lt;/h3&gt;
&lt;p&gt;我們靠著按時間 rotation 的作法，巧妙避開墓碑問題，又可清除不要的資料。但是在取得 queue 裡最新訊息的過程中，反而會產生多餘的 round trip，例如遇到跨月的時候:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Client 要求 queue 內的訊息，最多 10 筆&lt;/li&gt;
&lt;li&gt;Server 查詢到最後讀取點是上個月&lt;/li&gt;
&lt;li&gt;利用讀取點查詢上個月 bucket 的訊息&lt;/li&gt;
&lt;li&gt;上個月 bucket 的訊息回傳 6 筆&lt;/li&gt;
&lt;li&gt;因為不足 10 筆，再查詢本月 bucket 的訊息 ，結果取得 2 筆&lt;/li&gt;
&lt;li&gt;最後回傳 8 筆&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一共查詢了三次才得到全部結果，如果讀取點的 table 也是設計成只新增，不修改，那它也要做 rotation。最差的案例就要查詢四次了。&lt;/p&gt;
&lt;p&gt;這裡提供一個小技巧，可以減少 round trip 的次數。就是當你在新增資料時，一次新增兩筆：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 新增到本月 &#39;2014_12&#39;，TTL 是一個月長
INSERT 
  INTO MyQueue 
       (user_id, bucket, message_id, data)    
VALUES (&#39;userA&#39;, &#39;2014_12&#39;, now(), &#39;d1..&#39;)   
 USING TTL 2678400; -- 一個月

// 同一筆資料也新增到下個月 &#39;2015_01&#39;，TTL 是二個月長
INSERT 
  INTO MyQueue 
       (user_id, bucket, message_id, data)    
VALUES (&#39;userA&#39;, &#39;2015_01&#39;, now(), &#39;d1..&#39;)   
 USING TTL 5356800; -- 二個月
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一筆資料，各新增一筆到本月和下個月的 bucket。這樣一來，每次 client 要求時只要查詢當月的 bucket 即可，因為當月的 bucket 裡已經包含上個月的資料了。注意上例中兩個新增的 TTL 時間長度不一樣。&lt;/p&gt;
&lt;p&gt;這個技巧是善用 Cassandra 寫入比讀取還快的特性 (差 10~100倍) -- 反正寫入超快，不如一次寫兩筆來減少讀取時的 round trip 數。這個方法的缺點會佔用兩倍的儲存空間，算是用空間換取時間。如果訊息 table 很大的話，那就不適合套這個技巧。&lt;/p&gt;
&lt;h3&gt;讀取點在分散式下的問題&lt;/h3&gt;
&lt;p&gt;在分散式的架構下，尤其是 Cassandra，允許不同的節點同時做寫入，這會造成新增資料不會按時序排列。舉例來說，client 去某個 Cassandra 節點要了 queue 中訊息，例如 &lt;code&gt;[a, b, d]&lt;/code&gt;，將它們處理完後，正打算把 &lt;code&gt;d&lt;/code&gt; 當做新的讀取點寫回時，在別個節點上正好新增了一筆 &lt;code&gt;c&lt;/code&gt;，而它比 &lt;code&gt;d&lt;/code&gt; 還要舊，排在它前面。結果下次讀取時從 &lt;code&gt;d&lt;/code&gt; 開始算起，&lt;code&gt;c&lt;/code&gt; 這筆就永遠漏掉了。&lt;/p&gt;
&lt;p&gt;這個問題的成因是讀取點是以時間為基礎的設計，但是每個 Cassandra 節點的時鐘卻不可能完美的同步。可能的解法有幾種：一是限制同個 queue 都要在同個節點上新增訊息；二是讀取點加上一點時間的緩衝，既每次查詢時改成從最後讀取點前10秒開始，緩衝時間內如果有重覆的訊息則由應用層解決。&lt;/p&gt;
&lt;p&gt;在分散式的前提下要保證訊息按順序新增和讀出是一件超困難的事，即使真的實作出來了也是很慢。我們公司目前暫時選了緩衝時間的解法，而且時間拉長到一分鐘。這不是完美解，但實務上要出現一分鐘的時間誤差機率非常低。&lt;/p&gt;
&lt;h3&gt;總結&lt;/h3&gt;
&lt;p&gt;呼~ 這篇已經太長了，就此打住吧，原本應該分兩篇討論的。本文一開始討論不同需求，會有兩大類的 queue 的應用。任務導向的 queue 有解，用戶導向的目前我還找不到好的，尤其還要同時滿足 HA, scale out 等嚴苛的要求。後半的文章則討論了一些替代的方案。Cassandra 雖然 scale 能力很好，但是卻因為墓碑的設計無法勝任 queue 的任務。雖然如此，透過變更設計，將訊息按時間分段處理，還是可以克服墓碑的問題。&lt;/p&gt;
&lt;p&gt;最後，本文選擇了 &lt;em&gt;不刪除&lt;/em&gt; 來突破困境。而這，又是 &lt;em&gt;Immutability&lt;/em&gt; 的另一場勝利，特別是在分散式的環境下。&lt;/p&gt;
</description>
    </item>

  </channel>
</rss>
