~fvknk/bk

技術関連の備忘録

ReactNativeで家計簿アプリを作りたい(前編)

概要

以下のような家計簿アプリが欲しかったのですがうまく見つけられなかったので、自作してみることにしました。

  • ios対応
  • 複数人で使用できる
  • アカウント・パスワードの共有はしない
  • 共有財布機能がある
  • 月々の振り込みを個人(=非共有財布)で行い、任意のタイミングで共通財布から出費分の補填(以下、精算と呼ぶ)をする
  • 月々共有財布に一定額割り当て、余った分は貯蓄する

やったこと

フローの検討

PlantUMLを使って、概要に記載した要求が満たせそうな、基本的な業務機能関連図のようなものを整理しました。

ざっくりとした画面イメージ

業務機能関連図からかなりざっくりとイメージを描きました。

データの検討

業務機能関連図を見ながらPlantUMLを使って必要そうなテーブル・データを書き出しました。

フレームワークの検討

アプリ作成に際し、開発のフレームワークとして、以下が候補としてあがりました。

  • Swift
  • Flutter
  • ReactNative

最終的には私がReactに多少触れたことがあり、技術的なギャップが比較的少なそうと感じたことからReactNativeを採用することにしました。

環境整備

エミュレータは実機で良いので、面倒ごとが少なそうなExpo Go Quick Startで進めました。

reactnative.dev

上記リンクに記載の通りに実行すると無事に起動しました。

$ npx create-expo-app AwesomeProject

$ cd AwesomeProject
$ npx expo start
tarting Metro Bundler
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
█ ▄▄▄▄▄ █▄▄▄ ▀ ██ █ ▄▄▄▄▄ █
█ █   █ ██▄▀ █ ▀▄▄█ █   █ █
█ █▄▄▄█ ██▀▄ ▄▀▀█▀█ █▄▄▄█ █
█▄▄▄▄▄▄▄█ ▀▄█ ▀ ▀ █▄▄▄▄▄▄▄█
█ ▄▀ ▄▀▄▀▀▄▀█▄▀█▀ █▄█▀█▀▀▄█
█▄▀▀▄██▄▄▄▄██▄▄▄▄ ▀███▄▀▀ █
█ ▀▄▀▄ ▄██▄ █▀█▄ █ ▄▀▀█▀ ██
█ ▄▄▄▀▄▄▄▄▄ █▀▄▀ ▄▀ ██▄▀  █
█▄██▄█▄▄▄  ▀ ▄▄ █ ▄▄▄  ▄▀▄█
█ ▄▄▄▄▄ ██▀ ▀▄  █ █▄█ ███ █
█ █   █ █ ▄█▄ ▀█▄ ▄  ▄ █▀▀█
█ █▄▄▄█ █▀▄▄ ▀█▄ ▄█▀▀▄█   █
█▄▄▄▄▄▄▄█▄█▄▄██▄▄▄▄█▄▄███▄█

› Metro waiting on exp://192.168.1.146:8081
› Scan the QR code above with Expo Go (Android) or the Camera app (iOS)

› Using Expo Go
› Press s │ switch to development build

› Press a │ open Android
› Press i │ open iOS simulator
› Press w │ open web

› Press j │ open debugger
› Press rreload app
› Press m │ toggle menu
› Press o │ open project code in your editor

› Press ? │ show all commands

Logs for your project will appear below. Press Ctrl+C to exit.

手持ちのiPhoneでExpo GOをインストールしてからQRコードを読み込むと、以下のような画面が表示されました。

問題なく動作していそうです。

支出登録フォームを作成する

テキスト入力欄を作成する

ReactNative formで検索するとreact-hook-formというライブラリが比較的多く引っかかったので、react-hook-formを導入しました。

念の為react-hook-formのGitHubも見たのですが、スター数も十分多く、定期的にメンテナンスもされていそうなので比較的安心かなという感じで導入を決めています。

https://github.com/react-hook-form/react-hook-form

導入自体は以下に沿って行いました。

react-hook-form.com

以下のようにコンポーネントを作成してスマホで確認したところ、問題なく表示・動作しました。

import { Text, View, TextInput, Button } from "react-native";
import { useForm, Controller } from "react-hook-form";

export default function Form({ setItems }) {
  const {
    control,
    handleSubmit,
    formState: { errors }
  } = useForm({
    defaultValues: {
      price: null,
      shop: null,
    },
  });
  const onSubmit = async (data) => console.log(data);

  return (
    <View>
      <Text>金額</Text>
      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            label="金額"
            placeholder="¥"
            onBlur={onBlur}
            onChangeText={onChange}
            type="number"
            keyboardType="numeric"
            value={value}
            testID="price"
          />
        )}
        name="price"
      />
      {errors.price && <Text>price is required.</Text>}

      <Text>店</Text>
      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            label="店"
            placeholder="Amazon"
            onBlur={onBlur}
            onChangeText={onChange}
            value={value}
            testID="shop"
          />
        )}
        name="shop"
      />
      {errors.shop && <Text>shop is required.</Text>}

      <Button title="登録" onPress={handleSubmit(onSubmit)} testID="submit" />
    </View>
  );
}

金額の入力欄にフォーカスすると数値キーボードが表示されます。

日付入力欄を作成する

以下を確認したところ、日付入力には現在はコミュニティで公開されているライブラリを使ってねということらしいです。

reactnative.dev

とりあえず、スター数の多いreact-native-datetimepicker/datetimepickerをGitHubページを見ながら導入をしました。

github.com

実装自体は下記のようにしています。 また、onChangeを使うと登録ボタンを押した際に日付の変更がうまく反映されなかったため、setValuesで設定するように変更しています。

import { Text, View, TextInput, Button } from "react-native";
import { useForm, Controller } from "react-hook-form";
import DateTimePicker from "@react-native-community/datetimepicker";

export default function Form({ setItems }) {
  const {
    control,
    handleSubmit,
    formState: { errors },
    setValue,
  } = useForm({
    defaultValues: {
      price: null,
      shop: null,
      date: new Date(),
    },
  });
  const setDate = (_event, date) => {
    setValue("date", date);
  };
  const onSubmit = async (data) => console.log(data);

  return (
    <View>
      {/* 省略 */}
      {errors.shop && <Text>shop is required.</Text>}

      <Text>日付</Text>
      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { value } }) => (
          <DateTimePicker
            mode="date"
            onChange={setDate}
            value={value}
            testID="date"
          />
        )}
        name="date"
      />
      {errors.date && <Text>date is required.</Text>}

      <Button title="登録" onPress={handleSubmit(onSubmit)} testID="submit" />
    </View>
  );
}

画面でも問題なく表示ができました。

他のコンポーネントに登録内容を表示する

上記をFormコンポーネントとして、Listコンポーネントへ登録内容を表示する処理の実装を行いました。 (将来的には別画面にするのですが、現状は動作確認も兼ねて行っています。)

この時点でFormコンポーネントの呼び出しが以下のようになっていました。

import React from "react";
import { StyleSheet, SafeAreaView } from "react-native";
import Form from "./components/Form";

export default function App() {
  return (
    <SafeAreaView style={styles.container}>
      <Form />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center",
  },
});

そのため以下の2点を行いました。

  • <Form /> から呼び出し元に値を渡す
  • 呼び出し元から<List />へ値を渡す。

まず、適当に一覧画面用のListコンポーネントを呼び出し、適当な値を渡します。

// 親コンポーネント
import React from "react";
import { StyleSheet, SafeAreaView } from "react-native";
import Form from "./components/Form";
import List from "./components/List";

export default function App() {
  const items = [
    {
      id: 1,
      price: 100,
      shop: "Amazon",
      date: "2023/12/10",
    },
    {
      id: 2,
      price: 100,
      shop: "Amazon",
      date: "2023/12/12",
    },
  ];

  return (
    <SafeAreaView style={styles.container}>
      <List items={items} />
      <Form />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center",
  },
});
// 一覧表示
import React from "react";
import { View, FlatList, Text } from "react-native";

const Item = ({ item }) => (
  <View>
    <Text>
      {item.date}:{item.price}({item.shop})
    </Text>
  </View>
);

export default function List({ items }) {
  return (
    <FlatList
      data={items}
      renderItem={({ item }) => <Item item={item} />}
      keyExtractor={(item) => item.id}
      testID="list"
    />
  );
}

見た目はイマイチですが、画面でも問題なく表示できていそうです。

次に、<Form />から親コンポーネントへのデータの受け渡しを実装しました。 実装自体はReactのuseStateを使って行いました。

ja.react.dev

// 親コンポーネント
import React, { useState } from "react";
import { StyleSheet, SafeAreaView } from "react-native";
import Form from "./components/Form";
import List from "./components/List";

export default function App() {
  const [items, setItems] = useState([
    {
      id: 1,
      price: 100,
      shop: "Amazon",
      date: "2023/12/10",
    },
    {
      id: 2,
      price: 100,
      shop: "Amazon",
      date: "2023/12/12",
    },
  ]);

  return (
    <SafeAreaView style={styles.container}>
      <List items={items} />
      <Form
        setItems={(data) =>
          setItems([...items, { id: items.length + 1, ...data }])
        }
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center",
  },
});
// 登録用コンポーネント
import { Text, View, TextInput, Switch, Button } from "react-native";
import { useForm, Controller, useWatch } from "react-hook-form";
import DateTimePicker from "@react-native-community/datetimepicker";

export default function Form({ setItems }) {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm({
    defaultValues: {
      price: null,
      shop: null,
      date: new Date(),
    },
  });
  const onSubmit = async (data) => {
    await setItems({
      price: data.price,
      shop: data.shop,
      date: data.date.toLocaleDateString(),
    });
  };

  return (
    {/* 省略 */}

画面でも問題なく受け渡しができていそうです。

定期支出登録との画面の切り替えを作る

次に、フォーム画面の、定期支出の登録用トグルと画面の切り替えを実装しました。 1「週間」ごと、1「月」ごとなどの単位選択をプルダウンにしたかったのですが、ReactNativeでは提供していないようなので、以下を参考にreact-native-picker-selectを導入しました。

cpoint-lab.co.jp github.com

トグルでの表示切り替えはuseWatchを使ってトグルの値の監視するようにしました。

react-hook-form.com

また、<Switch />を使うと、値がfalseのときにとくにメッセージのないエラーが出てしまい、バリデーションが通らなくなってしまったいました。 そのため、ここではいったんclearErrorsで対象のエラーを削除しました。 (本当のエラーが出る可能性もあるので、後々良さそうな方法を見つけたら修正したい……)

最終的に以下のようなコードになっています。

import { Text, View, TextInput, Switch, Button } from "react-native";
import { useForm, Controller, useWatch } from "react-hook-form";
import DateTimePicker from "@react-native-community/datetimepicker";
import RNPickerSelect from "react-native-picker-select";

export default function Form({ setItems }) {
  const {
    control,
    handleSubmit,
    formState: { errors },
    setValue,
    clearErrors,
    resetField,
  } = useForm({
    defaultValues: {
      price: null,
      shop: null,
      isRegular: false,
      // isRegular = false の場合に表示される
      date: new Date(),
      // isRegular = true の場合に表示される
      intervalValue: "1",
      unit: "monthly",
    },
  });
  const toggleIsRegular = (isRegular) => {
    setValue("isRegular", isRegular);
    // isRegular = false の際に出る中身のないエラーを消す
    clearErrors("isRegular");
  };
  // isRegular の値を監視
  const isRegular = useWatch({
    control,
    name: "isRegular",
  });
  const onSubmit = async (data) => {
    await setItems({
      price: data.price,
      shop: data.shop,
      date: data.date.toLocaleDateString(),
    });
    resetField("price");
    resetField("shop");
    resetField("intervalValue");
    resetField("unit");
  };

  return (
    <View>
      <Text>金額</Text>
      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            label="金額"
            placeholder="¥"
            onBlur={onBlur}
            onChangeText={onChange}
            type="number"
            keyboardType="numeric"
            value={value}
            testID="price"
          />
        )}
        name="price"
      />
      {errors.price && <Text>price is required.</Text>}

      <Text>店</Text>
      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            label="店"
            placeholder="Amazon"
            onBlur={onBlur}
            onChangeText={onChange}
            value={value}
            testID="shop"
          />
        )}
        name="shop"
      />
      {errors.shop && <Text>shop is required.</Text>}

      <Text>定期支出</Text>
      <Controller
        control={control}
        render={({ field: { value } }) => (
          <Switch
            onValueChange={toggleIsRegular}
            value={value}
            testID="isRegular"
          />
        )}
        name="isRegular"
      />

      <View>
        {isRegular ? (
          <RegularScheduleForm
            control={control}
            setValue={setValue}
            errors={errors}
          />
        ) : (
          <DateForm control={control} errors={errors} />
        )}
      </View>

      <Button title="登録" onPress={handleSubmit(onSubmit)} testID="submit" />
    </View>
  );
}

function DateForm({ control, setValue, errors }) {
  const setDate = (_event, date) => {
    setValue("date", date);
  };
  const isRegular = useWatch({
    control,
    name: "isRegular",
  });

  return (
    <>
      <Text>日付</Text>
      <Controller
        control={control}
        rules={{
          validate: {
            isFilled: (value) => (!isRegular ? !!value : true),
          },
        }}
        render={({ field: { onChange, value } }) => (
          <DateTimePicker
            mode="date"
            onChange={setDate}
            value={value}
            testID="date"
          />
        )}
        name="date"
      />
      {errors.date && <Text>date is required.</Text>}
    </>
  );
}

function RegularScheduleForm({ control, errors }) {
  const isRegular = useWatch({
    control,
    name: "isRegular",
  });

  return (
    <>
      <Controller
        control={control}
        rules={{
          validate: {
            isFilled: (value) => (isRegular ? !!value : true),
          },
        }}
        render={({ field: { onChange, value } }) => (
          <TextInput
            placeholder="1"
            onChangeText={onChange}
            type="number"
            keyboardType="numeric"
            value={value}
            testID="intervalValue"
          />
        )}
        name="intervalValue"
      />

      <Controller
        control={control}
        rules={{ required: true }}
        render={({ field: { onChange, value } }) => (
          <RNPickerSelect
            onValueChange={onChange}
            items={[
              { value: "daily", label: "日" },
              { value: "weekly", label: "週間" },
              { value: "monthly", label: "月" },
              { value: "yearly", label: "年" },
            ]}
            value={value}
          />
        )}
        name="unit"
      />
      <Text>に1回</Text>

      {errors.intervalValue && <Text>intervalValue is required.</Text>}
      {errors.unit && <Text>unit is required.</Text>}
    </>
  );
}

セレクトボックスは問題なく動いていました。

また、トグルでの表示切り替えも問題なさそうでした。

結構長くなってしまったので、今回は一度この辺りで記事は切りたいと思います。 気が向いたら続きも書いてリンクを記載しておきます。

Rails チュートリアルを改めてやってみる 1-3章

概要

今までRails7を触ったことがなかったため、感触を確かめるのも兼ねて改めてRailsチュートリアルをやってみました。

railstutorial.jp

第1章

RailsチュートリアルではGitHub Codespaceを使用していますが、手元でDockerを用いて環境構築を行いました。

開発環境整備

まずは、Railsチュートリアル用のリポジトリをクローンします。 ディレクトリ名は今後の管理を楽にするため短いものへ変更しておきます。

 git clone https://github.com/yasslab/codespaces-railstutorial.git app

クローンが完了したら、appディレクトリ内にDockerfile、ルートにdocker-compose.ymlを配置して、docker compose upを実行します。
演習用環境なので、DBは立てるだけにしました。

FROM ruby:3

RUN apt-get update -qq

WORKDIR /app

COPY Gemfile Gemfile.lock /app/

RUN bundle install

CMD ["rails", "server", "-b", "0.0.0.0"]
services:
  db:
    image: esolang/sqlite3:latest

  app:
    build: ./app
    depends_on:
      - db
    ports:
      - "3000:3000"
    volumes:
      - ./app:/app

http://localhost:3000にアクセスすると、以下のようにチュートリアルの画面が表示されました。

bundle installDockerfileで実行済みなので、これで環境構築は完了になります。

演習でのrubyrailsbundleのバージョン確認はappコンテナにログインして実行しました。

$ docker compose exec app bash
root@933ea42ad62a:/app# ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-linux]
root@933ea42ad62a:/app# rails -v
Rails 7.0.4.3
root@933ea42ad62a:/app# bundle -v
Bundler version 2.4.12

1.3.4 Hello, world! の演習

本編は書いてある通り行っただけなので割愛。 演習は下記のようにしました。

class ApplicationController < ActionController::Base

  def hello
    render html: '¡Hola, mundo!'
  end

  def goodbye
    render html: 'goodbye, world!'
  end
end
Rails.application.routes.draw do
  root "application#goodbye"
end

第2章

docker-compose.ymlDockerfileを使い回して環境を作りました。 railsコマンドはappコンテナ内ですべて実行しました。

演習

userの最初のmicropostを表示する課題については、条件をつけずにuserを新規作成すると以下のエラーが出たので、.exists?で紐づくmicropost存在確認をしました。

class UsersController < ApplicationController
  before_action :set_user, only: %i[ show edit update destroy ]

  # ...

  # GET /users/1 or /users/1.json
  def show
    @first_post = @user.microposts.first
  end

  # ...
end
<h1>Editing user</h1>

<%= render "form", user: @user %>
<%= render @first_post if @user.microposts.exists? %>

<br>

<div>
  <%= link_to "Show this user", @user %> |
  <%= link_to "Back to users", users_path %>
</div>

user作成・更新時のname,emailのバリデーションは以下のようにしました。

class User < ApplicationRecord
  has_many :microposts
  validates :name, presence: true
  validates :email, presence: true
end

第3章

2章と同じく、docker-compose.ymlDockerfileを使い回して環境を作りました。

Contactのページの追加はまず、テストコードに以下を追加しました。 この時点ではRedになります。

require "test_helper"

class StaticPagesControllerTest < ActionDispatch::IntegrationTest
  # ...

  test "should get contact" do
    get static_pages_contact_url
    assert_response :success
    assert_select "title", "Contact | #{@base_title}"
  end
end

static_pages_contact_urlがないとのことなので、routes.rbに追加します。

# rails test
Running 4 tests in a single process (parallelization threshold is 50)
Run options: --seed 31081

# Running:

..E.

Finished in 0.592640s, 6.7495 runs/s, 10.1242 assertions/s.

  1) Error:
StaticPagesControllerTest#test_should_get_contact:
NameError: undefined local variable or method `static_pages_contact_url' for #<StaticPagesControllerTest:0x00007f6e1a73d720>
    test/controllers/static_pages_controller_test.rb:28:in `block in <class:StaticPagesControllerTest>'

4 runs, 6 assertions, 0 failures, 1 errors, 0 skips
Rails.application.routes.draw do
  get 'static_pages/home'
  get 'static_pages/help'
  get 'static_pages/about'
  get 'static_pages/contact'
  root 'application#hello'
end

再度テストを実行すると、StaticPagesControllercontactアクションがないとのことなので追加します。

# rails test
Running 4 tests in a single process (parallelization threshold is 50)
Run options: --seed 36012

# Running:

E...

Finished in 0.620830s, 6.4430 runs/s, 9.6645 assertions/s.

  1) Error:
StaticPagesControllerTest#test_should_get_contact:
AbstractController::ActionNotFound: The action 'contact' could not be found for StaticPagesController
    test/controllers/static_pages_controller_test.rb:28:in `block in <class:StaticPagesControllerTest>'

4 runs, 6 assertions, 0 failures, 1 errors, 0 skips
class StaticPagesController < ApplicationController
  def home
  end

  def help
  end

  def about
  end

  def contact
  end
end

再度テストを実行すると、テンプレートがないとのことなので追加します。

# rails test
Running 4 tests in a single process (parallelization threshold is 50)
Run options: --seed 30891

# Running:

...E

Finished in 1.012043s, 3.9524 runs/s, 5.9286 assertions/s.

  1) Error:
StaticPagesControllerTest#test_should_get_contact:
ActionController::MissingExactTemplate: StaticPagesController#contact is missing a template for request formats: text/html
    test/controllers/static_pages_controller_test.rb:28:in `block in <class:StaticPagesControllerTest>'

4 runs, 6 assertions, 0 failures, 1 errors, 0 skips

ファイル追加後、再度テストを実行すると、タイトルがおかしいとのことなので、追加します。

# rails test
Running 4 tests in a single process (parallelization threshold is 50)
Run options: --seed 19525

# Running:

.F..

Finished in 0.693485s, 5.7680 runs/s, 11.5359 assertions/s.

  1) Failure:
StaticPagesControllerTest#test_should_get_contact [/app/test/controllers/static_pages_controller_test.rb:30]:
--- expected
+++ actual
@@ -1 +1 @@
-"Contact | Ruby on Rails Tutorial Sample App"
+"| Ruby on Rails Tutorial Sample App"
.
Expected 0 to be >= 1.

4 runs, 8 assertions, 1 failures, 0 errors, 0 skips
<% provide(:title, "Contact") %>

再度テストを実行すると、無事Greenになりました。

# rails test
Running 4 tests in a single process (parallelization threshold is 50)
Run options: --seed 49162

# Running:

....

Finished in 0.618450s, 6.4678 runs/s, 12.9356 assertions/s.

4 runs, 8 assertions, 0 failures, 0 errors, 0 skips

コンテンツを追加して、ブラウザでhttp://localhost:3000/static_pages/contactアクセスすると以下のように表示できました。

(もう少し段階を踏むのであれば、assert_response :successが通ることを確認してから、assert_select "title", "Contact | #{@base_title}"が通ることを確認するテストを追加するのでしょうが、今回はやることが明白だったためまとめました)

Docker を用いた Flutter の環境構築

概要

以下を読みながら、DockerでFlutterの環境構築を行いました。
結局使用はしなかったのですが、備忘として記録します。

https://docs.flutter.dev/get-started/install/linux

手順

1. パッケージをインストール

以下に記載されているライブラリのうち、初期のDockerイメージにインストールされていないものをインストールします。

https://docs.flutter.dev/get-started/install/linux#system-requirements

今回はDockerイメージとして、Ubuntu:22.04を使用するので、以下のようなDockerfileを作成しました。

FROM ubuntu:22.04

# 1. パッケージをインストール
RUN apt update -qq && apt install curl file git unzip xz-utils zip clang cmake git ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev libsystemd0 -y -qq
version: '3'
services:
  app:
    build: ./app
    entrypoint: bash -c "flutter run -d web-server --web-port 8080 --web-hostname 0.0.0.0"
    ports:
      - 8080:8080
    volumes:
      - ./app:/app
    tty: true

2. SDKをインストール

以下のManual installationで記載の方法でSDKをDockerコンテナにインストールします。

https://docs.flutter.dev/get-started/install/linux#method-2-manual-installation

Dockerfileは以下のようになります。

FROM ubuntu:22.04

# 1. パッケージをインストール
RUN apt update -qq && apt install curl file git unzip xz-utils zip clang cmake git ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev libsystemd0 -y -qq

# 2. SDKをインストール
## インストール先のディレクトリを準備
WORKDIR /usr/local/bin

## SDKをダウンロードして解凍
RUN curl -O https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.13.9-stable.tar.xz
RUN tar xf flutter_linux_3.13.9-stable.tar.xz

## パスを設定
ENV PATH=$PATH:/usr/local/bin/flutter/bin

## 実行ディレクトリを設定
WORKDIR /app

3. Dockerコンテナ上でアプリを作成

以下を参考に/appディレクトリ配下で以下のコマンドを実行します。 https://docs.flutter.dev/get-started/test-drive?tab=terminal

$ flutter create .

4. flutter runを実行

以下の「Run the app」を参考にflutter runコマンドを実行します。
https://docs.flutter.dev/get-started/test-drive?tab=terminal

ポートとホストは以下で明示的に指定する方法が紹介されていたので、同じように設定します。 https://note.com/danchi_kun/n/n5747fa9eb748

$ flutter run -d web-server --web-port 8080 --web-hostname 0.0.0.0

上記をentrypointに設定します。

version: '3'
services:
  app:
    build: ./app
    entrypoint: bash -c "flutter run -d web-server --web-port 8080 --web-hostname 0.0.0.0"
    ports:
      - 8080:8080
    volumes:
      - ./app:/app
    tty: true

その後、docker compose upで実行し、ブラウザでhttp://localhost:8080にアクセスすると、以下の画面が表示されます。

ローカル環境で Docker コンテナ間の通信とサービス名を使ったブラウザからのアクセスを実現する

概要

ローカルでコンテナ間の通信とホストからの通信で同じURLを使ってリクエストを送信できるようにしました。 Dockerだけではなく、ホストの設定もコンテナサービス名に合わせて設定をして解決しています。

構成

  • Docker:24.0.6
  • Node.js:20.9.0
  • Express.js:4.18.2
  • EJS:3.1.9

手順

0. 事前準備

まず、今回使用するサーバーの立ち上げをします。 最初に各コンテナ用のディレクトリを作成します。今回作成するディレクトリ名は app1app2とします。

$ mkdir app1 app2

次に各ディレクトリに以下のファイルを格納します。

  • package.json
{
  "dependencies": {
    "ejs": "^3.1.9",
    "express": "^4.18.2"
  }
}
  • Dockerfile
FROM node:20

WORKDIR /web

COPY ./package.json .

RUN yarn install

COPY . .

次にapp1ディレクトリに以下のファイルを設置します。

  • index.js
const express = require('express')
const app = express()

app.set('view engine', 'ejs')

app.get('/', async (_, res) => {
  res.render('index')
})

app.listen(9000)

さらに、app1ディレクトリ内にviewsディレクトリを作成し、viewsディレクトリ内に以下のファイルを設置します。

  • index.ejs
<section>
  <p>
    Hello, I'm app1!
  </p>
</section>

同じようにapp2ディレクトリ内にも同じようにindex.jsindex.ejsを作成します。 このときindex.jsで指定するポート番号はapp1index.jsで指定したものとは異なるポート番号を指定します。

  • index.js
const express = require('express')
const app = express()

app.set('view engine', 'ejs')

app.get('/', async (_, res) => {
  res.render('index')
})

app.listen(9001)
  • index.ejs
<section>
  <p>
    Hello, I'm app2!
  </p>
</section>

最後app1app2の親ディレクトリに以下のファイルを作成します。

  • docker-compose.yml
version: "3.9"
services:
  app1:
    build: ./app1
    command: >
      bash -c "yarn install && node src/index.js"
    ports:
      - "9000:9000"
    volumes:
      - ./app1/:/web/

  app2:
    build: ./app2
    command: >
      bash -c "yarn install && node src/index.js"
    ports:
      - "9001:9001"
    volumes:
      - ./app2/:/web/

この状態でdocker compose upを実行し、ブラウザでhttp://localhost:9000http://localhost:9001にアクセスすると、以下のようなページが表示されます。

  最終的なファイル構成は以下のようになります。

.
├── app1
│   ├── Dockerfile
│   ├── index.js
│   ├── package.json
│   └── views
│       └── index.ejs
├── app2
│   ├── Dockerfile
│   ├── index.js
│   ├── package.json
│   └── views
│       └── index.ejs
└── docker-compose.yml

2. /etc/hosts の編集

この状態で、今回の条件の一つであるサービス名を使ってお互いのコンテナにリクエストを送ることはできるようになっています。

例えばapp1からapp2curlコマンドで GET リクエストを送信する場合、http://app2:9001に向けてリクエストを送信することで、コンテナ間の通信ができるようになっています。

# curl http://app2:9001 # app2 に GET リクエストを送信
<h1>Client - Index</h1>
<section>
  <p>
    Hello, I'm app2!
  </p>
</section>

しかし、現状ではブラウザでhttp://app1:9000http://app2:9001にアクセスしてもエラーになるので、ブラウザからアクセスができるようにします。
とは言ってもこちらの解決策は Docker の設定はほぼ関係なく、ホスト側の/etc/hostsに IP アドレスとコンテナのサービス名の組み合わせを追加するだけです。

$ sudo sh -c "echo '127.0.0.1 app1\n127.0.0.1 app2' >> /etc/hosts"
$ cat /etc/hosts
# 省略
127.0.0.1 app1
127.0.0.1 app2

この状態でブラウザでhttp://app1:9000http://app2:9001にアクセスすることで、ブラウザに以下の画面が表示されるようになります。

これでコンテナ間の通信、ホストからの通信を気にせず、同じ URL を使ってリクエストを送信できるようになりました。

課題

ホスト側の設定をいじって解決しているので、同じことを他のアプリケーションで行おうとするとそれだけホストの/etc/hostsの記述が増えることになります。
(他のアプリケーションの開発もすると考えると、Docker 側でうまく設定をまとめることができたらと思ったのですがうまくいかず……)

まとめ

ローカルでの学習用の際に、コンテナ間の通信か、ホストからの通信か細かく区別するのが面倒くさかったため、上記のような方法を取りました。
コンテナの IP を固定という方法もあったようなのですが、サーバーへのアクセスのたびに IP アドレスをいちいち書くのは面倒くさいので、採用はしませんでした。

プライベートな学習メモをGitHub Issueで実現する

概要

技術書や資格の勉強をしている際のメモに、GitHub の Issue を使ったところ、使い勝手は悪くなかったので、自分の使い方を紹介します。

対象

以下の全てを満たす人を想定します。

  • 手軽にメモをとりながら作業、勉強をしたい
  • でも外部に作業、勉強途中の雑なメモを公開するのは恥ずかしい
  • GitHub 使い方は最低限わかる

注意点

一般に想定されるGithub Issueの使い方とは異なるので注意してください。
以下は自分の自習用リポジトリでの実施を想定しています。

使い方

1. プライベートリポジトリを作成

ここでは人に見られるのが恥ずかしいので、プライベートで作成します。
すでに TIL リポジトリがあればそれを使っても問題ありません。

2. リポジトリの Issues タブから新規 Issue を作成

私の場合タイトルは読んでいる本のタイトルを入れています。
コメントには以下のように目標などを入れてみるもの良いかもしれません。

3. 疑問点や考えたこと、追加で調べたことをどんどん書き込む

2.で作成した Issue に疑問に思ったことや、考えたことなどを書き込んで投稿します。 自分は章・節などキリの良い単位で読んでることが多いので、その単位でコメントを追加することが多いです。

4. 一通り学習が終わったら Close する

Issues のリストで学習中の内容と学習完了した内容が混ざらないようにクローズしておきます。

メリット

現状感じているメリットは以下です。

  • その時気になったことを時系列でざっくりメモして残しておける
  • 誰にも見られないため、羞恥で手を止めることがない
  • 学習開始、終了が Issue の作成、クローズで意識せずに管理できる
  • スマホからも書き込めて、PC からも確認ができるので場所を選ばず勉強ができる

デメリット

デメリットとしては以下かなと考えています。

  • contributions に草が生えない
  • 人に見せない前提で書くので、メモの内容が雑
  • 人に教えることでより技術が身につくと考えると、学習効果としては微妙かも?

まとめ

巷では雑多なメモでも良いので公開すべきという言説もありますが、公開のプレッシャーで手が止まってしまうのは本末転倒だよなあと思い、上記の手段を取るに至りました。 多少なりとも自信がついてきたら、上記のようなメモも外へ出す方向に移行できればと思います。