初めてこちらに投稿させていただきます@daihaseと申します。
いつもは個人のブログの方で色々書いているのですが、より多くの人達に共有していけたらと思い、その第一弾として投稿致しました。
今回のお題は「AWS + Nginx + Node.js + iOS(Swift) でリアルタイムチャットアプリを作ろう」ですが、なぜこれを書こうと思ったか。
個人で開発するスマホアプリにおいて、チャットを0から実装するのはボリューム的にもそうそうないかなと思っております。その理由としてはiOS/Androidのクライアント側だけではなくサーバーサイドの実装も色々必要になってくるからです。
そこでググってみたら色々出てくるではありませんか。iOSだけを例に取らせていただきますが、その内容のほとんどがFirebaseを使ったものでした。
まぁそれだけFirebaseが優秀でいかにサーバーレスで簡単にリアルタイムチャットのようなサービスを組みやすいか、ってことですね。
これ自体は全然素晴らしいことで良いのですが、僕は結構昔にParseというサービスを使ってとあるiOS/Androidアプリを開発していた時期がありました。ご存知Parseはサービスを終了することとなったのですが、その時の損害というかダメージが大きかったが若干トラウマになっています…。
ParseやFirebaseといったようなサービスに頼るとロックインの危険性があるってわけですね。だからといってAWS等が必ずや永遠に続くサービスで安心かというとそういうわけでもありませんが。
そこでググっても全然出てこなかったということもあり、Firebaseを使わず同じくらいのボリュームのものを自前で作るとどれだけ大変なのか、実際にやってみることにしました。
使用環境 (ちなみにiOSクライアント側以外は基本最新にしとけば問題ないです)
- 開発マシン (Mac)
- AWS (Ubuntu 14.04 LTS)
- Nginx (1.10.0)
- Node.js (7.2.1)
- Swift (3.0) + Xcode8.2
また今回は一部html, cssのソースは下記記事書いてくださった方のを使わせてもらってます。こちらのFirebaseではなく自鯖による実装版、みたいな感じでみてもらえればと。
[swiftでリアルタイムチャット]
http://qiita.com/ryotakodaira/items/b234d1d51ae6b1110e8b
AWS
インスタンスの作成と設定
まずAWSでUbuntuサーバーを立ち上げます。構成ですが、1:1のチャットを実現するだけなのでスペックは最小のもので問題ありません。
インスタンスはt2.nano、セキュリティグループはインバウンド側22, 80, 3000, 443を開けておいてください。3000はNode.jsで使うPort、443はSSLです。(今回SSLは使ってませんが)
この1つのインスタンスにNginx + Node.jsを載せる感じですね。今回は簡単サンプルなのでDBは使いません。
ファイアウォールの設定
Ubuntuのファイアウォール設定ではufwを使用します。ufwはiptablesのラッパーでこれを使用することで簡単にファイアウォールの設定が出来ます。以下のコマンドでインストールします。
$ sudo apt-get install ufw
インストールが完了したらファイアウォールを有効に。
$ sudo ufw enable
とりあえず必要なポートを全て開けておきましょう。
$ sudo ufw allow 22
$ sudo ufw allow 3000
$ sudo ufw allow 80
$ sudo ufw allow 22/tcp
$ sudo ufw allow 3000/tcp
$ sudo ufw allow 80/tcp
設定が完了したらリロードします。
$ sudo ufw reload
キーペアファイルを使ってssh接続確認しましょう。無事接続も出来たらAWS側の設定は一旦終わり。実際は他にもやるべき初期設定等ありますが、ここでは割愛。
Nginx
インストール
では立ち上げたUbuntuサーバーに対しNginxをインストールします。インストール方法はソースからやれば細かい設定も出来ますが、ここでは一番最短なパッケージから入れます。
$ sudo apt-get update
$ sudo apt-get install nginx
インストール出来たか確認。
$ nginx -v
nginx version: nginx/1.10.0 (Ubuntu)
Nginxを起動しましょう。
$ sudo service nginx start
ブラウザでホスト名を打ち込んで「Welcome to nginx!」という画面が出れば無事起動しています。
表示するNode.jsのパス等を設定
Nginxがインストールできたら次はlocationディレクティブ等追記し、実際ブラウザからアクセスするための設定を行います。
以下を入力し設定ファイルを開きます。
$ sudo vim /etc/nginx/nginx.conf
後から設定するNode.jsの特定のhtmlを表示させるためにバーチャルサーバーの定義、locationディレクティブ等の設定を行います。
http {
.
.
.
server {
listen 80;
server_name local_host;
location /chat/ {
root /var/www/app;
index chat.html, index.html;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
rewrite ^/chat/(.*) /$1 break;
proxy_pass http://localhost:3000;
}
}
}
httpディレクティブ内に上記server、locationディレクティブを追記します。後ほど/var/www/app/内にchatディレクトリを作成しその中にNode.jsのプロジェクトを作成します。そのため、http://[ドメイン]/chat/chat.html でアクセスするために上記設定を行っています。
nginx.confを設定したら、以下でNginxを再起動し反映させます。
$ sudo nginx -s reload
Node.js
Node.jsのインストール
UbuntuサーバーにNode.jsをインストールします。こちらも色々インストールの仕方があるのですが、一番手っ取り早い方法でいきます。node.jsとパッケージ管理ツールをまとめてインストールします。
$ sudo apt-get install -y nodejs npm
次にNode.jsのバージョン管理に関して。nvmが有名ではありますが、npmのnをここでは使います。
$ sudo npm cache clean
$ sudo npm install n -g
今度はインストールしたnを使ってNode.jsとnpmを最新版にします。
$ sudo n stable
$ sudo npm update npm -g
それではNode.jsのインストールは一旦ここで終了。
サーバーサイドの実装
ブラウザ側チャット画面と、そのサーバーサイドの実装を行います。サーバーサイドがNode.js、ブラウザ側のチャット画面はHtml5 + Javascript(JQuery) +CSSを使って作成します。
ローカルの開発環境ですがNode.jsやPHP, Java開発など一般的なWeb開発に最適なIDEがあるのでそれを使います。その名もIntelliJ IDEA。日々Web開発するエンジニアにとっては普通に買う価値アリなので、まだ買ってない方はこれを機にぜひ。
まずIDEAでchatという名前でStatic Webプロジェクトを作成してください。プロジェクトが作成されたら、jsファイル等を追加して行って以下のようなファイル構成にします。
「node_modules」ディレクトリだけ無視してください。こちらローカルでnpmインストールして出来ただけなので実際はなくて大丈夫です。
それでは1つずつソースを書いていきます。
まずpackage.jsonから。
※ここではexpressなど入れてますが今回使用しないので実際はmimeとsocket.ioだけで問題ありません。
{
"name": "chat",
"version": "0.0.1",
"description": "Minimalist multiroom chat server",
"dependencies": {
"express": "^4.14.0",
"mime": "~1.2.7",
"redis": "^2.6.2",
"socket.io": "^1.4.5"
}
}
次にNode.jsのサーバーとして起動させるためのserver.js
var http = require('http');
var fs = require('fs');
var path = require('path');
var mine = require('mime');
var cache = {};
function send404(response) {
response.writeHead(404, {'Content-Type': 'text/plain'});
response.write('Error 404: resource not found.');
response.end();
}
function sendFile(response, filePath, fileContents) {
response.writeHead(
200,
{"content-type": mine.lookup(path.basename(filePath))}
);
response.end(fileContents);
}
function serveStatic(response, cache, absPath) {
if (cache[absPath]) {
sendFile(response, absPath, cache[absPath]);
} else {
fs.exists(absPath, function (exists) {
if (exists) {
fs.readFile(absPath, function (err, data) {
if (err) {
send404(response);
} else {
cache[absPath] = data;
sendFile(response, absPath, data);
}
});
} else {
send404(response);
}
});
}
}
// httpServer.
var server = http.createServer(function (request, response) {
var filePath = false;
if (request.url == '/') {
filePath = 'public/index.html';
} else {
filePath = 'public' + request.url;
}
var absPath = './' + filePath;
serveStatic(response, cache, absPath);
});
server.listen(3000, function () {
console.log("Server listening on port 3000.")
});
var chatServer = require('./lib/chat_server');
chatServer.listen(server);
一応存在しないパス等が来た場合にエラーを返す関数を用意し、後は普通にport3000でlistenします。
次に var chatServer = require('./lib/chat_server') でも指定しているchat_server.jsを。こちらはsocket.ioの具体的な処理を記述します。
var socketio = require('socket.io');
var io;
exports.listen = function (server) {
io = socketio.listen(server);
io.sockets.on('connection', function (clientSocket) {
console.log("WebSocket接続");
// 接続したユーザーをroomに入れる.
joinRoom(clientSocket, 'room');
// チャットメッセージを書き込むと処理する.
handleMessageBroadcasting(clientSocket);
// クライアントから送られてくるチャットメッセージ受け取り.
clientSocket.on("from_client", function (name, message) {
console.log("クライアントから送られてきたname: %s", name);
console.log("クライアントから送られてきたmessage: %s", message);
io.sockets.emit('receiveMessage', {name: name, message: message});
});
});
};
function joinRoom(clientSocket, room) {
// ユーザーをルームに参加させる.
clientSocket.join(room);
// ユーザーに新しいルームに入ったことを知らせる.
clientSocket.emit('joinResult', {room: room});
}
function handleMessageBroadcasting(clientSocket) {
clientSocket.on('newChatMessage', function (message) {
console.log("チャットにサーバーから書き込み");
// チャットに書き込んだメッセージをクライアントに通知.
io.emit('from_server', message.message)
});
}
ここまでがサーバー側の実装。次にブラウザの画面周りに関わるファイルを作成していきます。チャット画面にあたるchat.htmlです。こちらsocket.io.jsやjqueryらを読み込むのを忘れずに。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>chat</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="stylesheets/chatStyle.css">
</head>
<body>
<div class="panel-default">
<div class="panel-heading">
<p>チャットサンプル</p>
</div>
<div id='room'></div>
<div id="scroller" class="panel-body">
<ul id='messages'></ul>
</div>
<form id="send-form">
<div class="form-group">
<label>メッセージ</label>
<input class="span11" id="send-message" type="text" placeholder="メッセージ内容を入力してください。">
<button class="btn btn-primary" id="send-button" type="button">送信</button>
</div>
</form>
</div>
<script src="/chat/socket.io/socket.io.js" type="text/javascript"></script>
<script src="http://code.jquery.com/jquery-1.8.0.min.js" type="text/javascript"></script>
<script src="javascripts/chat.js" type="text/javascript"></script>
<script src="javascripts/chat_ui.js" type="text/javascript"></script>
</body>
</html>
Chatクラスを作成するファイルです。
var Chat = function(socket) {
this.socket = socket;
};
// クライアントに書き込んだメッセージ内容を返す.
Chat.prototype.sendMessage = function(message) {
this.socket.emit('newChatMessage', {message: message});
}
チャットの表示周りを行うファイルです。
// チャットメッセージ書き込み.
function updateChatMessage(chatApp, name, message) {
if (name == "山田 太郎") {
var messageElement = $("<il><p class='sender_name me'>" + name + "</p><p class='right_balloon'>" + message + "</p><p class='clear_balloon'></p></il>");
// クライアントに通知.
chatApp.sendMessage(message);
} else {
var messageElement = $("<il><p class='sender_name'>" + name + "</p><p class='left_balloon'>" + message + "</p><p class='clear_balloon'></p></il>");
}
// チャットボードに書き込み.
$('#messages').append(messageElement);
$('#messages').scrollTop($('#messages').prop('scrollHeight'));
$('#send-message').val('');
}
var socket = io.connect('http://"ここにドメイン名を".xip.io:3000');
$(document).ready(function () {
var chatApp = new Chat(socket);
// ルーム参加.
socket.on('joinResult', function (result) {
console.log("ルームへ入室")
$('#room').text("接続しているルーム: " + result.room);
});
// クライアントから来たメッセージ受信.
socket.on('receiveMessage', function (data) {
updateChatMessage(chatApp, data.name, data.message);
});
$("#send-message").focus();
// 「送信」ボタンクリック.
$('#send-button').click(function () {
// フォームに入力したメッセージを取得.
var message = $('#send-message').val();
updateChatMessage(chatApp, "山田 太郎", message);
});
});
ちょっとサンプルなので色々雑ですが、ブラウザ側は「山田 太郎」という名前で固定です。この山田 太郎と、この後作成するiOS側のユーザーが1:1のチャットを行い、その描画周りをこのファイルで行っている感じです。
最後にCSS。チャット画面や吹き出しなどをレイアウトします。
#room {
background-color: #ddd;
margin-bottom: 1em;
}
ul,li{
padding: 0;
margin: 0;
list-style-type: none;
text-align: left;
}
#scroller {
height: 500px;
overflow: auto;
}
.left_balloon {
position: relative;
background: #f1f0f0;
border: 0px solid #777;
margin: 5px 10px;
padding: 5px 10px;
border-radius: 15px;
width: 400px;
clear: both;
}
.right_balloon {
color: #fff;
position: relative;
background: #0084ff;
border: 0px solid #777;
margin: 5px 10px;
padding: 5px 10px;
border-radius: 15px;
width: 400px;
clear: both;
float: right;
}
.clear_balloon{
clear: both;
}
.sender_name{
margin-bottom: -5px;
padding-left: 20px;
color: rgba(0, 0, 0, .40);
}
.sender_name.me{
float: right;
padding: 0 20px 0 0;
}
これでNode.js、ブラウザ側の実装はおしまいです。
サーバーへデプロイ
作ったこれらをサーバーへ上げます。デプロイツールなど色々ありますが、IDEAにはDeployment機能があるのでそちらを使います。
sftpで「chat」というのを作成。「Connection」タブではサーバーの情報を書き込みます。User Nameは「ubuntu」、Autho typeをKey pairにしてAWSのpemを指定します。SFTPはサーバーのホスト名ですね。
ちなみに「Test SFTP Connection」ボタンで実際にssh接続による疎通確認が取れますので、ここで失敗してたら設定が間違っています。
次に「Mappings」タブを開いてマッピングを行います。こちらは実際サーバーにあげる場所を指定します。 /var/www/app/chat ですね。
設定が完了したら、OKを押して終了しましょう。
IDEA画面左側のエクスプローラーのプロジェクト名のところにカーソルをやって⌘ + alt + shift + x でサーバーへアップします。(右クリックからのUpload to chatというのを選んでもOKです)
AWSでsshログインし、実際 /var/www/app/chatができてるか確認しましょう。また所有権やアクセス権等がちゃんと設定されてないかもしれないので、その場合は/var/www/app上で以下をしておきましょう。
$ sudo chmod 755 -R chat
$ sudo chown ubuntu -R chat
$ sudo chgrp ubuntu -R chat
サーバー側に必要なモジュールをインストールするために/var/www/app/chat上で以下を叩きます。
$ npm install
これでサーバーサイド側はおしまい。最後、iOSのアプリ側の作成を行います。
iOS(Swift3 + Xcode8.2)
プロジェクトの作成
Xcodeを起動し、「chat」プロジェクトを作成します。最初に作られるViewControllerは使用しないので消してしまっても構いません。では早速必要なクラスなどを作成していきます。
まず今回アプリ側とサーバーでNode.jsを使ったリアルタイムチャットを行うのですが、それにはWebSocketを使って実現します。Socket.ioのライブラリは色々あるのですが「Socket.IO-Client-Swift」が一番使いやすかったのでこちらを採用します。
[Soeket.IO-Client-Swift]
https://github.com/socketio/socket.io-client-swift
次にアプリ側でチャットのUIを簡単に作成してくれる優秀なライブラリがあるので、今回はそちらも使います。
[JSQMessagesViewController]
https://github.com/jessesquires/JSQMessagesViewController
どちらもPodfileを作成し、そちらに記入します。
source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '10.0'
use_frameworks!
target 'chat' do
pod 'Socket.IO-Client-Swift', '~> 8.2.0'
pod 'JSQMessagesViewController'
end
こちら作成し追記したらターミナル上で'pod install'しxcworkspaceを作成。こちらで次からは起動してやります。
※Xcodeが起動したら一度ビルドしてエラーを解除してやります。
次にWebSocket周りを管理するマネージャークラスを作成します。
import SocketIO
import JSQMessagesViewController
class SocketIOManager: NSObject {
// Singleton.
class var sharedInstance: SocketIOManager {
struct Static {
static let instance: SocketIOManager = SocketIOManager()
}
return Static.instance
}
private override init() {super.init()}
var socket: SocketIOClient = SocketIOClient(socketURL: NSURL(string: "http://ドメイン名:3000/chat/public/chat.html") as! URL)
// 接続.
func establishConnection() {
socket.on("connect") { data in
print("iOS側からサーバーへsocket接続.")
}
socket.connect()
}
// 切断.
func closeConnection() {
socket.on("disconnect") { data in
print("socketが切断されました")
}
socket.disconnect()
}
// メッセージ送信.
func sendMessage(_ message: String, name: String) {
socket.emit("from_client", name, message)
}
// メッセージ受信.
func getChatMessage(_ completionHandler: @escaping (_ messageInfo: JSQMessage) -> Void) {
socket.on("from_server") { (dataArray, socketAck) -> Void in
print(dataArray[0])
let message = dataArray[0] as! String
let jsqMessage = JSQMessage(senderId: "Other", displayName: "B", text: message)
completionHandler(jsqMessage!)
}
}
}
基本的なon、emitの関数らが定義されたマネージャークラスですね。
次にチャット画面となるChatViewControllerを。
import UIKit
import JSQMessagesViewController
class ChatViewController: JSQMessagesViewController {
var name: String?
var messages: [JSQMessage] = []
override func viewDidLoad() {
super.viewDidLoad()
self.setChatModuleParam()
// キーボードのジェスチャー登録.
let tap: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(ChatViewController.dismissKeyboard))
self.view.addGestureRecognizer(tap)
}
func setChatModuleParam() {
senderDisplayName = "A"
senderId = "Self"
self.name = "毒きのこ"
// Node.jsからのメッセージをブロードキャストし、画面にそれを表示。
SocketIOManager.sharedInstance.getChatMessage {
(messageInfo)-> Void in
self.messages.append(messageInfo)
self.finishReceivingMessage(animated: true)
}
}
func dismissKeyboard() {
self.view.endEditing(true)
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
}
// MARK: JSQMessageViewController
extension ChatViewController {
override func collectionView(_ collectionView: JSQMessagesCollectionView!, messageDataForItemAt indexPath: IndexPath!) -> JSQMessageData! {
return messages[indexPath.row]
}
// チャットの吹き出し設定.
override func collectionView(_ collectionView: JSQMessagesCollectionView!, messageBubbleImageDataForItemAt indexPath: IndexPath!) -> JSQMessageBubbleImageDataSource! {
if messages[indexPath.row].senderId == senderId {
return JSQMessagesBubbleImageFactory().outgoingMessagesBubbleImage(
with: UIColor(red: 112/255, green: 192/255, blue: 75/255, alpha: 1))
} else {
return JSQMessagesBubbleImageFactory().incomingMessagesBubbleImage(
with: UIColor(red: 229/255, green: 229/255, blue: 229/255, alpha: 1))
}
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return messages.count
}
// アバターのイメージ設定.
override func collectionView(_ collectionView: JSQMessagesCollectionView!, avatarImageDataForItemAt indexPath: IndexPath!) -> JSQMessageAvatarImageDataSource! {
return JSQMessagesAvatarImageFactory.avatarImage(
withUserInitials: messages[indexPath.row].senderDisplayName,
backgroundColor: UIColor.lightGray, textColor: UIColor.white,
font: UIFont.systemFont(ofSize: 10), diameter: 30)
}
// Sendボタンが押された時に呼ばれる.
override func didPressSend(_ button: UIButton!, withMessageText text: String!, senderId: String!, senderDisplayName: String!, date: Date!) {
// 新しいメッセージデータを追加する.
let message = JSQMessage(senderId: senderId, displayName: senderDisplayName, text: text)
self.messages.append(message!)
self.finishReceivingMessage(animated: true)
// サーバーへメッセージ送信.
SocketIOManager.sharedInstance.sendMessage(text, name: name!)
// TextFieldのテキストをクリア.
self.inputToolbar.contentView.textView.text = ""
self.inputToolbar.toggleSendButtonEnabled()
}
}
超最低限の実装ですね。 とりあえずJSQMessagesViewControllerの仕様に従ってsenderIDやらnameなんかを設定してやります。
ちなみにこのライブラリ、デフォルトで添付のためのボタンなんかも表示されますが、その受け皿を用意してないのでこのままだとタップすると落ちます。まぁドキュメントも充実してますし(Objective-Cですが)、簡単なんでファイル添付等にも挑戦したい方はやってみると良いかもしれません。
最後、StoryboardでCustom Classの設定を忘れずに。
これでiOS側は完了です。JSQMessageViewControllerのおかげでチャット周りの描画だのなんだのってのをほとんど実装しないですみましたね。これは楽。
これで一通り実装は終わりです。実際の動作を見てみましょう。
動作確認
全ての準備が出来ましたらAWSの/var/www/app/chat上で以下を実行してNode.jsからサーバーを立ち上げましょう。
$ node server.js
これでアプリやブラウザからWebsocketへのコネクションが貼れるようになります。
さて、実行したらどうなるか。
一番左がチャットのログをとって表示してます。真ん中がブラウザ画面ですね。こっちは「山田 太郎」としてチャットに参加してます。一番右がiOSのシミュレーター画面です。「毒きのこ」でチャットに参加してます。問題なく動作してますね。もちろん実機を使っても確認可能です。
おわりに
どうでしょうか、実際これFirebaseを使った場合と違いチャットのやりとり履歴なんかは残っていません。それをやるなら別途DBサーバーをたてるか一般的にはRedisを使って保存・表示等してやらないと行けません。
そう考えるといかにFirebaseが楽かがわかりますね。基本的な設定さえしてしまえばサーバーサイドを一切考えずにフロント側の開発だけに集中できますから。
今回はiOSでのリアルタイムチャットアプリということで、諸々自前で実装し実現させてみました。ベンダーロックインのようなリスクを恐れる方は自前で作ってみてはいかがでしょうか。
今回のプロジェクト一式はGitHubにあげておきます。
https://github.com/daihase/Realtime_Chat_App