Phoenix Channelで作る最先端Webアプリ - topic-subtopic編 - Qiita
Phoenix Channelで作る最先端Webアプリ - ETS編 - Qiita
Phoenix Channelで作る最先端Webアプリ - Fault Tolerance編 - Qiita
Phoenix Channelで作る最先端Webアプリ - 地図(Geo)拡張編 - Qiita
Phoenix Channelで作る最先端Webアプリ - DynamicSupervisor編 - Qiita
Phoenix Channelで作る最先端Webアプリ - Elixier Application編 - Qiita
Phoenix Channelで作る最先端Webアプリ - Reactチャット編 - Qiita
■ Phoenix Channelをテーマにした過去記事
Phoenix Channelとelm-phoenixについて -Qiita
東京電力電力供給状況監視 - Phoenix Channel - Qiita
今回の技術的なポイントは以下の通りです。
1.Phoenix Channelを設定する
2.Phoenix channelのPresenceを実装する(mix phx.gen.presence)
3.PhoenixでReactクライアントの設定を行う
4.ReactでChannelに接続する
5.Reactでchatアプリを作る
Phoenix Channelは次世代のWebアプリの骨格を与えてくれると言われています。その言葉を信じて、Phoenix Channelを中心として、ElixirとJavaScriptでのアプリ開発を追求していきたいと思います。「Phoenix Channelで作る最先端Webアプリ」というタイトルで何回かに分けて記事を書きたいと思います。少し恥ずかしいタイトルですが、最先端を探求したい、という意味合いで取って頂ければ幸いです。初回はReactチャット編です。今回作成するプロジェクトをベースとして、本シリーズを続けていくつもりです。
Phoenix channelとElmクライアントでチャットプログラムを作る記事を「Phoenix Channelとelm-phoenixについて - Qiita」で書きました。今回はElmクライアントをReactクライアントで置き換えます。
Phoenix channelのクライアントとしてElmでなく、Reactを使う利点は2つあります。まず第1はassets管理のBrunchをそのまま使うことができる点です。追加のパッケージは必要ありません。第2はchannelのライブラリが、標準のJavaScriptのものをそのまま使える点です。これも追加のパッケージは必要ありません。
1.Phoenixの設定を行う
プロジェクトを作成し、ReactChat Applicationを作っていきます。
mix phx.new react_chat --no-ecto
cd react_chat
さてmix.exsファイルで、このプロジェクトのスタート地点を確認します。modはこのApplicationのトップのmoduleを示しています。(ちなみにextra_applicationsはこのApplicationが実行されるときに、すでに動作している必要があるApplicationのリストです。動作していなければ起動されます。)
#
def application do
[
mod: {ReactChat.Application, []},
extra_applications: [:logger, :runtime_tools]
]
end
#
Phoenix channelにはPresenceという機能があります。Phoenix.Presence。これはchannelのtopicをsubscribeしているクライアントを記録するためのものです。つまりチャットに参加しているクライアントを記録します。一般的には分散環境などでも安全に記録するためにPresenceが提供されているようです。以下のコマンドでlib/react_chat_web/channels/presence.exが作成されます
mix phx.gen.presence
作成されたファイルの中身です。ReactChatWeb.Presence が定義されています。以下はコメントを削除した後のシンプルなものですが、コメントにはpresenceの実装のガイドがありますので一読の価値はあると思います。
defmodule ReactChatWeb.Presence do
use Phoenix.Presence, otp_app: :react_chat,
pubsub_server: ReactChat.PubSub
end
modで指定されたReactChat.Applicationを確認してみましょう。今回はPresence機能を使いたいので、childrenにReactChatWeb..Presenceを追加します。これでReactChatWeb.ApplicationはsupervisorとしてReactChatWeb.EndpointとReactChatWeb.Presenceをスタートするようになります。Presenceはあるchannelのあるtopicに参加しているユーザをtrackし続ける機能です。
#
children = [
supervisor(ReactChatWeb.Endpoint, []),
supervisor(ReactChatWeb.Presence, []),
]
#
ReactChatWeb.Presenceを追加したことにより、join後にpresencesがtrackされるようになります。
次にReactChatWeb.Endpointをみてみます。このプロジェクトのEndpointで、socketへのrouteがReactChatWeb.UserSocketへ振り分けられていることを確認します。
defmodule ReactChatWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :react_chat
socket "/socket", ReactChatWeb.UserSocket
#
次にReactChatWeb.UserSocketを確認します。以下のようにchannel行のコメントを外します。これで"room:"ではじまるtopicに関連したメッセージは全てReactChatWeb.RoomChannelを通るようになります。
## Channels
channel "room:*", ReactChatWeb.RoomChannel
#
def connect(_params, socket) do
{:ok, socket}
end
#
ちなみにここで定義してあるconnect/2は、クライアントからのsocket接続のリクエストが必ず通る関数です。この関数は認証などを行う場所として適していますが、今回はデフォルトのソースのままで修正は加えません。
さて最後にchannel moduleを作ります。room_channel.exですが、これは記事「Phoenix Channelとelm-phoenixについて - Qiita」のものと全く同じです。チャットプログラムのコードとしては汎用性があります。
defmodule ReactChatWeb.RoomChannel do
use ReactChatWeb, :channel
alias ReactChatWeb.Presence
def join("room:lobby", %{"user_name" => user_name}, socket) do
send(self(), {:after_join, user_name})
{:ok, socket}
end
def handle_in("new_msg", %{"msg" => msg}, socket) do
user_name = socket.assigns[:user_name]
broadcast(socket, "new_msg", %{msg: msg, user_name: user_name})
{:reply, :ok, socket}
end
def handle_info({:after_join, user_name}, socket) do
push(socket, "presence_state", Presence.list(socket))
{:ok, _ref} = Presence.track(socket, user_name, %{online_at: now()})
{:noreply, assign(socket, :user_name, user_name)}
end
def terminate(_reason, socket) do
{:noreply, socket}
end
defp now do
System.system_time(:seconds)
end
end
まずjoin/3についてです。クライアントがChannel通信を利用するためには、最初にtopic-subtopicを指定してjoinする必要があります。joinに対するサーバ側の処理はここでコーディングしています。"room:lobby"はtopicが"room"でsubtopicが"lobby"であることを示しています。presenceを使うので以下のコードを追加しています。
send(self(), {:after_join, user_name})
これは自分自身(self())にメッセージを送り、handle_info/3を起動しています。
def handle_info({:after_join, user_name}, socket) do
push(socket, "presence_state", Presence.list(socket))
{:ok, _ref} = Presence.track(socket, user_name, %{online_at: now()})
{:noreply, assign(socket, :user_name, user_name)}
end
クライアント側はPresenceの状態を維持するために、'presence_state'と'presence_diff'のイベントをリッスンしています。
push/3はjoinしてきたクライアントに対して'presence_state'イベントを通知し、現在のPresenceの状態(joined ユーザ一覧)を伝えます。クライアントは初期状態としてこれを記憶します。
またPresence.track/3はこのchannel processをuser_nameに対するpresenceとして登録します。これ以降のPresenceの変更は'presence_diff'イベントとして、自動的にクライアントに通知されます。初期状態が変更され、Presenceの状態が最新のものに維持され続けます。ここではこれ以上Presenceに深入りしません(できません)が、このようなコーディングで入退室時のユーザ一覧を管理できるようになります。
handle_in/3でチャットメッセージを受取、他のユーザにブロードキャストします。
2.Reactの設定を行う
assetsディレクトリで、reactに必要なパッケージをインストールします。
cd assets
npm install react react-dom material-ui babel-preset-react --save
brunch-config.jsを修正します。
#
plugins: {
babel: {
presets: ["es2015", "react"],
// Do not use ES6 compiler in vendor code
ignore: [/web\/static\/vendor/]
}
},
#
npm: {
enabled: true,
whitelist: ["phoenix", "phoenix_html", "react", "react-dom"]
}
#
これでPhoenixでReactを開発するための環境が整いました。プログラムを作成していきます。
まず、テンプレートファイルを修正します。Reactアプリにマウントポイントを作ります。
<div id="app"></div>
次にReactアプリのメインを作ります。通常のReactの作法通りですが、Material-UIを使うように設定しています。
import "phoenix_html";
import React from "react";
import ReactDOM from "react-dom";
import lightBaseTheme from 'material-ui/styles/baseThemes/lightBaseTheme';
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
import getMuiTheme from 'material-ui/styles/getMuiTheme';
import Chat from "./Chat";
class App extends React.Component {
render() {
return (
<Chat />
)
}
}
const target = document.getElementById('app');
const node =(
<MuiThemeProvider muiTheme={getMuiTheme(lightBaseTheme)}>
<App />
</MuiThemeProvider>
);
ReactDOM.render( node, target )
最後にReactアプリの本体であるChat.jsを示します。channelに関する設定はほとんど handleJoin()で行っています。コメントを付けてありますので、ちょっと長いですが、理解しやすいのではないでしょうか?
import React from "react";
import {Socket, Presence} from "phoenix"
import RaisedButton from 'material-ui/RaisedButton';
import Paper from 'material-ui/Paper';
import Divider from 'material-ui/Divider';
import TextField from 'material-ui/TextField';
class Chat extends React.Component {
constructor() {
super();
this.state = {
isJoined: false, //joinしているかどうか。画面を切り替える。
inputUser: "", //ユーザ名の入力
inputMessage: "", //メッセージの入力
messages: [], //受け取ったメッセージの配列
presences: {} //presence(参加ユーザ)の状態
}
}
handleInputUser(event) {
this.setState({
inputUser: event.target.value
})
}
handleInputMessage(event) {
this.setState({
inputMessage: event.target.value
})
}
// join処理
handleJoin(event) {
event.preventDefault();
if(this.state.inputUser!="") {
// assets/js/socket.jsのデフォルトの定義と同じ
this.socket = new Socket("/socket", {params:
{token: window.userToken}
});
this.socket.connect();
this.channel = this.socket.channel("room:lobby", {user_name: this.state.inputUser});
// Presences:現在のサーバの状態を初期状態として設定
this.channel.on('presence_state', state => {
let presences = this.state.presences;
presences = Presence.syncState(presences, state);
this.setState({ presences: presences })
console.log('state', presences);
});
// Presences:初期状態からの差分を更新していく
this.channel.on('presence_diff', diff => {
let presences = this.state.presences;
presences = Presence.syncDiff(presences, diff);
this.setState({ presences: presences })
console.log('diff', presences);
});
// メッセージを受け取る処理
this.channel.on("new_msg", payload => {
let messages = this.state.messages;
messages.push(payload)
this.setState({ messages: messages })
})
// channelにjoinする
this.channel.join()
.receive("ok", response => { console.log("Joined successfully", response) })
.receive('error', resp => { console.log('Unable to join', resp); });
this.setState({ isJoined: true })
}
}
// 退室の処理 socketを切断するだけ。これでいいのか?
handleLeave(event) {
event.preventDefault();
this.socket.disconnect();
this.setState({ isJoined: false })
}
// メッセージ送信の処理
handleSubmit(event) {
event.preventDefault();
this.channel.push("new_msg", {msg: this.state.inputMessage})
this.setState({ inputMessage: "" })
}
// 画面表示
render() {
const style1 = { margin: '16px 32px 16px 16px', padding: '10px 32px 10px 26px',};
const style2 = { display: 'inline-block', margin: '1px 8px 1px 4px',};
const messages = this.state.messages.map((message, index) => {
return (
<div key={index}>
<p><strong>{message.user_name}</strong> > {message.msg}</p>
</div>
)
});
let presences = [];
Presence.list(this.state.presences, (name, metas) => {
presences.push(name);
});
let presences_list = presences.map( (user_name, index) =>
<li key={index} style={style2}>{user_name}</li>
);
let form_jsx;
if(this.state.isJoined===false) {
form_jsx = (
<form onSubmit={this.handleJoin.bind(this)} >
<label>ユーザ名を指定してJoin</label>
<TextField hintText="ユーザ名" value = {this.state.inputUser} onChange = {this.handleInputUser.bind(this)} />
<RaisedButton type="submit" primary={true} label="Join" />
</form>
);
} else {
form_jsx = (
<div>
<Paper style={style1}>
<label>参加者 : {this.state.inputUser}</label>
<ul>
{presences_list}
</ul>
<div align="right">
<form onSubmit={this.handleLeave.bind(this)} >
<RaisedButton type="submit" primary={true} label="Leave" />
</form>
</div>
</Paper>
<Paper style={style1}>
<form onSubmit={this.handleSubmit.bind(this)}>
<label>チャット</label>
<TextField hintText="Chat Text" value = {this.state.inputMessage} onChange = {this.handleInputMessage.bind(this)} />
<RaisedButton type="submit" primary={true} label="Submit" />
</form>
<Divider />
<br />
<div>
{messages}
</div>
</Paper>
</div>
);
}
return (
<div>
{form_jsx}
</div>
)
}
}
export default Chat
3.チャット画面
わたしは疲れました。
以上でReactChat Applicationの作成が終わりです。