綴Photo の勉強ノートに戻る
開発記録2026.06.15

アプリの綴りを backend に送る『出口』を、内側から積んで固めた — 変換・HTTP・サービスの4層と WSL の罠

FlutterDartAPITDD綴Photo

モバイルアプリと backend をつなぐ『管』を1本通した記録を、判断と詰まりどころ中心にまとめたものです。

ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。

綴Photo は、モバイルアプリと、それを美しく見せる web、そして両者の間に立つ backend でできています。器は3つあるのに、器をつなぐ管(同期)がまだ無かった。モバイルで綴ったデータは端末のローカル DB に溜まるだけで、backend には流れていません。web はモックデータで動いていた。

テンプレートを量産して器を磨くより先に、この管を1本通すことにした。実データが流れない器をいくら磨いても "カタログ" にしかなりません——作りながら自分で動かしてみると、データが端末に閉じたままでは先が見えないとわかった。最初の一歩として、**モバイルから backend へ綴りデータを送る『出口』**を作る。

この記事は、その出口を作る過程でいちばん神経を使った2つ——「契約のズレを地図にする」ことと「実機につなぐ前に内側からテストで固める」こと——と、途中で踏んだ Windows×WSL の罠をまとめる。

この記事で扱う観察ポイント

  • モバイルと backend は、同じ概念でも名前と形が違います(body が text になる、など)。ズレを変換の1か所に閉じ込めます
  • HTTP やサーバを触る前に、純関数とフェイクだけでほぼ全部テストできるように層を切ります
  • 実機につなぐ最後の一歩で、Windows から WSL へコマンドが空文字で渡る罠を踏んだ

契約のズレを、変換の1枚に閉じ込める

モバイルのローカル DB と backend の同期 API では、同じ綴りでも置き場と形が違います。

同じ綴りでも、置き場と形が違う端末 SQLite閉じても残るphotos 1枚 = 1行client_photo_id / taken_atmeaningsbody / mood / client_memory_idmeanings もう1件同じ写真にぶら下がる入れ子変換メモリ上の JSON保存しない。リクエストの間だけitem 綴り1件text ← body親の ID と taken_at を各件へis_favorite は falseitem もう1件同じ client_photo_idフラットなリストの1要素フラットPOSTbackend DB閉じても残るphotos同じ ID で束ねる / is_favoritemeaningstext / client_memory_idmeanings もう1件冪等キーで二重に作らない再び行へJSON アイテムはテーブルではない。backend の行は photos と meanings に入る。is_favorite はモバイルの表に無い。送る JSON に false を載せる。

この間の変換を buildSyncItems という純関数1枚に全部寄せた(実装は AI に書いてもらい、何をどう写すかは契約を読んで自分で決めた)。

dart
List<Map<String, dynamic>> buildSyncItems(List<PhotoWithAllMeanings> memories) {
  final result = <Map<String, dynamic>>[];
  for (final memory in memories) {
    final photo = memory.photo;
    final takenAtStr = photo.takenAt?.toUtc().toIso8601String();
    for (final meaning in memory.meanings) {
      result.add({
        'client_memory_id': meaning.clientMemoryId,
        'client_photo_id': photo.clientPhotoId, // 親写真の値を各itemへ転写する
        'text': meaning.body,                   // body → text(名前が違う・いちばんの地雷)
        'mood': meaning.mood,
        'taken_at': takenAtStr,                 // 撮影日時。無ければ null(createdAtで埋めない)
        'is_favorite': false,                   // モバイルに favorite の概念が無い
      });
    }
  }
  return result;
}

この6行の写し替えに、判断が4つ詰まっている。

  • body → text: 同じ「綴った本文」なのに、モバイルは body、backend は text。名前が違うだけに、写し間違えても型は通ってしまいます。いちばんの地雷なので、テストで名前の一致を直接見張ることにした。
  • client_photo_id を各アイテムに転写: フラット化すると親写真の情報が落ちます。でも backend 側は client_photo_id が同じアイテムを「同じ1枚の写真にぶら下がる綴り」として束ねてくれます。だから親写真の ID を各綴りにコピーして送ると決めた。送らないと、綴りごとに別々の空写真ができてしまいます。
  • taken_at は無ければ null: 撮影日時が取れない写真があります。表示用には登録日時で代用しているが、同期では代用せず null を送ると決めた。「いつ撮ったか分からない」を「登録した日に撮った」に化けさせたくなかった。backend 側も null を許す契約だった。
  • is_favorite は常に false: モバイルの写真には「お気に入り」概念自体がありません。backend にはあります。無いものは送りようがないので固定値にした。

学びポイント

  • 別々に育ったモバイルと backend は、同じ概念でも名前・形・粒度が食い違います。変換を1か所(純関数)に集めて、ズレをそこだけに閉じ込めると、後段が契約を気にしなくてよくなります
  • 「型が通る=正しい」ではありません。body と text のような同型の取り違えは、テストで値の対応を名指しで確認するしかありません

実機につなぐ前に、内側からテストで固める

出口は最終的に、端末の DB から取り出して backend へ送り、返事を読み取る流れになります。これを一気に作って実機で試すと、どこが悪いか分かりません。だからいちばん内側(純粋な計算)から外側(通信)へ積むことにした。各部品を、その場だけでテストできる形に切る。

テストはメモリでほぼ終わる。本物の通信は最後の1歩。メモリ(保存しない。サーバ不要)入れ子の綴り変換JSON アイテムフェイク決め打ちの返事解析集計buildSyncItems と parseSyncResponse は純関数。フェイクはメモリに置く。実機で初めて触るネットワークPOST /api/sync/memories/backend DBphotos / meanings に保存する

HTTP クライアント(ApiClientImpl)は http の MockClient で、送るヘッダ・URL・本文と返事のデコードを検証します。同期サービス(SyncService)は上を束ね、手書きのフェイク(本番サーバの代わりに置く「電話に出るふりをする留守電」——決め打ちの返事を返すので、サーバが居なくても会話の段取りだけ試せる)を差し込んでテストします。サーバは不要です。各部品は既にテスト済みなので、サービスで見るのは「束ね方」だけになります。

dart
Future<SyncResultSummary> syncMemories(List<PhotoWithAllMeanings> memories) async {
  final items = buildSyncItems(memories);                // ①変換(純関数)
  if (items.isEmpty) return const SyncResultSummary([]); // 空なら POST しない
  final chunks = chunkSyncItems(items, maxPerRequest: _maxItemsPerRequest);
  final all = <SyncItemResult>[];
  for (final chunk in chunks) {
    // ②HTTP は「抽象」越しに呼ぶ → テストでフェイクに差し替えられる
    final json = await _apiClient.post(endpoint, body: {'items': chunk});
    all.addAll(parseSyncResponse(json).results);         // ③解析(純関数)
  }
  return SyncResultSummary(all);
}

ここまでで、変換・解析・HTTP・サービスの4層が全部テスト緑になった。実機につなぐ前に、ロジックの正しさはほぼ確信が持てている状態にできた。

学びポイント

  • 通信や DB のような「外の世界」に触る部分は最後に回し、内側の計算を純関数とフェイクで固めてから外へ出ます。実機で初めて分かる範囲が小さくなります
  • 抽象(インターフェース)に依存させておくと、テストでフェイクを差し込めます。「テストしやすい設計」は「依存を差し替えられる設計」とほぼ同義だった

詰まった: Windows から WSL へ、コマンドが空で渡る

最後の実機確認用に、backend が動いている Docker コンテナの中でテスト用のトークンを手発行したかった。開発環境は Windows + WSL で、backend は WSL 側の Docker にいる。Windows の PowerShell から WSL の bash にコマンドを渡すのだが、ここで素直に書くと壊れます。

powershell
# これは壊れる: PowerShell → wsl.exe の途中で \" が落ち、
# python に渡る引数が空文字になる(username="" → エラー)
wsl bash -lc "python -c \"print('hi')\""

クォートの入れ子(" の中の \")が、PowerShell → wsl.exe → bash → python と渡る間に剥がれて、肝心の中身が空になります。これに気づくまで「なぜトークンが空文字になる?」でしばらく止まった。

抜け方は、コマンド本体を base64 に固めて渡し、WSL 側でだけ展開して実行すること。base64 にしてしまえば、途中のシェルが余計な解釈をする隙が無くなります。

powershell
# スクリプト本体を base64 にして渡す → WSL 側で復号して実行
$b = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($script))
wsl bash -lc "echo $b | base64 -d > /tmp/run.sh && bash /tmp/run.sh"

クォート衝突だけでなく、Windows 側で書いたファイルの改行コード(CRLF)が WSL の bash/python を壊す問題も、同時に避けられます(base64 の中で改行を保ったまま渡せます)。環境をまたぐときの定石として手元にメモした。

学びポイント

  • 環境をまたいでコマンドを渡すとき、クォートの入れ子は途中のシェルで剥がれます。\" が消えて引数が空になる、という壊れ方をします
  • そういうときは中身を base64 に固めて運ぶと、途中のシェルに解釈させずに済みます。クォートと改行コードの両方の罠を一度に回避できます

管が開通した

最後に、手発行したトークンを使って、モバイルの実際のコード経路(変換 → HTTP クライアント → 本物の backend → 解析)で1件送ってみた。

live result: total=1 accepted=1 duplicated=0 failed=0 allOk=true

backend の DB に入っている綴りの件数も、送る前後で 1 → 2 に増えた。curl で叩いたのではなく、アプリが実際に通る道で「綴ったデータが backend に届く」ことを確認できた。器をつなぐ管が、ひとまず1本通った。

揃えれば、この変換は要らなくなるのか

書いている途中で一度、こう引っかかった。モバイルと backend のデータベースの名前や形をきっちり揃えておけば、この変換はそもそも要らないんじゃないか。

整理してみると、半分はその通りだった。「変換」と一括りにしていたものが、実は二つの交差に分かれていたからだ。

「変換」は一つの箱ではなく、二つの交差だったメモリDart オブジェクト入れ子。保存しない翻訳body → text揃えれば縮む直列化オブジェクト → 文字消えないHTTP の文字JSON保存しない。文字しか運べないPOSTbackend DBphotos / meanings閉じても残る翻訳は名前のズレ。直列化は HTTP が文字しか運べないことそのものだ。

しかも、翻訳の側も「揃えれば全部消える」わけではなかった。揃っていても残るものが3つあります。

  • 言語の作法: Dart は clientMemoryId、Django 側の API は client_memory_id。概念が同じでも、キャメルケースとスネークケースの差は残ります。
  • それぞれの都合の形: モバイルは「写真に綴りが入れ子」の方が表示に速く、backend は「綴り1件=1行のフラット」の方が重複排除に速いです。これはズレというより、両者が自分の仕事に合う形を選んだ結果で、無理に揃えるものではありません。
  • 意味の判断: is_favorite はモバイルに概念自体がありません。taken_at を null にするか登録日時で代用するかも、人が決めること。名前を合わせても消えません。

だから、この変換は「名前がズレていることの後始末」ではなかった。これは別々に動く2つの世界をつなぐ縫い目だ。仮にいま完全に揃えても、モバイルと backend は別々にリリースされ、別々にバージョンが上がっていきます。片方が名前を1個変えた瞬間、縫い目が無ければもう片方が黙って壊れます。変換の1枚は、その揺れを1か所で受け止める場所として、揃っていても薄く残しておく方がいいです。

このテーマの括り

やったことは「ローカルのデータを backend に POST する」だけ、と言えばだけだ。でも効いたのは、送る処理そのものより送る前の段取りだった。契約のズレ(名前・形・粒度)を変換の1枚に閉じ込めて、変換・解析・HTTP・サービスを内側から積んで、サーバに触る範囲を最後の1ステップまで小さくした。だから実機につないだとき、確かめることは「本当に届くか」だけで済んだ。

コードは AI に書いてもらっているけれど、「body を text に写す」「client_photo_id を各綴りに転写する」「taken_at は代用せず null にする」「管を先に通す」といった写し方・作り方・順番を決める側は、契約を読んで自分が持つ仕事だった。次は、この管を逆向き(backend → web)にも通して、自分が綴ったものが web で見えるところまでをローカルで一周させたい。


この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。