Help us understand the problem. What is going on with this article?

LINE風チャットWebアプリを作ってみる(初心者向け)

自己紹介

バックエンドエンジニアとして働いています!
仕事では主にJavaを使っていますが、趣味でGoやVueを触っています!最近は、flutterも触り始めました!twittergithubもやってます!お話ししましょうー

成果物

スクリーンショット 2020-01-19 22.20.02.png

スクリーンショット 2020-01-19 22.19.39.png

最後にGithubのコードも上がっているので参考にしてください!

できること

  • ルーム名とユーザ名を指定してルームに入ることができます。
  • そのルーム内でリアルタイムチャットができます。

内容はそこまで濃くはないのですが、
Webアプリをどういう風に作っていったかを見ていくことで
少しでもお役に立てればと思います。

使用技術

websocket

websocketはサーバとクライアントの間で双方向通信する仕組みです!リアルタイム通信で使われます。
今回はこの仕組みを用いて、ユーザの投稿を同じ部屋にいるユーザに対して送ってあげる処理を実現します。
line-gazo.jpg

解説

 ディレクトリ

.(line-chat-go)
├── Makefile
├── README.md
├── cmd
│   └── main.go
├── go.mod
├── go.sum
├── router.go
└── view
    ├── index.html
    ├── room.html
    └── static
        ├── css
        │   └── common.css
        ├── img
        │   └── icon.png
        └── js
            └── index.js

プロジェクトを作成する

$ cd line-chat-go
$ go mod init chat

まず、Goでは(version 1.11以上から)
go mod init (モジュール名)でモジュールを生成します。このモージュル名は、importする際のルートの名前になります。
例えば、 上のディレクトリのmain.goを指す場合は
import "chat/cmd"

となります。(今回はcmd以下がmainパッケージなので、importすることはありませんが。。。)

main.go を作る

cmd/main.go
package main

import (
    "chat"
)

func main() {
    chat.Run()
}         

たったこれだけです。
では、Run()メソッドを実装しているrouter.goを見てみましょう

router.go を作る

router.go
package chat

import (
    "github.com/gin-gonic/gin"
    melody "gopkg.in/olahol/melody.v1"
    "net/http"
)

func Run() {
    r := gin.Default()
    m := melody.New()

    r.Static("/static", "./view/static")
    r.LoadHTMLGlob("view/*.html")

    r.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{})
    })

    r.GET("/room/:name", func(c *gin.Context) {
        c.HTML(http.StatusOK, "room.html", gin.H{
            "Name": c.Param("name"),
        })
    })

    r.GET("/room/:name/ws", func(c *gin.Context) {
        m.HandleRequest(c.Writer, c.Request)
    })

    m.HandleMessage(func(s *melody.Session, msg []byte) {
        m.BroadcastFilter(msg, func(q *melody.Session) bool {
            return q.Request.URL.Path == s.Request.URL.Path
        })
    })

    r.Run(":8080")
}

まずimportを見てみましょう
ここでは
- ginというWebフレームワークを用いています。
- melodyは、WebSocketを簡単に利用することができるパッケージです。

少しだけginの書いている所を解説していきます。
いくつか抜き出します。

func Run() {
    r := gin.Default()
    r.Static("/static", "./view/static")
    r.LoadHTMLGlob("view/*.html")
    r.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{})
    })
    r.Run(":8080")
}

r := gin.Default()で、ginを初期化します。

r.Static("/static", "./view/static")は、view/staticフォルダ以下のファイルをhttp://xxxxx/static
に割り当てることができます
(サーバを実行した状態で
http://localhost:8080/static/img/icon.png
とブラウザに打つと、view/static/img/icon.pngを返してくれます)

r.LoadHTMLGlob("view/*.html")は、HTMLファイルを読み込みます。
staticとの違いは、テンプレートとして利用できるようになります。
テンプレートはGoの仕組みで文字列を埋め込んだりすることができます。

次に,ここがルーティングを設定します。

r.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{})
})

ルーム参加するためのページを表示する時、このページがリクエストされます。
urlで
http://xxxxx/
にGetアクセスが来たときに、index.htmlを返すという処理を書いています。
このindex.htmlはr.LoadHTMLGlob()でロードされているファイルから読みこみます。

最後に
r.Run(":8080")
は、8080ポートで待ち受けるということです。
ポートは、ネットを繋ぐ時の穴の番号で、urlに表示されていない(省略されている)ときは、80か443です。

次はginの応用編です。

r.GET("/room/:name", func(c *gin.Context) {
        c.HTML(http.StatusOK, "room.html", gin.H{
            "Name": c.Param("name"),
        })
})

/room/(名前)にリクエストが来たときに、room.htmlを返却するという意味です。
特定のルームに参加するときに、このページがリクエストされます。
この(名前)はc.Param("name")で動的に取得できます。
gin.H{}とは、テンプレートに渡す値を格納しておくものです。
今回だと"Name"というパラメータに:nameの値を代入しています。

<body>{{.Name}}</body>

と書いてあると、ginが自動的に名前を埋め込んでから、レスポンスを返してくれます。

残りはWebSocketの処理です。

m := melody.New()

r.GET("/room/:name/ws", func(c *gin.Context) {
    m.HandleRequest(c.Writer, c.Request)
})

m.HandleMessage(func(s *melody.Session, msg []byte) {
    m.BroadcastFilter(msg, func(q *melody.Session) bool {
        return q.Request.URL.Path == s.Request.URL.Path
    })
})

/room/:name/wsにリクエストが来たときは、webSocketの通信として、HandleMessageの処理を行います。
BroadcastFilter()は、ブロードキャストなので、クライアントから/room/:name/wsにリクエストが来ると、送られてきた値をサーバとつながっているクライアント全員に、送信します。
今回は、部屋名が同じクライアントのみに送りたいので、Filterをかけています。

index.html

view/index.html
<html>
  <head>
    <title>LINE</title>
    <link rel="stylesheet" href="/static/css/common.css">
  </head>

  <body>

    <div class="line">
      <div id="room" class="line-header">Join Room</div>
      <div id="chat" class="line-container"></div>
      <div class="line-form">
        <input placeholder="room" id="channel" type="text">
        <input placeholder="user name" id="user" type="text">
        <button id="join" class="line-form-button" onclick="send_data()">Join</button>
      </div>
    </div>

    <script>
      const chan = document.getElementById("channel");
      const join = document.getElementById("join");
      const user = document.getElementById("user");

      join.onclick = function () {
        if (chan.value != "") {
          localStorage.setItem("user",user.value)
           window.location = "/room/" + chan.value;
        }
      };

    </script>
  </body>
</html>

jsを使った処理を書いていますが、
joinしたときに、ローカルストレージにuserの名前を保存しています。
ローカルストレージとは、ブラウザにあるキーバリュー形式の保存場所です。ページが切り替わっても保持されています。

そのままだと見た目が悪いので、cssを追加しましょう

view/static/css/common.css
* {
    margin: 0px;
    padding: 0px;
}

.line-header{
    top:0;
    left: 0;
    height: 50px;
    width: 100vw;
    background-color:#253749;
    color:white;
    display: table-cell;
    text-align: center;
    vertical-align: middle;
}
.line-container{
    background-color:#49F;
    height: calc(100% - 100px);
    overflow: scroll;
}


.line-form{
    bottom: 0;
    left: 0;
    height: 50px;
    background-color: #eee;
    display:flex;
}

.line-form-button{
  color: #FFF;
  background: #03A9F4;/*色*/
  border: solid 1px #039fAA;/*線色*/
  border-radius: 5px;
  margin:8px;
  padding:1px 5px;
}

input{
    width: 80%;
    border-radius:5px;
    margin:8px 5px;
}
.line-right{
    position: relative;
    margin-right: 5%;
    float: right;
    display: block;
    max-width: 75%;
    margin: 5px 30px;
    clear: both;
}
.line-right::after{
    content: "";
    position: absolute;
    top: 3px; 
    right: -19px;
    border: 8px solid transparent;
    border-left: 18px solid #3c3;
    -webkit-transform: rotate(-35deg);
    transform: rotate(-35deg);
}
.line-right .line-right-text{
    background: #3c3;
    border-radius: 10px;
    padding:0.5em 1em ;
    word-break: break-all;
}

.line-right-time{
    color: white;
    size:0.2em;
    float: left;
}

.line-left{
  position: relative;
  padding: 10px;
  float: left;
    display: flex;
    margin: 2px 0;
    max-width: 75%;
    clear: both;
}

.line-left-container{
    margin-left:15px; 
    overflow: hidden;
}

.line-left .line-left-text{
    background: #eee;
    border-radius: 10px;
    padding:0.5em 1em ;
    word-break: break-all;
}
.line-left .line-left-text::after{
  content: "";
  display: block;
  position: absolute;
  top: 30px; 
  left: 50px;
  border: 8px solid transparent;
  border-right: 18px solid #edf1ee;
  -webkit-transform: rotate(35deg);
  transform: rotate(35deg);
}

.line-left-time{
    color: white;
    size:0.2em;
    float: right;
}

.line-left-name{
    color: white;
}

.line-left img{
    border-radius: 50%;
    width: 40px;
    height: 40px;
    border: #333;
    background-color: #eee;
}

@media screen and (max-width:600px){
    .line{
        height: 100%;
    }
}
@media screen and (min-width:601px){
    .line {
        height: 500px;
        width: 300px;
        margin: calc((100vh - 500px)/2) auto;
        border: solid 30px  #aaa;
        border-radius: 1em;
    }
}

すると
スクリーンショット 2020-01-19 23.13.39.png

こんな感じになってます。

room.html

room.html
<html>
  <head>
    <title>LINE</title>
    <meta name="viewport" content="width=device-width">
    <link rel="stylesheet" href="/static/css/common.css">
  </head>
  <body>
    <div class="line">
      <div id="room" class="line-header">{{.Name}}</div>
      <div id="chat" class="line-container"></div>
      <div class="line-form">
        <input id="text" type="text">
        <button class="line-form-button" onclick="send_data()">Send</button> 
      </div>
    </div>
    <script type="text/javascript" src="/static/js/index.js"></script>
  </body>
</html>

見た目は、index.htmlと同じです。
特徴としては、room.htmlはテンプレートとして読み込んでいるため、Goのサーバ側で{{.Name}}にルーム名が埋め込まれています。

<div id="room" class="line-header">{{.Name}}</div>

さらにここでは、webSocketでサーバと通信する必要があるため、その処理をjs/index.jsに記述していきます。

view/static/js/index.js
const url = "ws://" + window.location.host + window.location.pathname + "/ws";
const ws = new WebSocket(url);
const name = localStorage.getItem("user")
const chat = document.getElementById("chat");

const text = document.getElementById("text");

ws.onmessage = function (msg) {
  let obj = JSON.parse(msg.data);
  obj.message = escape_html(obj.message);
  let line ="";
  if (obj.name==name){
    line =`<div class='line-right'>
            <p class='line-right-text'>${obj.message} </p>
            <div class="line-right-time">${now()}</div>
           </div>`
  }else{
    let image = '<img src="/static/img/icon.png"/>'
    line =`<div class='line-left'>
                ${image}
                <div class='line-left-container'>
                    <p class='line-left-name'>
                    ${obj.name}
                    </p>
                    <p class='line-left-text'>
                    ${obj.message}
                    </p>
                    <div class='line-left-time'>
                        ${now()}
                    </div>
                </div>
           </div>`
  }
  chat.innerHTML += line;
};

text.onkeydown = function (e) {
  if (e.keyCode === 13) {
    send_data();
  }
};

function send_data(){
    if (text.value == "")return;
    text.value = escape_html(text.value);
    let sendData = `{"name":"${name}","message":"${text.value}"}`;
    ws.send(sendData);
    text.value = "";
}

function now() {
    let date = new Date();
    let min = (date.getMinutes()<10)?`0${date.getMinutes()}`:date.getMinutes();
    let hour = (date.getHours()<10)?`0${date.getHours()}`:date.getHours();
    return `${hour}:${min}`
};

function escape_html (string) {
    if(typeof string !== 'string') {
      return string;
    }
    return string.replace(/[&'`"<>]/g, function(match) {
      return {
        '&': '&amp;',
        "'": '&#x27;',
        '`': '&#x60;',
        '"': '&quot;',
        '<': '&lt;',
        '>': '&gt;',
      }[match]
    });
}

少し説明していきます。

const url = "ws://" + window.location.host + window.location.pathname + "/ws";
const ws = new WebSocket(url);

jsファイルが読み込まれるときに、websocketでサーバに通信しています。

ws.onmessage = function (msg) {
  let obj = JSON.parse(msg.data);
};

ここでは、Goのサーバからブロードキャストでデータが送られてきたときに実行されます。

function send_data(){
    if (text.value == "")return;
    text.value = escape_html(text.value);
    let sendData = `{"name":"${name}","message":"${text.value}"}`;
    ws.send(sendData);
    text.value = "";
}

逆にサーバに値を送るときは、ws.send()を利用して送信します。

{
    "name":"user",
    "message":"メッセージ"
}

基本的にデータ構造はjson型で以下のように送受信されています。

実行してみる

$ go run cmd/main.go

ブラウザで、http://localhost:8080/
とうって確認してみてください。
タブで二つ開いて、同じ部屋にJoinすると、LINEの見た目でリアルタイム通信ができるはずです。
line2.gif

これで以上です。

最後に

今回はWebSocketを使って、Line風チャットを作ってみました。
githubにコードをあげていますー!
https://github.com/ryomak/line-chat-go
少しでもWeb制作のお役に立てれば良いなと思います!

Go生活楽しみましょー!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした