20210103のReactに関する記事は13件です。

React with Babylon.js での注意事項(随時更新)

お仕事で React と Babylon.js を組で使用することになり、色々調べて得られた知見をまとめます。
サンプルコードを GitHub に置いたので、必要に応じてこちらも参照してみてください。

なお、この記事は随時更新予定です。

Babylon.js コンポーネント実装の基本

React では useRefuseEffect を使って Babylon.js のためのコンポーネントを実装するのが王道のようです。

import { useEffect, useRef } from "react";
import { Engine } from '@babylonjs/core';

const GameCanvas = () => {
  const renderCanvas = useRef(null);

  useEffect(() => {
    const engine = new Engine(renderCanvas.current);
    // ... 以下略 ...
  }, [renderCanvas]);

  return (
    <canvas ref={renderCanvas}></canvas>
  );
};

export default GameCanvas;

HTML 要素と Babylon.js の連携

HTML で描画したボタンで Babylon.js の状態を変更するような場合、React Redux の導入がほぼ必須になります。

React Redux を導入しない場合、コンポーネントの単位を大きくするか、コールバックのバケツリレーを行うか、どちらかをしなければなりません。基本的にどちらもやりたくない作業なので、素直に React Redux を導入しましょう。

React Redux を導入する際に迷うのは、canvas 要素の無駄な再描画を回避するために、どのように状態を取得するべきかという点です。つまり、コンポーネントの中で、

const something = useSelector(state => state.something);
game.setSomething(something);

のように useSelector で値を取得しても問題ないのか、あるいは、

const store = useStore();
const state = store.getState();
store.subscribe(() => {
  game.setSomething(state.something);
});

のように Store に直接コールバックを設定する必要があるのか、です。
再描画の計算コストはそれなりに大きそうなので、できるだけ再描画されない方法で管理したいですよね。

結論ですが、React では DOM に差分がある時だけ再描画を行うので、Store から取得した値をコンポーネントから返却する JSX の中で使用しない限り、再描画は発生しません。なので、基本的には前者のように useSelector を使用しても問題ありません。ただし Redux 取得した値を JSX に引き渡す必要がある場合は後者の形で実装する必要があります。

React の再描画については以下の記事(英語)が参考になります。

タブ切り替え時の canvas 要素

タブ切り替えなど、画面内の canvas 要素を出したり消したりする必要がある場合、Babylon.js コンポーネント実装の基本 で示したナイーブな実装では問題が生じることがあります。canvas を消すタイミングで生成された Engine が放棄され、再表示の際に新たな Engine が生成されてしまうためです。

canvas が非表示にされても Babylon.js の状態を維持したい場合、以下の2つの選択肢が考えられます:

  1. canvas の非表示処理を display='none' スタイルで実装する
  2. Babylon.js のオブジェクトを管理するクラスをシングルトンで実装する

お手軽なのは 1. です。react-tabsforceRenderTabPanel のように、オプションが用意されている場合も多いので、使用するパッケージの詳細を調べてみるとよいでしょう。

シングルトンで解決する場合には canvas 要素の扱いに注意が必要です。そもそも Babylon.js の Engine は canvas 要素なしにはインスタンス生成ができません。

この制約がシングルトンの実装を微妙にややこしくします。悩むのは「シングルトンの Engine をいつ生成するか?」という点です。シングルトンのインスタンス取得時に Engine を生成する場合、あらかじめ canvas を引き渡さなければならないので、

const game = GameFactory.getInstance(renderCanvas.current);

となり、getInstance のインターフェースが不便極まりないものになります。

対して initialize メソッドを追加し、Engine の生成を遅延すると、

const game = GameFactory.getInstance();
game.initialize(renderCanvas.current);

となり、表面上の利便性は上がります。しかし、上記のコードをコンポーネントに記述した場合、canvas を再描画するたびに initialize がコールされるため、initialize の実装はステートフルなものにならざるを得ません。実装イメージは以下の通りです。

let instance = null;

export const GameFactory = {
  getInstance: () => {
    if (instance === null) {
      instance = new Game();
    }
    return instance;
  }
};

class Game {
  initialize(canvas) {
    if (this.engine === null) {
      this.engine = new Engine(canvas);
      // ... 以下略 ...
    } else {
      // 2度目以降の処理をここに書く
    }
  }
}

どちらも避けたい方法ですね。

納得いかなかったので色々試行錯誤した結果、document.createElementEngine 生成のための canvas を生成し、canvas は EngineregisterView を使って登録するようにすると、ある程度キレイに実装できるようです。以下にコードを示します。

let instance = null;

export const GameFactory = {
  getInstance: () => {
    if (instance === null) {
      instance = new Game();
    }
    return instance;
  }
};

class Game {
  constructor() {
    // 架空の canvas を生成して Engine の生成に使用する
    const canvas = document.createElement('canvas');
    this.engine = new Engine(canvas);
    // ... 以下略 ...
  }

  registerView(canvas) {
    // canvas を登録する(ゴミが残らないように一度クリアしている)
    this.engine.views = [];
    this.engine.registerView(canvas);
  }
}

コンポーネント側のコードは以下のようになります。

import { useEffect, useRef } from "react";
import { GameFactory } from './3d/game';

const GameCanvas = () => {
  const renderCanvas = useRef(null);
  const game = GameFactory.getInstance();

  useEffect(() => {
    game.registerView(renderCanvas.current);
  }, [renderCanvas, game]);

  return (
    <canvas ref={renderCanvas}></canvas>
  );
};

export default GameCanvas;
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactで複数ページを作る

reactを使う方の多くはシングルページで実装している方が多いとも聞きますが複数ページで作成した時もあります。

今回はreactで複数ページを作る学習をしたので次回使う時に苦労しないように今回はここにやり方をアウトプットします。

事前準備

今回はフロントエンド側のアプリ開発なのでわかりやすくfrontendというなめでアプリを作ります。

create-react-app frontend

複数ページを作るにはルーティングのライブラリであるreact-router-domを使います。

reactのアプリに移動したのちライブラリをインストールします。

cd fronend
npm install react-router-dom

リンクを貼るコンポーネントを作成する

一部話が脱線してしまいますがコンポーネントについても簡単に説明します。

コンポーネントはパーツの一部のような物で他のファイルで作ったjsファイルをindex.jsでReactDomで読み込み、index.htmlにあるdivのidなどに埋め込むことができます。

https://qiita.com/tsuuuuu_san/items/58f82201ded0da420201

コンポーネントで作ったファイルに転送用のリンクを作る

Routerを必ず下のAPP.jsの中にRouterを先に作らなければエラーになります。

Router内にあるリンクをクリックするとRouterに記述したHomeやAboutのコンポーネントを呼び質すことができます。

このファイルのタグはベースになって他のコンポーネントにアクセスしてもそのままブラウザに表示されます。

import './App.css';

import { BrowserRouter as Router, Route } from 'react-router-dom';
import Ff from './nv';
import Home from './Home';
import About from './About';

function App() {
  return (
    <div className="App">
      <Router>
          <div>
            <Ff /><hr/>
            <Route exact path='/' component={Home}/>
            <Route path='/About' component={About}/>
          </div>
        </Router>
    </div>
  );
}

export default App;

参考サイト

https://qiita.com/k-penguin-sato/items/e46725edba00013a8300

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactでいい感じのサブウィンドウを実装する ⑤ 表示する場所を指定する

Reactでサブウィンドウを表示する際に表示する場所を指定したい時が、よくあると思います。

今回は、この方法について紹介します。

やり方

Rndのdefaultプロパティに値を指定してあげると指定した箇所に表示することができます

例1 画面の真ん中にサブウィンドウを表示する

<Rnd
  default={{ 
    x: document.documentElement.clientWidth / 2, 
    y: document.documentElement.clientHeight / 2
  }}
>
  test
</Rnd>

例2 画面の右上に表示する

<Rnd
  default={{ 
    x: 0, 
    y: 0
  }}
>
  test
</Rnd>

おまけ

defaultプロパティにwidthとheightを指定すると、 サブウィンドウの初期表示時のサイズを調整できます。

<Rnd
  default={{ 
    x: 0, 
    y: 0,
    width: 200,
    height: 100
  }}
>
  test
</Rnd>

最後に

今回は、サブウィンドウの初期表示の設定方法について説明しました。

最後まで読んでよかったらLGTMお願いします!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactでいい感じのサブウィンドウを実装する ⑤ 初期表示の設定方法

シリーズ記事です。
初めての方は、こちら を最初に読んでください

今回は、Reactでサブウィンドウの初期表示の設定方法について説明していきます。

やり方

Rndのdefaultプロパティに値を指定してあげると指定した箇所に表示することができます

例1 画面の真ん中にサブウィンドウを表示する

<Rnd
  default={{ 
    x: document.documentElement.clientWidth / 2, 
    y: document.documentElement.clientHeight / 2
  }}
>
  test
</Rnd>

例2 画面の右上に表示する

<Rnd
  default={{ 
    x: 0, 
    y: 0
  }}
>
  test
</Rnd>

おまけ

defaultプロパティにwidthとheightを指定すると、 サブウィンドウの初期表示時のサイズを調整できます。

<Rnd
  default={{ 
    x: 0, 
    y: 0,
    width: 200,
    height: 100
  }}
>
  test
</Rnd>

最後に

今回は、サブウィンドウの初期表示の設定方法について説明しました。

最後まで読んでよかったらLGTMお願いします!

まとめ記事 もあるのでよければこちらもみてください

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Next.js】Routingを基礎からしっかり。

前書き

筆者がNext.jsを仕事で書くことになったので、1から勉強するためにアウトプット記事を書くことにしました。
基本的にはドキュメントを噛み砕いて、翻訳した記事です。間違っているところなどあれば、ご指摘していただけるとありがたいです?‍♂️

以下、本題です。

Routing

Next.jsは"page"というコンセプトに基づいて形成された、ファイルシステムベースのルーターがあります。

つまりルールに従って、ファイルを配置すればNext.js側で自動的にルーティングを設定してくれるよ、と言うことです。

pagesディレクトリ配下にファイルを追加すれば、自動的にルーティングが作成されます。

Index routes

indexと言う名前のファイルを自動的にそのディレクトリのrootとしてルーティングします。

pages/index.tsx => `/`
pages/post/index.tsx => `/post`

みたいな感じです。

Nested routes

ルーターは入れ子になったファイルもサポートしています。入れ子構造を持つフォルダーを作成すると、自動的に同じ構造のルーティングを作成してくれます。

pages/hoge.tsx => `/hoge`
pages/post/hoge/foo.tsx => `/post/hoge/foo`

Dynamic route segments

動的なルーティングはブランケット構文を使用することで、実現できます。

pages/post/[hoge].tsx => '/post/:hoge'
pages/post/[:postId] => '/post/:postId'

こんな感じでIDを指定してあげれば、「詳細ページ」みたいなのも簡単に実現できますね。

Linking between pages

Next.jsのルーターはSPAのようにクライアントサイドでページ間をルート遷移することができます。

クライアントサイドでルート遷移をするためには<Link>コンポーネントを使う必要があります。

import Link from 'next/link'

const Home = () => {
  return (
    <>
      <Link href="/">
        <a>Home</a>
      </Link>
      <Link href="/hoge">
        <a>hoge</a>
      </Link>
      <Link href="/Foo">
        <a>foo</a>
      </Link>
    </>
  )
}

上記に例では<a>タグをラップし、Linkコンポーネント内の属性でhrefを指定していますね。上から

  • / => pages/index.tsx
  • /hoge => pages/hoge.tsx
  • /foo => pages/foo.tsx

を表示してくれます。

Linking to dynamic paths

動的なpathに対しては、どのようにLinkコンポーネントを指定してあげれば良いでしょうか。

encodeURIComponentを使っていい感じに書く方法もありますが、個人的にはURLオブジェクトを使う方が見やすいです。

import Link from 'next/link'

const Home = ({ post }) => {
  return (
    <Link 
      href={{
        pathname: 'post/[postId]'
        query: {postId: post.id},
      }}
    >
      <a>post.name</a>
    </Link>
  )
}

pathnameでpathを指定し、queryの部分で動的なクエリパラメータを指定してあげています。

以上です。お疲れ様でした。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

reactの勉強1 -reducerを使って入力欄をつくる-

はじめに

reactの勉強をするので、記録を残す。
今回は以下の「12.イベントの状態遷移を管理する」を見ながら勉強している。
https://www.udemy.com/share/101tSGB0cYcFZVQnw=/

覚えておくことリスト

State…状態
Store…Stateの保管場所
Action…状態の更新を指示する
Dispatch…ActionをStoreに送る
Reducer…Actionの指示に応じてStateをどうこうする

やりたいこと

幽霊屋敷の生存者リストを作成する。
幽霊屋敷の生存者を追加したり、削除したりいろいろ。

やったこと

src/reducers/index.js
const events = (state = [], action) =>{
  switch (action.type) {
    case 'ADD_PEOPLE':
      const people = { name: action.name, body: action.body }
      const length = state.length
      const id = length == 0 ? 1 : state[length - 1].id + 1
      return [...state, { id, ...people }]
    case 'LEAVE_PEOPLE':
      return state
    case 'LEAVE_ALL_PEOPLE':
      return []
    default:
      return state
  }
}

eventsというコンポーネントを作成する。
このコンポーネントでは幽霊屋敷の訪問者の状態を

  • 追加する(case 'ADD_PEOPLE'
  • 消える(case 'LEAVE_PEOPLE'
  • すべて消える(case 'LEAVE_ALL_PEOPLE'
  • 現状(default

という四種類のタイプで定義する。

src/components/App.js
const App = () => {
  const [state, dispatch] = useReducer(reducer, [])
  const [name, setName] = useState('')
  const [body, setBody] = useState('')

  const addPeople = e =>{
    e.preventDefault()
    dispatch({
      type: 'ADD_PEOPLE',
      name,
      body
    })
    setName('')
    setBody('')
  }

  return (
    <div className="container-fluid">
      <h4>幽霊屋敷の生存者作成</h4>
      <form>
        <div className="form-group">
          <label htmlFor="formPeopleName">訪問者名</label>
          <input className="form-control"
                 id="formPeopleName"
                 value={name} 
                 onChange={e =>
                 setName(e.target.value)} />
        </div>

        <div className="form-group">
          <label htmlFor="formPeopleBody">訪問者のプロフィール</label>
          <input className="form-control"
                 id="formPeopleBody"
                 value={body}
                 onChange={e =>
                 setBody(e.target.value)} />
        </div>

        <button className="btn btn-primary" onClick={addPeople}>上記の人物が屋敷に訪れる</button>
        <button className="btn btn-danger">すべての人間が姿を消す</button>
      </form>

      <h4>生存者一覧</h4>
      <table className="table table-hover">
        <thead>
          <tr>
            <th>ID</th>
            <th>生存者名</th>
            <th>プロフィール</th>
          </tr>
        </thead>
      </table>
    </div>
  )
}

addPeopleというコンポーネントを定義し、ここでsrc/reducers/index.jsで定義した訪問者の追加(ADD_PEOPLE)を利用して入力欄(人物名/人物のプロフィール)に追加したい訪問者の情報を入力する部分まで定義する。

※ ここまでの段階では追加ボタンをクリックしても生存者一覧に表示することはできないが、実際に入力した内容が渡っているかどうか確認したい場合はaddPeople内でconsole.logを使って確認できる。

const addPeople = e =>{
    e.preventDefault()
    dispatch({
      type: 'ADD_PEOPLE',
      name,
      body
    })
    setName('')
    setBody('')

    console.log({ name, body })
  }

スクリーンショット 2021-01-03 14.59.31.png

スクリーンショット 2021-01-03 15.00.24.png

正直reactのことはよくわかっていない。このStateやらDispatchやらもなんとなくの感覚でつかってみたがやっぱりよくわかっていない。この先どうなってしまうのか

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactでいい感じのサブウィンドウを実装する ④ ウィンドウ動かす機能をON/OFF切り替える

シリーズ記事です。
初めての方は、こちら を最初に読んでください

Reactでいい感じのサブウィンドウを表示する際にウィンドウ動かす機能をON/OFF切り替えたい場面がよくあるので、

今回は、この方法について紹介します。

Videotogif (2).gif

やり方

RndのdisableDraggingというプロパティに値を指定してあげるとON/OFFの切り替えがでできます。

例1 ウィンドウを動かす機能 ON 

<Rnd disableDragging={false}>test</Rnd>

※ デフォルトは falseなので指定しなくても大丈夫です。

例2 ウィンドウを動かす機能 OFF

<Rnd disableDragging={true}>test</Rnd>

例3 サンプルGif

import "./App.css";
import React, { useState } from "react";
import { Rnd } from "react-rnd";

function App() {
  const [isDisableDragging, setDisableDragging] = useState(false);

  return (
    <div className="App">
      <header className="App-header">
        <button onClick={() => setDisableDragging(!isDisableDragging)}>
          ウィンドウを動かす機能 {isDisableDragging ? "OFF" : "ON"}
        </button>
        <Rnd
          style={{
            backgroundColor: "#fff0d8"
          }}
          default={{
            x: 0,
            y: 0,
            width: 320,
            height: 200
          }}
          disableDragging={isDisableDragging}
        >
          <span
            style={{
              color: "#000f27"
            }}
          >
            {isDisableDragging ? "動きません" : "動きます"}
          </span>
        </Rnd>
      </header>
    </div>
  );
}

export default App;

最後に

プロパティを一つ指定するだけでウィンドウを動かす機能のON/OFFの切り替えができるようになりましたね

次は、ウィンドウの初期表示の設定方法
ついて紹介していきます。

最後まで読んでよかったらLGTMお願いします!

まとめ記事 もあるのでよければこちらもみてください

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactでいい感じのサブウィンドウを実装する ③ リサイズ機能をON/OFF切り替える

シリーズ記事です。
初めての方は、こちら を最初に読んでください

Reactでいい感じのサブウィンドウを表示する際にリサイズ機能をON/OFF切り替えたい場面がよくあるので、

今回は、この方法について紹介します。

やり方

RndのdisableDraggingというプロパティに値を指定してあげるとON/OFFの切り替えがでできます。

例1 リサイズ機能ON 

<Rnd enableResizing={true}>test</Rnd>

※ デフォルトは trueなので指定しなくても大丈夫です。

例2 リサイズ機能OFF

<Rnd enableResizing={false}>test</Rnd>

最後に

プロパティを一つ指定するだけでリサイズ機能のON/OFFの切り替えができるようになりましたね

次は、ウィンドウ動かす機能のオンオフのやり方について紹介していきます。

最後まで読んでよかったらLGTMお願いします!

まとめ記事 もあるのでよければこちらもみてください

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

material-tableで先頭行を固定する方法

テーブルの先頭行を固定したい。
たったこれだけのことでハマってしまったので備忘として残します。

まず material-table とは

この説明を省くと誤解される可能性があるので。
これです↓
https://material-table.com/#/

material-uiをベースに作られた簡単にデータテーブルを実装できるOSSです。
簡単にフィルター、検索、ソートなどの機能をつけられるので、
material-uiのDataGridで頑張る必要がありません。

先頭行を固定する方法

material-tableを編集したい場合は
<MaterialTable />内にプロパティを設定することで簡単に編集できます。

プロパティ一覧↓
https://material-table.com/#/docs/all-props

先頭行を固定したい場合はheaderStyleを編集すればいいので

table.js
const DataTable = () => {
    return (
      <MaterialTable
        title="DataTable"
        columns={[
            //省略
        ]}
        data={[
            //省略
        ]}        
        options={{
            headerStyle: {position: 'sticky', top: 0},
        }}
      />
    )
}

このようにプロパティにoptionsheaderStyleと書いて
position: 'sticky', top: 0とするだけ。

...と思っていたのですが、これだけでは効きませんでした。

効かなかった理由

material-tableを使うとtableより上の階層にいくつかdivが作られるのですが、
そのうちの2つでoverflowが初期値ではなく別の値に書き換えられていたことが原因でした。

https://termina.io/posts/position-sticky-and-overflow-property

position: stickyは祖先要素にvisible以外のoverflow属性が指定してあると期待通りに動作しない場合があります。

解決方法

原始的な方法ですが、対象のスタイルを上書きして解決しました。

App.css
.Component-horizontalScrollContainer-19,
.Component-horizontalScrollContainer-19 div {
  overflow: visible !important;
}

最後に

もっと綺麗な解決方法がないかなーとドキュメントを眺めていたらこんな一文が。
https://material-table.com/#/docs/features/component-overriding

Container that everything renders in

これを見る限りContainerコンポーネントのオーバーライドで解決できそうな気がしますが、ちょっと難しそうなので触れていません。笑

もし詳しい方がいましたら教えてください!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

フロントエンド開発における状態管理の違い(MVCとFlux/Redux)

フロントエンド開発における状態管理とは

そもそも「フロントエンド開発」というワードだけでも、企業や案件によって定義が微妙に違ってくる。具体的にいうと、

  • LP制作(ここでは主にコーディング、場合によってはデザイナー)
  • HTML / CSS / JSを利用した動的Webサイトの作成(WordPressやMovableTypeなど)
  • ReactやVue.jsなどを利用したSPA開発
  • サーバ側とのやりとりをも含めた大規模開発(クライアント側)

などの違いがあるのかなと。
個人的には全てフロントエンド開発だとは思っているが、それぞれ領域が微妙に違うので、それによって求められるスキルも違ってくる。
ただ、ここにおける「状態管理」とは、コンポーネント指向におけるフレームワーク・ライブラリを利用したコードにて利用するものなので、今回はSPA開発としてのライブラリであるReactと、状態管理によく利用されるReduxについて、自分の理解を深めるために整理していこうと思う。

Client-side MVC

  • M(Model): データ構造やUIの状態など、アプリケーションのデータを監視。
  • V(View): Modelの値を表示する出力の役割。
  • C(Controller): Modelへ変更を伝える入力の役割。
// Modelの作成
class Model {
    constructor() { // 初期値の設定
        this.count = 0;
    }

    increment() { // 関数increment()。値の変更の際に関数trigger()を発動。
    this.count++;
    this.trigger();
    }

    trigger() { // アプリケーション全体に"count/increment"というメッセージを通知。
        const event = new CustomEvent("count/increment", {
            count: this.count
        });
        window.dispatchEvent(event);
    }
}

// View, Controllerの作成
class ViewController {
    constructor() {
        this.model = new Model();
        this.$element = document.getElementById("app");
        this.$button = document.getElementById("button");
    }
    mount() {
        this.render();
        this.$button.addEventListener("click", (e) => this.onClick(e));
        window.addEventListener("count/increment", (e) => this.onMessage(e));
    }
    render() {
        this.$element.innerHTML = `<p>${this.model.count}</p>`:
    }
    onClick(event) { // Controllerの部分
        this.model.increment();
    }
    onMessage(event) { // Controllerの部分
        this.render();
    }
}

const view = new ViewController();
view.mount();

クライアントMVCにおけるModelとViewの役割

  • サーバサイドMVCのModelであれば、DBのentityが必要となってくる。がしかし、クライアント側になってくると、DBに加えてUIのための抽象化された固有のデータモデルも必要となってくる。
  • DBとUIが紐付きあっているため、ViewとModelの関係は複雑化していき、関係性も1対1ではなくなってくる。
  • これらのことから、規模が大きくなった際に抽象化されたデータが双方向に行き来するため、開発者の認知の範囲を超えてきがちになってくる。

クライアントMVCにおけるControllerの役割の曖昧さ

  • サーバーサイドフレームワークにおけるControllerの役割は、「リクエストに付随するメタ情報を入力とし、適切なレスポンス(またはビュー)を返却する」ことである。
  • フロントエンドにおけるControllerは、「OSやバージョンなどのユーザー環境を入力の1つとしてViewに出力する」役割がある。また、滞在時間が長い場合は、HTTPリクエストとしてレスポンスで完結せずに、ユーザー操作を受けてさらに出力を繰り返す長いライフサイクルを持つこととなる。

FluxとRedux

Fluxのおさらい

Flux is a pattern for managing data flow in your application. The most important concept is that data flows in one direction.
Fluxはアプリ内でデータフローを管理するためのパターン。単方向でのデータフローであることが最大のコンセプト。(公式より抜粋)

flux-simple-f8-diagram-with-client-action-1300w.png

  • Action: アプリにおける内部APIとなるもの。Storeに存在するデータを更新。
  • Dispatcher: それぞれのActionを受け取り、それぞれのStoreに送信する。
  • Store: アプリケーションが保持しているデータや状態。
  • View: StoreからのDataをviewとして表示。

ReduxにおけるFlux

Screenshot 2021-01-03 at 9.56.19.png

  • Reducers: 現在のState(状態)やActionを受け取る関数。状態をupdateしたり新しいstateにしたりを決定する。
import { createStore, combineReducers } from "redux";

// Action
const COUNT_INCREMENT = "count/increment";

// Reducer
const count = ( state = 0, action ) {
  switch (action.type) {
    case COUNT_INCREMENT:
      return state + 1;
    default:
      return state;
  }
};

// Store
const store = createStore(combineReducers({ count }));
const $element = document.getElementById("app");
const $button = document.getElementById("button");

// Dispatch
const render = () => {
  const { count } = store.getState();
  $element.innerHTML = `<p>${count}</p>`;
};
$button.addEventListener("click", e => {
  store.dispatch({ type: COUNT_INCREMENT })
});

render();

// Store監視
store.subscribe(render);
  • createStore(): そのままの意味。storeを作成する。
  • combineReducers(): これもそのままの意味。複数のreducerを合わせる。
  • createStore(combineReducers({ count }));: つまり、Reducerにて定義した count のstateに応じて、 combineReducers() でreducerを定義し、それによってStoreを作成するということ。
  • subscribe(): RxJSの1つ。簡単にいうと、変更の監視。変更の都度 render() が呼び出される。

Flux/Reduxの利点

  • 中規模以上の開発において変更が頻繁にある場合でも、Model/Viewが分離されているため、容易に変更しやすい。
  • 状態が単方向なので、単純化しており、見通しが立ちやすい
  • コンポーネント(View)はStoreの値のみであり、コンポーネント指向のライブラリとの親和性が高い。

References

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Mapbox GL JSをReactの関数コンポーネントで表示する改

はじめに

以前に投稿したReactの関数コンポーネントでMapbox GL JSを表示するデモというのが、割とあっさりanyを使っていたりデモと言えるのか怪しい出来だったので、そのリバイスを兼ねて、よりマシなサンプル実装を示します。

環境構築

①React + Typescriptプロジェクト構築

npx create-react-app react-mapbox-demo --template typescript

※テンプレートのままだとReactの型定義がうまく適用されないかも、その場合改めてnpm installすれば大丈夫です。

②Mapbox GL JSと型定義をインストール

npm install mapbox-gl@v1.13.0 @types/mapbox-gl

③サーバーたてる

npm start

コンポーネントのサンプル

import React, { CSSProperties, useEffect, useState, useRef } from 'react';
import './App.css';

// Mapbox GL JSインポート
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';

// 地図表示するDiv要素に適用するCSS
const mapCSS: CSSProperties = {
    height: '1000px',
};

// Mapbox Style
const mapStyle: mapboxgl.Style = {
    version: 8,
    sources: {
        OSM: {
            type: 'raster',
            tiles: ['http://tile.openstreetmap.org/{z}/{x}/{y}.png'],
            tileSize: 256,
            attribution:
                '<a href="http://osm.org/copyright">© OpenStreetMap contributors</a>',
        },
    },
    layers: [
        {
            id: 'OSM',
            type: 'raster',
            source: 'OSM',
            minzoom: 0,
            maxzoom: 18,
        },
    ],
};

const App: React.FC = () => {
    // mapboxgl.Mapのインスタンスへの参照を保存するためのuseState
    const [mapInstance, setMapInstance] = useState<mapboxgl.Map>();

    // 地図表示するDiv要素を特定するためのuseRef
    const mapContainer = useRef<HTMLDivElement | null>(null);

    useEffect(() => {
        // 初回時のみ走る処理
        if (!mapInstance) {
            if (!mapContainer.current) {
                // mapContainer.currentはnullになり得るので型ガード
                return;
            }
            const map = new mapboxgl.Map({
                container: mapContainer.current, // 型ガードのおかげで必ずHTMLDivElementとして扱える
                style: mapStyle,
                center: [142.0, 40.0],
                zoom: 4,
            });
            // mapboxgl.Mapのインスタンスへの参照を保存
            setMapInstance(map);
        }
    }, [mapInstance]);
    return (
        <div className="App">
            <div ref={mapContainer} style={mapCSS} />
        </div>
    );
};

export default App;

無事に地図画面が表示されました。

スクリーンショット 2021-01-03 7.34.41.png

TIPS:地図のレイヤー構成の操作について

実際の地図アプリケーション開発では、地図のレイヤー構成を動的に更新したいかもしれません。Mapbox GL JSでは、その際、addSource()して、addLayer()して、でもその時sourceが既に存在していたら…とか色々めんどくさいです。なのでそれらのAPIは一切用いず、Style自体を操作する方法がおすすめです。以下が実装例。

import React, { CSSProperties, useEffect, useState, useRef } from 'react';
import './App.css';

// Mapbox GL JSインポート
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';

// 地図表示するDiv要素に適用するCSS
const mapCSS: CSSProperties = {
    height: '1000px',
};

// Mapbox Style
const initMapStyle: mapboxgl.Style = {
    version: 8,
    sources: {
        OSM: {
            type: 'raster',
            tiles: ['http://tile.openstreetmap.org/{z}/{x}/{y}.png'],
            tileSize: 256,
            attribution:
                '<a href="http://osm.org/copyright">© OpenStreetMap contributors</a>',
        },
    },
    layers: [
        {
            id: 'OSM',
            type: 'raster',
            source: 'OSM',
            minzoom: 0,
            maxzoom: 18,
        },
    ],
};

const emptyMapStyle: mapboxgl.Style = {
    version: 8,
    sources: {},
    layers: [],
};

const App: React.FC = () => {
    // mapboxgl.Mapのインスタンスへの参照を保存するためのuseState
    const [mapInstance, setMapInstance] = useState<mapboxgl.Map>();

    // 地図表示するDiv要素をHTML要素を特定するためのuseRef
    const mapContainer = useRef<HTMLDivElement | null>(null);

    // 地図スタイルをstate管理
    const [mapStyle, setMapStyle] = useState<mapboxgl.Style>(initMapStyle);
    const [flag, setFlag] = useState(false);

    // mapStyleの変更時に走る処理
    useEffect(() => {
        if (!mapInstance) {
            // nullチェック
            return;
        }
        // MapインスタンスのsetStyle()を実行
        mapInstance.setStyle(mapStyle);
    }, [mapStyle]);

    useEffect(() => {
        // 初回時のみ走る処理
        if (!mapInstance) {
            if (!mapContainer.current) {
                // mapContainer.currentはnullになり得るので型ガード
                return;
            }
            const map = new mapboxgl.Map({
                container: mapContainer.current, // 型ガードのおかげで必ずHTMLDivElementとして扱える
                style: mapStyle,
                center: [142.0, 40.0],
                zoom: 4,
            });

            // mapboxgl.Mapのインスタンスへの参照を保存
            setMapInstance(map);
        }
    }, [mapInstance]);

    return (
        <div className="App">
            <div
                ref={mapContainer}
                style={mapCSS}
                onClick={() => {
                    // 以下のようにReactのstateを操作するとMapインスタンス側でsetStyle()が走る
                    setMapStyle(flag ? emptyMapStyle : initMapStyle);
                    setFlag(!flag);
                }}
            />
        </div>
    );
};

export default App;

単純にOSMスタイルと空スタイルを行き来するサンプルですが、React側の変数の操作だけでMapインスタンスのsetStyle()を発火させる事が出来ています。データの流れも非常にシンプルになります(Reactのスタイル変数の変更から、常に一方向にMapインスタンスへ反映される)。もしあちこちでaddSource()やaddLayer()を繰り返すと、Styleの管理がとても複雑になります(行数も増えてしまい何も良い事がありません)。
これはライブラリ自体の出来だと思いますが、setStyle()はスタイル全てを再レンダリングする事はなく、変更箇所のみを改めて描画してくれるようなのでパフォーマンスへの影響はないです(たぶん)。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React(typescript)で外部scriptを遅延ロードする方法

今回陥った内容の概要

React(typescript)で外部scriptを使用するだけなのだがかなりの時間と調査を要して解決したのでのでナレッジの共有をしたいと思います。
ただベストプラクティスではないと思っており、実装内容も少しレガシーの方法となっております。
今回は外部scriptになるが古いライブラリを使用する場合、React(typescript)ですが、@typeに対応していないものを使用する内容となっております。

今回の用件

  • 実装はReactでのSPA
  • 外部scriptはbody内に任意の箇所
  • headerにはプロパティを追加する外部scriptなし
  • innerHtmlはサニタイズを考慮して使用不可
  • 外部script内にdocument.writeが使用されている()
  • 外部script内にさらなる外部scriptが使用されている

試したアプローチ

1.scriptタグの生成

useEffectで以下のコードを実装

logic.tsx
const script = document.createElement("script");
script.asyc = true;
script.src = "url";

const currentElem = document.getElementById("current");
currentElem?.appendChild(script);

build結果としては

<div id="current">
    <scirpt async src="url"></script>
</div>

っとなってしまいました。

望む結果としては外部scriptの実行結果(document.write)が行われDOM要素を生成して欲しいのでこちらでは望む結果とはなりませんでした。

解決したアプローチ

postscribeを使用しました。

npm i postscribe

ただこちらのライブラリは@typeが作成されていなさそうなので、

import postscribe from "postscribe";

ではエディタ上ではErrorとなってしまいます。

そのため、ベストプラクティスではないと思いつつ以下のような実装で対応しました。

logic.tsx
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postscribe = require("postscribe");
postscribe(
  "#current",
  "<script src='url'></script>"
);

上記実装でbuildを実行しますと

<div id="current">
    <scirpt async src="url"></script>
    <!-- ここに上記外部scriptのdocument.writeで追加されたDOMが追加 -->
</div>

となり、任意のコンテンツが表示されました。

まとめ

今回に関してはこのような対応で実現させました。
実現できた時は安堵の方がかなり多かったですが、個人的には満足いく実装が出来なかったです。
自分以外にも似たような内容で困っている方がいれば一度試してみてはいかがでしょうか?
また、他にもっと良い実装があるよって方は残していただければ試してみようかと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DockerでReact+Django+Nginx+MySQLの環境構築

はじめに

Docker環境でコマンドを打つだけで一発でDjango,React,MySQLなどを立ち上げて開発をできるようにしたくて、今回の記事を書きました。

この記事は一から環境を構築することを目指していますが、完成形だけをみたいかたはこちらのGitHubからどうぞ:django-react-nginx-mysql-docker
(READMEに書いてあることを実行すれば、うまくいくはずです)

目標

  • 仮想環境などは一切使わず、終始dockerでプロジェクトの作成などを行う
  • docker-compose upでウェブ開発に必要なすべてのコンテナが立ち上がるようにする
  • 最終的にK8sにデプロイする(次回の記事になると思います)。

最初は仮想環境で立ち上げて、そのあとにdockerfileを作成して環境構築をできるようにする、という記事は多く見かけます。ですが私はvirtualenvとかyarnをローカルに入れるのが面倒なので、終始Dockerで全部プロジェクトの管理を行いたいと思います。

前提

  • Dockerインストール済み
  • docker-composeインストール済み
  • LinuxまたはMac(強めのCPUとメモリがあるのが好ましいです)

流れ

以下のように進めていきます。

  1. バックエンドとDBの構築
  2. バックエンドのAPIを実際に触ってみて、データを追加してみる
  3. フロントエンドの構築
  4. APIでデータを取得し、フロントにて表示してみる

使う技術

  • Docker (docker-compose)
  • django (django rest framework)
  • nginx
  • mysql
  • react
  • next
  • typescript

ルートディレクトリにフォルダ作成

ではまずフォルダの作成から始めます。
プロジェクトフォルダを作成して、その直下で以下のコマンドを打ちます。

$ mkdir backend
$ mkdir frontend
$ mkdir mysql
$ mkdir mysql_volume
$ mkdir sql
$ touch docker-compose.yml

以下のようになっているはずです。

$ tree
.
├── backend
├── docker-compose.yml
├── frontend
├── mysql
├── mysql_volume
└── sql

5 directories, 1 file

1. BackendとDBの構築

web-backnginxのフォルダを作成します。nginxweb-backは今後K8sにデプロイするときには同じポッドにしようと思っているので、このような構成になります。フロントのときのweb-frontnginxも同じです。

$ cd backend
$ mkdir web-back
$ mkdir nginx

web-backの用意

$ cd web-back
$ touch .env Dockerfile requirements.txt

.envはAPIのKEYなど、センシティブな情報を含むファイルです。今はシークレットキーなどはないので、とりあえずテキトーに埋めておきます。

backend/web-back/.env
SECRET_KEY='XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
DEBUG=False

Python環境のDockerfileです。

# backend/web-back/Dockerfile
# set base image
FROM python:3.7

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# set work directory
WORKDIR /code

# install dependencies
COPY requirements.txt ./
RUN python3 -m pip install --upgrade pip setuptools
RUN pip install -r requirements.txt

# Copy project
COPY . ./

# Expose application port
EXPOSE 8000

pipでインストールするモジュールです。

backend/web-back/requirements.txt
asgiref==3.2.7
Django==3.0.5
django-cors-headers==3.2.1
djangorestframework==3.11.0
gunicorn==20.0.4
psycopg2-binary==2.8.5
python-dotenv==0.13.0
pytz==2019.3
sqlparse==0.3.1
mysqlclient==2.0.2

nginxの用意

nginxフォルダに入ってDockerfileとconfファイルを作成します。
今後デプロイするとき用にファイルを分けたいので、devを入れておいて区別できるようにします。

$ cd ../nginx
$ touch Dockerfile.dev default.dev.conf

nginxのDockerfileです。

backend/nginx/Dockerfile.dev
FROM nginx:1.17.4-alpine

RUN rm /etc/nginx/conf.d/default.conf
COPY default.dev.conf /etc/nginx/conf.d

nginxコンテナに回ってきた通信をすべてdjangoのコンテナに流すようにします。

backend/nginx/default.dev.conf
upstream django {
    server web-back:8000;
}

server {

    listen 80;

    location = /healthz {
        return 200;
    }

    location / {
        proxy_pass http://django;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_redirect off;
    }

    location /static/ {
        alias /code/staticfiles/;
    }
}

MySQLの用意

mysqlフォルダ直下にDockerfileとmy.cnfを作成します。

$ cd ../../mysql
$ touch Dockerfile my.cnf

mysqlのバージョンは8.0.0を使うことにします。

FROM mysql:8.0.0

RUN echo "USE mysql;" > /docker-entrypoint-initdb.d/timezones.sql &&  mysql_tzinfo_to_sql /usr/share/zoneinfo >> /docker-entrypoint-initdb.d/timezones.sql

COPY ./my.cnf /etc/mysql/conf.d/my.cnf

文字コードなどの設定をmy.cnfに書き込みます。

mysql/my.cnf
# MySQLサーバーへの設定
[mysqld]
# 文字コード/照合順序の設定
character_set_server=utf8mb4
collation_server=utf8mb4_bin

# タイムゾーンの設定
default_time_zone=SYSTEM
log_timestamps=SYSTEM

# デフォルト認証プラグインの設定
default_authentication_plugin=mysql_native_password

# mysqlオプションの設定
[mysql]
# 文字コードの設定
default_character_set=utf8mb4

# mysqlクライアントツールの設定
[client]
# 文字コードの設定
default_character_set=utf8mb4

sqlフォルダの用意

SQLフォルダに移動し、init.sqlを作成します。

$ cd ../sql
$ touch init.sql
sql/init.sql
GRANT ALL PRIVILEGES ON test_todoList.* TO 'user'@'%';

FLUSH PRIVILEGES;

docker-composeでバックエンドの立ち上げ

ここまででファイルは以下のようになっているはずです。

$ tree -a
.
├── backend
│   ├── nginx
│   │   ├── default.dev.conf
│   │   └── Dockerfile.dev
│   └── web-back
│       ├── Dockerfile
│       ├── .env
│       └── requirements.txt
├── docker-compose.yml
├── frontend
├── mysql
│   ├── Dockerfile
│   └── my.cnf
├── mysql_volume
└── sql
    └── init.sql

7 directories, 9 files

フロントエンドはまたあとでやるので、とりあえずバックエンドの立ち上げを行っていきます。以下のようにdocker-compose.ymlファイルを用意します。

docker-compose.yml
version: "3.7"

services:
  web-back:
    container_name: python-backend
    env_file: ./backend/web-back/.env
    build: ./backend/web-back/.
    volumes:
      - ./backend/web-back:/code/
      - static_volume:/code/staticfiles # <-- bind the static volume
    stdin_open: true
    tty: true
    command: gunicorn --bind :8000 config.wsgi:application
    networks:
      - backend_network
    environment:
      - CHOKIDAR_USEPOLLING=true
      - DJANGO_SETTINGS_MODULE=config.local_settings
    depends_on:
      - db
  backend-server:
    container_name: nginx_back
    build:
      context: ./backend/nginx/.
      dockerfile: Dockerfile.dev
    volumes:
      - static_volume:/code/staticfiles # <-- bind the static volume
    ports:
      - "8080:80"
    depends_on:
      - web-back
    networks:
      - backend_network
  db:
    build: ./mysql
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: todoList
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      TZ: 'Asia/Tokyo'
    volumes:
      - ./mysql_volume:/var/lib/mysql
      - ./sql:/docker-entrypoint-initdb.d
    networks:
      - backend_network

networks:
  backend_network:
    driver: bridge
volumes:
  static_volume:

内容が多いので少し難しいですね。
今回はとりあえず動くものを作りたいので、意味については割愛させていただきます。
ではDjangoのプロジェクトを作成しましょう!まずはconfigというプロジェクトを作成します。

$ docker-compose run --rm web-back sh -c "django-admin startproject config ."
Creating backend_web-back_run ... done
etc.....

$ docker-compose run --rm web-back sh -c "python manage.py startapp todo"
Creating backend_web-back_run ... done

うまくいったら以下のようにconfigtodoが作成されているはずです。

$ tree
.
├── backend
│   ├── nginx
│   │   ├── default.dev.conf
│   │   └── Dockerfile.dev
│   └── web-back
│       ├── config
│       │   ├── asgi.py
│       │   ├── __init__.py
│       │   ├── settings.py
│       │   ├── urls.py
│       │   └── wsgi.py
│       ├── Dockerfile
│       ├── manage.py
│       ├── requirements.txt
│       ├── staticfiles
│       └── todo
│           ├── admin.py
│           ├── apps.py
│           ├── __init__.py
│           ├── migrations
│           │   └── __init__.py
│           ├── models.py
│           ├── tests.py
│           └── views.py
............................

開発環境用のsettingファイルの作成

開発環境と本番環境で設定ファイルを分けたいので、configフォルダにてlocal_setting.pyファイルを作成します。settings.pyの情報を引き継ぐようにして、データベースの情報だけここで塗り替えます。

config/local_settings.py
from .settings import *

DEBUG = True

ALLOWED_HOSTS = ['*']

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'todoList',
        'USER': 'user',
        'PASSWORD': 'password',
        'HOST': 'db',
        'PORT': '3306',
    }
}

これでビルドしてみましょう。

$ docker-compose up --build
Starting python-backend ... done
Starting nginx          ... done
Attaching to python-backend, nginx
python-backend | [2020-12-28 14:59:49 +0000] [1] [INFO] Starting gunicorn 20.0.4
python-backend | [2020-12-28 14:59:49 +0000] [1] [INFO] Listening at: http://0.0.0.0:8000 (1)
python-backend | [2020-12-28 14:59:49 +0000] [1] [INFO] Using worker: sync
python-backend | [2020-12-28 14:59:49 +0000] [10] [INFO] Booting worker with pid: 10

これでlocalhost:8080にアクセスしてみましょう。以下の画面が出てくるはずです。
8080ポートにアクセスすると、nginxが8000ポートに通信を流してくれます。それによってdjangoの提供してくれるページにアクセスできます。

Screenshot from 2021-01-02 22-23-38.png

マイグレーションの準備と実行

  • rest frameworkを使いたい
  • APIを操作するための管理画面ページを使いたい

上記がまだできていないので、ここではそのためのデータベースのマイグレーションの準備を行います。以下3つのファイルを編集していきます。

  • settings.py
  • todo/models.py
  • todo/admin.py

settings.pyを以下のように編集します。ついでにこの際にcorsの部分も追加し、あとからフロントエンドからバックエンドのAPIを呼び出せるようにしておきます。

config/settings.py
"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 3.0.5.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.0/ref/settings/
"""

import os
from dotenv import load_dotenv  # 追加

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_DIR = os.path.basename(BASE_DIR)  # 追加

# .envの読み込み
load_dotenv(os.path.join(BASE_DIR, '.env'))  # 追加

# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '1_vj5u9p3nm4fwufe_96e9^6li1htp9avbg8+7*i#h%klp#&0='

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = ["*"]


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # 3rd party
    'rest_framework',
    'corsheaders',

    # Local
    'todo.apps.TodoConfig',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',

    'corsheaders.middleware.CorsMiddleware',
]

ROOT_URLCONF = 'config.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'config.wsgi.application'




# Password validation
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/

LANGUAGE_CODE = 'ja'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/

STATIC_URL = '/static/'

# 開発環境下で静的ファイルを参照する先
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] # 追加

# 本番環境で静的ファイルを参照する先
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') # 追加

# メディアファイルpath
MEDIA_URL = '/media/' # 追加

# 追加
REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.AllowAny',
    ]
}

CORS_ORIGIN_WHITELIST = (
    'http://localhost',
)

todoの中のmodels.pyを編集します。

todo/models.py
from django.db import models


class Todo(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()

    def __str__(self):
        return self.title

todoの中のadmin.pyを編集します。

todo/admin.py
from django.contrib import admin
from .models import Todo


admin.site.register(Todo)

これでマイグレーションを以下のように実行します。ついでにsuperuserも作成しておきます。パスワードなどは好きなように設定してください。

$ docker-compose run --rm web-back sh -c "python manage.py makemigrations"
Creating backend_web-back_run ... done
Migrations for 'todo':
  todo/migrations/0001_initial.py
    - Create model Todo

$ docker-compose run --rm web-back sh -c "python manage.py migrate"
Creating backend_web-back_run ... done
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, todo
Running migrations:
  Applying contenttypes.0001_initial... OK
........

$ docker-compose run --rm web-back sh -c "python manage.py createsuperuser"
Creating backend_web-back_run ... done
ユーザー名 (leave blank to use 'root'):
メールアドレス: example@gmail.com
Password:root
.........

URLの設定

adminapiのページに飛べるように設定します。

backend/web-back/config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('todo.urls'))  # 追加
]

todoでもURLの設定などを行わなければいけません。また、JSONに変換するserializerファイルも作成します。

backend/web-back/todo $ touch urls.py serializers.py
urls.py
from django.urls import path, include
from .views import ListTodo, DetailTodo

urlpatterns = [
    path('<int:pk>/', DetailTodo.as_view()),
    path('', ListTodo.as_view())
]
serializers.py
from rest_framework import serializers
from .models import Todo


class TodoSerializer(serializers.ModelSerializer):
    class Meta:
        model = Todo
        fields = ('id', 'title', 'body')

viewも編集します。

views.py
from django.shortcuts import render

# Create your views here.
from django.shortcuts import render
from rest_framework import generics
from .models import Todo
from .serializers import TodoSerializer


class ListTodo(generics.ListAPIView):
    queryset = Todo.objects.all()
    serializer_class = TodoSerializer


class DetailTodo(generics.RetrieveAPIView):
    queryset = Todo.objects.all()
    serializer_class = TodoSerializer

2.APIを触ってデータを追加してみる。

もう一度走らせて、adminapiにアクセスする

このままではcssファイルなどが反映されないので、staticなファイルをまず整理してから立ち上げます。

$ cd backend/web-back
$ mkdir static
$ docker-compose run --rm web-back sh -c "python manage.py collectstatic"
Starting ... done

163 static files copied to '/code/staticfiles'.
$ docker-compose up

localhost:8080/adminは以下のようになります。先ほど作成したsuperuserでログインしましょう。

Screenshot from 2021-01-02 22-47-01.png

ログインしたらtodoの管理などができる画面に入ります。

Screenshot from 2021-01-02 22-47-51.png

こんな感じで追加しておきます。

Screenshot from 2021-01-02 22-48-40.png

これでlocalhost:8080/api/1に行くと、見つかります。

Screenshot from 2021-01-02 22-49-35.png

これで以下のことができるようになりました。

  • 管理画面へのログイン
  • APIでデータの取得

これでフロントエンドの構築を始めることができます。

mysql dbでも確認してみる

以下のようにコンテナに入って確認すると、たしかにデータが格納されています。

$ docker exec -it container_db bash
root@e34e5d2a20e1:/# mysql -u root -p

mysql> use todoList;

mysql> select * from todo_todo;
+----+-------------+--------------+
| id | title       | body         |
+----+-------------+--------------+
|  1 | do homework | finish maths |
+----+-------------+--------------+
1 row in set (0.00 sec)


(余談)テストファイルの作成と実行

今すぐ必要というわけではないですが、テストファイルの作成と実行も一通りここでやっておきます。以下のテストファイルを走らせます。

backend/web-back/todo/tests.py
from django.test import TestCase

# Create your tests here.
from django.test import TestCase
from .models import Todo


class TodoModelTest(TestCase):

    @classmethod
    def setUpTestData(cls):
        Todo.objects.create(title="first todo", body="a body here")

    def test_title_content(self):
        todo = Todo.objects.get(id=1)
        excepted_object_name = f'{todo.title}'
        self.assertEqual(excepted_object_name, 'first todo')

    def test_body_content(self):
        todo = Todo.objects.get(id=1)
        excepted_object_name = f'{todo.body}'
        self.assertEqual(excepted_object_name, 'a body here')

テストをコンテナの中で走らせます。うまく通るはずです。

$ docker-compose run --rm web-back sh -c "python manage.py test"
Creating backend_web-back_run ... done
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.005s

OK
Destroying test database for alias 'default'...

3. フロントエンドの構築

それでは、フロントエンドのほうのnginxとreact+next.jsの環境を構築していきます。

$ cd frontend/
$ mkdir nginx web-front

$ cd nginx
$ touch Dockerfile.dev default.dev.conf wait.sh

以下のようなファイル構成にします。

$ cd ../
$ tree
.
├── nginx
│   ├── default.dev.conf
│   ├── Dockerfile.dev
│   └── wait.sh
└── web-front

2 directories, 3 files

以下の2つのファイルはバックエンドのときとほぼ同じです。

frontend/nginx/default.dev.conf
upstream react {
    server web-front:3000;
}

server {

    listen 80;

    location = /healthz {
        return 200;
    }

    location / {
        proxy_pass http://react;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_redirect off;
    }
    location /sockjs-node {
        proxy_pass http://react;
      proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    error_page 500 502 503 504    /50x.html;

    location = /50x.html {
        root    /usr/share/nginx/html;
    }
}
frontend/nginx/Dockerfile.dev
FROM nginx:1.17.4-alpine

RUN apk add --no-cache bash

COPY wait.sh /wait.sh

RUN chmod +x /wait.sh

CMD ["/wait.sh", "web-front:3000", "--", "nginx", "-g", "daemon off;"]

RUN rm /etc/nginx/conf.d/default.conf
COPY default.dev.conf /etc/nginx/conf.d

このまま進めると、reactのコンテナは毎回nginxより遅く立ち上がってしまい、nginxは接続エラーだと勘違いしてexitしてしまいます。それを阻止するために以下のシェルファイルを用意してnginxコンテナの立ち上げを遅らせます。こちらのファイルはvishnubob/wait-for-itのレポジトリからコピーしてきたものです。

wait.sh
#!/usr/bin/env bash
# Use this script to test if a given TCP host/port are available

WAITFORIT_cmdname=${0##*/}

echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }

usage()
{
    cat << USAGE >&2
Usage:
    $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
    -h HOST | --host=HOST       Host or IP under test
    -p PORT | --port=PORT       TCP port under test
                                Alternatively, you specify the host and port as host:port
    -s | --strict               Only execute subcommand if the test succeeds
    -q | --quiet                Don't output any status messages
    -t TIMEOUT | --timeout=TIMEOUT
                                Timeout in seconds, zero for no timeout
    -- COMMAND ARGS             Execute command with args after the test finishes
USAGE
    exit 1
}

wait_for()
{
    if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
        echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
    else
        echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
    fi
    WAITFORIT_start_ts=$(date +%s)
    while :
    do
        if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
            nc -z $WAITFORIT_HOST $WAITFORIT_PORT
            WAITFORIT_result=$?
        else
            (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
            WAITFORIT_result=$?
        fi
        if [[ $WAITFORIT_result -eq 0 ]]; then
            WAITFORIT_end_ts=$(date +%s)
            echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
            break
        fi
        sleep 1
    done
    return $WAITFORIT_result
}

wait_for_wrapper()
{
    # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
    if [[ $WAITFORIT_QUIET -eq 1 ]]; then
        timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
    else
        timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
    fi
    WAITFORIT_PID=$!
    trap "kill -INT -$WAITFORIT_PID" INT
    wait $WAITFORIT_PID
    WAITFORIT_RESULT=$?
    if [[ $WAITFORIT_RESULT -ne 0 ]]; then
        echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
    fi
    return $WAITFORIT_RESULT
}

# process arguments
while [[ $# -gt 0 ]]
do
    case "$1" in
        *:* )
        WAITFORIT_hostport=(${1//:/ })
        WAITFORIT_HOST=${WAITFORIT_hostport[0]}
        WAITFORIT_PORT=${WAITFORIT_hostport[1]}
        shift 1
        ;;
        --child)
        WAITFORIT_CHILD=1
        shift 1
        ;;
        -q | --quiet)
        WAITFORIT_QUIET=1
        shift 1
        ;;
        -s | --strict)
        WAITFORIT_STRICT=1
        shift 1
        ;;
        -h)
        WAITFORIT_HOST="$2"
        if [[ $WAITFORIT_HOST == "" ]]; then break; fi
        shift 2
        ;;
        --host=*)
        WAITFORIT_HOST="${1#*=}"
        shift 1
        ;;
        -p)
        WAITFORIT_PORT="$2"
        if [[ $WAITFORIT_PORT == "" ]]; then break; fi
        shift 2
        ;;
        --port=*)
        WAITFORIT_PORT="${1#*=}"
        shift 1
        ;;
        -t)
        WAITFORIT_TIMEOUT="$2"
        if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
        shift 2
        ;;
        --timeout=*)
        WAITFORIT_TIMEOUT="${1#*=}"
        shift 1
        ;;
        --)
        shift
        WAITFORIT_CLI=("$@")
        break
        ;;
        --help)
        usage
        ;;
        *)
        echoerr "Unknown argument: $1"
        usage
        ;;
    esac
done

if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
    echoerr "Error: you need to provide a host and port to test."
    usage
fi

WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}

# Check to see if timeout is from busybox?
WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)

WAITFORIT_BUSYTIMEFLAG=""
if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
    WAITFORIT_ISBUSY=1
    # Check if busybox timeout uses -t flag
    # (recent Alpine versions don't support -t anymore)
    if timeout &>/dev/stdout | grep -q -e '-t '; then
        WAITFORIT_BUSYTIMEFLAG="-t"
    fi
else
    WAITFORIT_ISBUSY=0
fi

if [[ $WAITFORIT_CHILD -gt 0 ]]; then
    wait_for
    WAITFORIT_RESULT=$?
    exit $WAITFORIT_RESULT
else
    if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
        wait_for_wrapper
        WAITFORIT_RESULT=$?
    else
        wait_for
        WAITFORIT_RESULT=$?
    fi
fi

if [[ $WAITFORIT_CLI != "" ]]; then
    if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
        echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
        exit $WAITFORIT_RESULT
    fi
    exec "${WAITFORIT_CLI[@]}"
else
    exit $WAITFORIT_RESULT
fi

docker-compose.ymlの編集

docker-compose.yml
version: "3.7"

services:
  web-back:
    container_name: python-backend
    env_file: ./backend/web-back/.env
    build: ./backend/web-back/.
    volumes:
      - ./backend/web-back:/code/
      - static_volume:/code/staticfiles # <-- bind the static volume
    stdin_open: true
    tty: true
    command: gunicorn --bind :8000 config.wsgi:application
    networks:
      - backend_network
    environment:
      - CHOKIDAR_USEPOLLING=true
      - DJANGO_SETTINGS_MODULE=config.local_settings
    depends_on:
      - db
  backend-server:
    container_name: nginx_back
    build:
      context: ./backend/nginx/.
      dockerfile: Dockerfile.dev
    volumes:
      - static_volume:/code/staticfiles # <-- bind the static volume
    ports:
      - "8080:80"
    depends_on:
      - web-back
    networks:
      - backend_network
  db:
    build: ./mysql
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: todoList
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      TZ: 'Asia/Tokyo'
    volumes:
      - ./mysql_volume:/var/lib/mysql
      - ./sql:/docker-entrypoint-initdb.d
    networks:
      - backend_network

  web-front:
    image: node:14.13.1
    volumes:
      - ./frontend/web-front:/home/app/frontend
    ports:
      - 3000:3000
    working_dir: /home/app/frontend
    command: [bash, -c, yarn upgrade --no-progress --network-timeout 1000000 && yarn run dev]
    networks:
      - frontend_network
  frontend-server:
    container_name: nginx_frontend
    build:
      context: ./frontend/nginx/.
      dockerfile: Dockerfile.dev
    ports:
      - "80:80"
    depends_on:
      - web-front
    networks:
      - frontend_network
networks:
  backend_network:
    driver: bridge
  frontend_network:
    driver: bridge
volumes:
  static_volume:

これでファイルの用意はできました。

reactのプロジェクトの作成

docker-compose run --rm web-front sh -c "npx create-react-app ."

web-frontはnode_modulesを除くと以下のようにプロジェクトができているはずです。

$ tree web-front -I node_modules
web-front
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── README.md
├── src
│   ├── App.css
│   ├── App.js
│   ├── App.test.js
│   ├── index.css
│   ├── index.js
│   ├── logo.svg
│   ├── reportWebVitals.js
│   └── setupTests.js
└── yarn.lock

2 directories, 17 files

next.jsのための準備

必要なモジュールを今のうちに入れておきましょう。

docker-compose run --rm web-front sh -c "yarn add next axios"
docker-compose run --rm web-front sh -c "yarn add --dev typescript @types/react"

package.jsondevの項目を追加します。これがないとdevが見つからないといってエラーになります。

package.json
  "scripts": {
    "dev": "next dev", //追加
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },

pagesフォルダをsrcの下に作って、テキトーなtypescriptファイルをおいてみます。next.jsではpagesの下にページを置くことがルールとなっています。

pages/index.tsx
import { FC } from 'react'

export default (() => {
    return (
        <div>
            hello world
        </div>
    )
}) as FC

これでdocker-compose upしてみましょう。
フロントエンドにアクセスするときは、localhostだけで大丈夫です、ポート番号は必要ありません。hello worldと返されているのが見えるはずです。

Screenshot from 2021-01-02 23-27-19.png

4. APIのデータを取得して表示

  • index.tsxの編集
pages/index.tsx
import React, { FC, useEffect, useState } from 'react'
import axios, { AxiosInstance } from 'axios'

type Todo = {
    id: string
    title: String
    body: String
}

export default (() => {
    const [todos, setTodo] = useState<Todo[]>([])

    const getAPIData = async () => {
        let instance: AxiosInstance

        instance = axios.create({
            baseURL: 'http://localhost:8080',
        })

        try {
            const response = await instance.get('/api/')
            console.log(response?.data)
            const tododata = response?.data as Todo[]
            setTodo(tododata)
        } catch (error) {
            console.log(error)
        }
    }
    return (
        <div>
            hello world
            <button onClick={getAPIData}>click</button>
            {todos.map((item) => (
                <div key={item.id}>
                    <h1>{item.title}</h1>
                    <p>{item.body}</p>
                </div>
            ))}
        </div>
    )
}) as FC

localhostにアクセスすると、以下のようにボタンが現れると思います。

Screenshot from 2021-01-03 00-23-01.png

ボタンを押したらAPIを通してデータが取得されます。

Screenshot from 2021-01-03 00-23-07.png

CSSやらBootstrapなどを使っていないのでしょうもないものですが、一応フロントエンドとバックエンドで通信ができていることを確認できました!

とりあえずここまでにしておいて、今後K8sへのデプロイについての記事を書くかもしれません。

参考

ゼロからGKEにDjango+Reactをデプロイする(1)backendの開発 - Nginx + Django
wait-for-it.sh

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む