- 投稿日:2021-01-03T22:10:02+09:00
React with Babylon.js での注意事項(随時更新)
お仕事で React と Babylon.js を組で使用することになり、色々調べて得られた知見をまとめます。
サンプルコードを GitHub に置いたので、必要に応じてこちらも参照してみてください。なお、この記事は随時更新予定です。
Babylon.js コンポーネント実装の基本
React では
useRef
とuseEffect
を使って 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つの選択肢が考えられます:
- canvas の非表示処理を
display='none'
スタイルで実装する- Babylon.js のオブジェクトを管理するクラスをシングルトンで実装する
お手軽なのは 1. です。react-tabs の
forceRenderTabPanel
のように、オプションが用意されている場合も多いので、使用するパッケージの詳細を調べてみるとよいでしょう。シングルトンで解決する場合には 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.createElement
でEngine
生成のための canvas を生成し、canvas はEngine
のregisterView
を使って登録するようにすると、ある程度キレイに実装できるようです。以下にコードを示します。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;
- 投稿日:2021-01-03T21:40:59+09:00
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;参考サイト
- 投稿日:2021-01-03T17:17:00+09:00
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お願いします!
- 投稿日:2021-01-03T17:17:00+09:00
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お願いします!
まとめ記事 もあるのでよければこちらもみてください
- 投稿日:2021-01-03T15:43:02+09:00
【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
の部分で動的なクエリパラメータを指定してあげています。以上です。お疲れ様でした。
- 投稿日:2021-01-03T15:09:48+09:00
reactの勉強1 -reducerを使って入力欄をつくる-
はじめに
reactの勉強をするので、記録を残す。
今回は以下の「12.イベントの状態遷移を管理する」を見ながら勉強している。
https://www.udemy.com/share/101tSGB0cYcFZVQnw=/覚えておくことリスト
State…状態
Store…Stateの保管場所
Action…状態の更新を指示する
Dispatch…ActionをStoreに送る
Reducer…Actionの指示に応じてStateをどうこうするやりたいこと
幽霊屋敷の生存者リストを作成する。
幽霊屋敷の生存者を追加したり、削除したりいろいろ。やったこと
src/reducers/index.jsconst 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.jsconst 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 }) }正直reactのことはよくわかっていない。このStateやらDispatchやらもなんとなくの感覚でつかってみたがやっぱりよくわかっていない。この先どうなってしまうのか
- 投稿日:2021-01-03T13:29:39+09:00
Reactでいい感じのサブウィンドウを実装する ④ ウィンドウ動かす機能をON/OFF切り替える
シリーズ記事です。
初めての方は、こちら を最初に読んでくださいReactでいい感じのサブウィンドウを表示する際にウィンドウ動かす機能をON/OFF切り替えたい場面がよくあるので、
今回は、この方法について紹介します。
やり方
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お願いします!
まとめ記事 もあるのでよければこちらもみてください
- 投稿日:2021-01-03T13:01:46+09:00
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お願いします!
まとめ記事 もあるのでよければこちらもみてください
- 投稿日:2021-01-03T12:28:37+09:00
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.jsconst DataTable = () => { return ( <MaterialTable title="DataTable" columns={[ //省略 ]} data={[ //省略 ]} options={{ headerStyle: {position: 'sticky', top: 0}, }} /> ) }このようにプロパティに
options
とheaderStyle
と書いて
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-overridingContainer that everything renders in
これを見る限り
Container
コンポーネントのオーバーライドで解決できそうな気がしますが、ちょっと難しそうなので触れていません。笑もし詳しい方がいましたら教えてください!
- 投稿日:2021-01-03T10:26:18+09:00
フロントエンド開発における状態管理の違い(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-concepts
- Action: アプリにおける内部APIとなるもの。Storeに存在するデータを更新。
- Dispatcher: それぞれのActionを受け取り、それぞれのStoreに送信する。
- Store: アプリケーションが保持しているデータや状態。
- View: StoreからのDataをviewとして表示。
ReduxにおけるFlux
- 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
- 投稿日:2021-01-03T08:05:41+09:00
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;無事に地図画面が表示されました。
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()はスタイル全てを再レンダリングする事はなく、変更箇所のみを改めて描画してくれるようなのでパフォーマンスへの影響はないです(たぶん)。
- 投稿日:2021-01-03T01:28:19+09:00
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.tsxconst 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>となり、任意のコンテンツが表示されました。
まとめ
今回に関してはこのような対応で実現させました。
実現できた時は安堵の方がかなり多かったですが、個人的には満足いく実装が出来なかったです。
自分以外にも似たような内容で困っている方がいれば一度試してみてはいかがでしょうか?
また、他にもっと良い実装があるよって方は残していただければ試してみようかと思います。
- 投稿日:2021-01-03T01:12:32+09:00
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とメモリがあるのが好ましいです)
流れ
以下のように進めていきます。
- バックエンドとDBの構築
- バックエンドのAPIを実際に触ってみて、データを追加してみる
- フロントエンドの構築
- 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 file1. BackendとDBの構築
web-back
とnginx
のフォルダを作成します。nginx
とweb-back
は今後K8sにデプロイするときには同じポッドにしようと思っているので、このような構成になります。フロントのときのweb-front
とnginx
も同じです。$ cd backend $ mkdir web-back $ mkdir nginxweb-backの用意
$ cd web-back $ touch .env Dockerfile requirements.txt
.env
はAPIのKEYなど、センシティブな情報を含むファイルです。今はシークレットキーなどはないので、とりあえずテキトーに埋めておきます。backend/web-back/.envSECRET_KEY='XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' DEBUG=FalsePython環境の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 8000pipでインストールするモジュールです。
backend/web-back/requirements.txtasgiref==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.2nginxの用意
nginxフォルダに入ってDockerfileとconfファイルを作成します。
今後デプロイするとき用にファイルを分けたいので、dev
を入れておいて区別できるようにします。$ cd ../nginx $ touch Dockerfile.dev default.dev.confnginxのDockerfileです。
backend/nginx/Dockerfile.devFROM nginx:1.17.4-alpine RUN rm /etc/nginx/conf.d/default.conf COPY default.dev.conf /etc/nginx/conf.dnginxコンテナに回ってきた通信をすべてdjangoのコンテナに流すようにします。
backend/nginx/default.dev.confupstream 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.cnfmysqlのバージョンは
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=utf8mb4sqlフォルダの用意
SQLフォルダに移動し、
init.sql
を作成します。$ cd ../sql $ touch init.sqlsql/init.sqlGRANT 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.ymlversion: "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うまくいったら以下のように
config
とtodo
が作成されているはずです。$ 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.pyfrom .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の提供してくれるページにアクセスできます。マイグレーションの準備と実行
- 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.pyfrom 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.pyfrom 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の設定
admin
とapi
のページに飛べるように設定します。backend/web-back/config/urls.pyfrom 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.pyurls.pyfrom django.urls import path, include from .views import ListTodo, DetailTodo urlpatterns = [ path('<int:pk>/', DetailTodo.as_view()), path('', ListTodo.as_view()) ]serializers.pyfrom rest_framework import serializers from .models import Todo class TodoSerializer(serializers.ModelSerializer): class Meta: model = Todo fields = ('id', 'title', 'body')viewも編集します。
views.pyfrom 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 = TodoSerializer2.APIを触ってデータを追加してみる。
もう一度走らせて、
admin
とapi
にアクセスするこのままでは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でログインしましょう。ログインしたらtodoの管理などができる画面に入ります。
こんな感じで追加しておきます。
これで
localhost:8080/api/1
に行くと、見つかります。これで以下のことができるようになりました。
- 管理画面へのログイン
- 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.pyfrom 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.confupstream 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.devFROM 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 fidocker-compose.ymlの編集
docker-compose.ymlversion: "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 filesnext.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.json
でdev
の項目を追加します。これがないと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.tsximport { FC } from 'react' export default (() => { return ( <div> hello world </div> ) }) as FCこれで
docker-compose up
してみましょう。
フロントエンドにアクセスするときは、localhost
だけで大丈夫です、ポート番号は必要ありません。hello world
と返されているのが見えるはずです。4. APIのデータを取得して表示
- index.tsxの編集
pages/index.tsximport 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
にアクセスすると、以下のようにボタンが現れると思います。ボタンを押したらAPIを通してデータが取得されます。
CSSやらBootstrapなどを使っていないのでしょうもないものですが、一応フロントエンドとバックエンドで通信ができていることを確認できました!
とりあえずここまでにしておいて、今後K8sへのデプロイについての記事を書くかもしれません。
参考
ゼロからGKEにDjango+Reactをデプロイする(1)backendの開発 - Nginx + Django
wait-for-it.sh