LoginSignup
46
40

More than 3 years have passed since last update.

Vue.js+Go+Firebaseでザクっとチャットボット

Last updated at Posted at 2018-12-21

はじめに

ゲーム作りとは別に普通のWebサービス作ってみてーなーみたいなことを思ったのと、CEDECにて「モバイルゲームのお問い合わせ対応にてAIチャットボットを導入してお問い合わせ件数を約20%削減した話」の講演を聞いたのもあって、「よーし、サクッとできるって言ってるしいっちょ機械学習以外のとこを自前で作ったろ!」と思ってはじめました。
そして、これを個人のポートフォリオにしてゲーム以外もできるんやで!って言えるようにしたかったという下心があり、チャットボットを作ってみることにしました。

普段は、ソーシャルゲーム会社でUnityC#でUIの挙動書いたりとか、APIサーバーとしてのLaravelのコード書いたりレビューしたり、必要になればJenkins触ってCI環境整備したりとかしてます。なので、今回の技術は初めて尽くしでした。

構想

  • Webでのチャット形式
    • 打ち込まれた文字列に対してレスポンスを返す
  • 機械学習API を使う
    • Google Prediction APIを考えてた
    • 結局現状は簡単だったリクルートのTalkAPI
  • ユーザーログインする
    • ユーザー質問とボット回答をDBに保存するため

最終的に実装したアーキテクチャ

アーキテクチャ図.png

コード

Firebaseプロジェクトを作る

Firebaseの導入に関しては下記のリンクが大変参考になります、と言うかほぼこのまま僕は使ってます。
https://cr-vue.mio3io.com/tutorials/firebase.html

Twitter認証だとTwitter側にアプリケーション登録とかしないといけないとめんどくさかったので、今回はメールアドレス認証を使っています。これだと特に準備いらない。

要点としては、RealTimeDatabaseのルール設定です。
これによって、DBの読み書き権限が設定されます。
間違えるとやりたい放題にされると思うので、間違えないように。

Firebaseの初期化についてはフロント/バックの各項目で記載します。

Vue.js

準備

Vue.jsの開発環境として vue-cliを使用する。
そのため、node.jsとnpmが必要になるのでインストールしておいてください。

$node -v
v10.11.0
$ npm -v
6.4.1

vue-cliをインストールします。typescriptを使用するので @vue-cliである必要があります

$ npm install -g @vue/cli

$ vue -V
3.0.5

Vue-Cliのプロジェクトを作成

$ vue create chatbot-front

Vue CLI v3.0.5
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, Router, Linter
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript for auto-detected polyfills? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a linter / formatter config: TSLint
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

ローカルで立ち上げて、起動確認

$ cd chatbot-front
$ npm run serve

Firebaseを導入

npmでfirebaseをインストール

$ npm install firebase --save

main.ts でVueの初期化前にFirebaseのInitialize処理を追加する

main.ts
// Initialize Firebase
const config = {
    apiKey: process.env.VUE_APP_FIRE_BASE_API_KEY,
    authDomain: process.env.VUE_APP_AUTH_DOMAIN,
    databaseURL: process.env.VUE_APP_DATABASE_URL,
    projectId: process.env.VUE_APP_PROJECT_ID,
    storageBucket: process.env.VUE_APP_STORAGE_BUCKET,
    messagingSenderId: process.env.VUE_APP_MESSAGING_SENDER_ID,
};
firebase.initializeApp(config);

new Vue({
  router,
  render: (h) => h(App),
}).$mount('#app');

process.env.HOGEHOGE に関しては、 プロジェクトディレクトリ直下に配置された .env ファイルが読み込まれるようになっています。 npm run serve だとlocal buildなので .env.localが読み込まれている(多分)

FirebaseAuthの初期化 にて、どこからこれらの値を取得すればいいか書いてあるので見てみてください。

Firebase認証状態のベース

FirebaseBase.vue というコンポーネントを作成し、Firebase認証を使用するページのベースとして当てています。
下記のように生成時に、認証状態の変更を検知し、user != null(ログイン状態)なら User情報を保持するようにしています。

    private created(): void {
        Firebase.auth().onAuthStateChanged((user) => {
            if ( user != null) {
                user.getIdToken().then((token) => {
                    localStorage.setItem('jwt', token);
                    this.user = user;
                });
            }
        });
    }

認証した後にgetIdTokenしてるんですが、これがまたAPI走ってる気がする。
なんか最初の状態だけで終わらせたいんですけどもなんかいい方法ないっすかね。。。

サインイン/ログイン/ログアウト

サインイン

    private signup(): void {
        Firebase.auth().createUserWithEmailAndPassword(this.email, this.password).then((user) => {
            const name = user.user == null ? null : user.user.email;
            alert('Create account: ' + name);
        }).catch((error) => {
            alert(error.message);
        });
    }

ログイン

    private doLogin(): void {
        Firebase.auth().signInWithEmailAndPassword(this.email, this.password).then((res) => {
            alert('sucess login!');
        }, (err) => {
            alert(err.message);
        });
    }

ログアウト

    private doLogout(): void {
        Firebase.auth().signOut().then(() => {
            localStorage.removeItem('jwt')
      });
    }

これだけ!簡単!
https://github.com/kannan-xiao4/chatbot-front/blob/master/src/views/Signup.vue
https://github.com/kannan-xiao4/chatbot-front/blob/master/src/views/Login.vue

チャット実装

チャットの購読は、RealtimeDatabaseがWebsocketで繋がっているので、購読するだけ。
メッセージがPushされたら画面が更新される。

    private mounted(): void {
        Firebase.auth().onAuthStateChanged((user) => {
            const REF_MESSAGE = Firebase.database().ref('message');
            if (user) {
                REF_MESSAGE.limitToLast(10).on('child_added', this.childAdded);
            } else {
                REF_MESSAGE.limitToLast(10).off('child_added', this.childAdded);
            }
        });
    }

チャットの送信は、バックエンドのGoに対してPost。
メッセージは入力されたもの、name,imageは適当に。。。
正しくレスポンスが帰ってきたら入力フィールドを空にするようにしています

    private doSend(): void {
        if (this.user.uid && this.input.length) {

            ApiClient.Post('/api/chat',
                {
                    message: this.input,
                    name : this.user.displayName,
                    image : this.user.photoURL,
                })
                .then( (res) => this.input = '' );
        }
    }

Golang

準備

まずは go を入れる

$ go version
go version go1.11.2 darwin/amd64

依存管理ツールとして dep を使っているので入れてください

$ dep version
dep:
 version     : v0.5.0
 build date  : 2018-07-26
 git hash    : 224a564
 go version  : go1.10.3
 go compiler : gc
 platform    : darwin/amd64
 features    : ImportDuringSolve=false

Gin の導入

フレームワークとしてはGin(https://github.com/gin-gonic/gin) を使っています。
最初の参考としてはこちらを使わせてもらいました→ https://qiita.com/bossbuss0910/items/0fbe2460f6b3f33e15f8

Gorm の導入

ORMとして、Gorm(https://github.com/jinzhu/gorm) を使いました。
ひとまずさらっと http://doc.gorm.io/ この辺を読みながら。
LaravelのEloquentを使ってたので、まあ同じようなもんだろとなもんだと思ってます。

DBへの接続

これでとりあえずDBへ接続して、その gorm.DB を返す
これを基本に、DBへの読み書きを行なっています。
異なるDBに接続する場合は、引数にDBの名前を取れれば十分かな。

database.go
func Connect() *gorm.DB {
    user := os.Getenv("DATABASE_USER")
    password := os.Getenv("DATABASE_PASSWARD")
    host := os.Getenv("DATABASE_HOST_NAME")
    dbName := "chatbot"
    connect := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8&parseTime=True", user, password, host, dbName)

    db, err := gorm.Open("mysql", connect)

    if err != nil {
        panic(fmt.Sprintf("fail to connect %s database, connection format is %s, ", dbName, connect) + err.Error())
    }

    return db
}

Migrationの実行

とりあえずDBの作成はprovision.goで。。。
Gormとか関係なくほとんど素のSQL文実行してるが
https://github.com/kannan-xiao4/chatbot-back/blob/master/command/provision.go

マイグレーションはこちら
https://github.com/kannan-xiao4/chatbot-back/blob/master/command/migrate.go
引数で渡したstructの型でtableが作成されます。

Firebaseの導入

FirebaseのSDKのセットアップは、下記の部分を参照。
https://firebase.google.com/docs/admin/setup?authuser=0

json形式の秘密鍵?を取得して、 envfiles/hogehoge.jsonとなるようにファイルをおいてください。

認証処理

これはMiddlewareとして、間に挟んでいます。
下記を参考というか丸パクリ
https://qiita.com/po3rin/items/d3e016d01162e9d9de80#firebase-admin-sdk-go-%E3%82%BB%E3%83%83%E3%83%88%E3%82%A2%E3%83%83%E3%83%97%E3%81%AE%E6%BA%96%E5%82%99

firebaseauth.go
func FirebaseAuth(c *gin.Context) {

    // Firebase SDK のセットアップ
    opt := option.WithCredentialsFile(os.Getenv("FIREBASE_ADMIN_SDK_FILENAME"))
    app, err := firebase.NewApp(context.Background(), nil, opt)
    if err != nil {
        fmt.Printf("error initializing app: %v", err)
        firebaseUninitialized(c)
        return
    }
    auth, err := app.Auth(context.Background())
    if err != nil {
        fmt.Printf("error initializing app: %v", err)
        firebaseUninitialized(c)
        return
    }

    authorizationHeader := c.Request.Header.Get("Authorization")
    if authorizationHeader == "" {
        unauthorized(c)
        return
    }
    if authorizationHeader[:7] != "Bearer " {
        unauthorized(c)
        return
    }
    tokenString := authorizationHeader[7:]

    // JWT の検証
    token, err := auth.VerifyIDToken(context.Background(), tokenString)
    if err != nil {
        // JWT が無効なら Handler に進まず別処理
        fmt.Printf("error verifying ID token: %v\n", err)
        unauthorized(c)
        return
    }
    log.Printf("Verified ID token: %v\n", token)
    c.Set("uid", token.UID)
}

func unauthorized(c *gin.Context) {
    c.JSON(http.StatusUnauthorized, gin.H{
        "error": http.StatusText(http.StatusUnauthorized),
    })
    c.Abort()
}

func firebaseUninitialized(c *gin.Context) {
    c.JSON(http.StatusUnauthorized, gin.H{
        "error": http.StatusText(http.StatusInternalServerError),
    })
    c.Abort()
}

CORSの対応

これもMiddlewareで挟んでいます。

cors.go
func CORS(c *gin.Context) {

    if c.Request.Method == "OPTIONS" {
        // for preflight
        //origin := c.Request.Header.Get("Origin")

        //r := reg.Copy()
        //if r.MatchString(origin) {
        if true {
            headers := c.Request.Header.Get("Access-Control-Request-Headers")

            c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
            c.Writer.Header().Set("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE")
            c.Writer.Header().Set("Access-Control-Allow-Headers", headers)
            //c.Writer.Header().Set("Access-Control-Allow-Headers", "")

            c.Data(200, "text/plain", []byte{})
            c.Abort()
        } else {
            c.Data(403, "text/plain", []byte{})
            c.Abort()
        }
    } else {
        // for actual response
        c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
        //c.Writer.Header().Set("Access-Control-Expose-Headers", "")
        c.Next()
    }

    return
}

チャット投稿時の実装

  1. リクエスト中のユーザーメッセージをそのままFirebaseRealtimeDBにユーザーとしてPush
  2. TalkAPIにメッセージを投げて、レスポンスを受け取る
  3. レスポンスをFirebaseRealtimeDBにBotとしてPush
  4. リクエストメッセージとTalkAPiのレスポンスメッセージをペアでDBに保存 という流れ

長いのでコードは割愛、そんな対ソなことはしてないです。
https://github.com/kannan-xiao4/chatbot-back/blob/master/handlers/chat.go#L43

FirebaseRealtimeDBへのPush

これはこんな感じで、Authと同じような感じで、SDKのセットアップをしてPushAPI叩くだけですね。

firebasedatabase.go
func FirebaseConnect(path string) *db.Ref {

    ctx := context.Background()
    conf := &firebase.Config{DatabaseURL: os.Getenv("FIREBASE_DATABASE_URL")}

    // Firebase SDK のセットアップ
    opt := option.WithCredentialsFile(os.Getenv("FIREBASE_ADMIN_SDK_FILENAME"))
    app, err := firebase.NewApp(ctx, conf, opt)
    if err != nil {
        panic(fmt.Sprintf("error initializing app: %v", err))

        return nil
    }

    cliant, err := app.Database(ctx)

    if err != nil {
        panic(fmt.Sprintf("Error initializing database client: %v", err))
        return nil
    }

    return cliant.NewRef(path)
}

TalkAPI

使用したのはリクルートが提供しているTalkAPI
概要としてはリンク先に記載されてる通りで、メッセージを投げると何がしらの返答になるようなレスポンスを返してくれる。

ページにあるAPIKeyを発行ボタンから手順に従ってAPIKeyを発行してください。
このAPIKeyをGoのenvに設定します。(develo.env の RECRUIT_SMARTTALK_API_KEY
また、これを使用するエンドポイントは RECRUIT_SMARTTALK_ENDPOINT で設定できるようになっています。
https://api.a3rt.recruit-tech.co.jp/talk/v1/smalltalk から変わらないなら埋め込んでも良い)

実装

talkapi.go
type Response struct {
    Status  int      `json:"status"`
    Message string   `json:"message"`
    Results []Result `json:"results"`
}

type Result struct {
    Perplexity float32 `json:"perplexity"`
    Reply      string  `json:"reply"`
}

//リクルートのやつを使う
//https://a3rt.recruit-tech.co.jp/product/talkAPI/
func Talk(message string) string {
    requesturl := os.Getenv("RECRUIT_SMARTTALK_ENDPOINT")
    apikey := os.Getenv("RECRUIT_SMARTTALK_API_KEY")

    client := &http.Client{}
    values := url.Values{}
    values.Add("apikey", apikey)
    values.Add("query", message)

    req, err := http.NewRequest("POST", requesturl, strings.NewReader(values.Encode()))
    if err != nil {
        return fmt.Sprintf("Fail Make Request: %v\n", err)
    }

    res, err := client.Do(req)
    if err != nil {
        return fmt.Sprintf("Fail Client Request: %v\n", err)
    }

    defer res.Body.Close()

    if res.StatusCode != http.StatusOK {
        return fmt.Sprintf("Fail Http Requst: %v\n", req)
    }

    body, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return fmt.Sprintf("Fail Read Response Body: %v\n", err)
    }

    str := string(body)
    fmt.Println(str)

    // JSONデコード
    var response Response
    if err := json.Unmarshal(body, &response); err != nil {
        return fmt.Sprintf("Fail Json Decode: %v\n", err)
    }

    if response.Status != 0 {
        return fmt.Sprintf("Fail Request Result: %v\n", response.Message)
    }

    return response.Results[0].Reply
}

普通に渡されてきたメッセージを追加して、APIを叩いてるだけですね。
レスポンスがJSONなので、専用のstruct をここで一緒に定義してParseしてやっています。
レスポンスのResultが複数あるかもしれないですが、ここは1個だけと想定して 0番目のIndexを決め打ちしています。
ResultのReplyを返却するサービスとして押し込めてます。

まとめ

実装の説明をはしょりながらですが、チャットボットのサービス全体の紹介でした。
もっと説明したらキリがないことが多いですが、こればっかりはコード見てもらった方が早いかも。

以下それぞれの技術に対する感想です

Vue

  • こうしたいが結構簡単にかけます。ボタン押した時これしたい、画面更新したいとか
  • また、ここのコンポーネント化が楽なので共通部分の取り回しもやりやすいんじゃないかなーと
    • 大規模開発だとまた違うだろうけど
  • Observerパターンがフレームワークとして組み込まれてて、イベントの処理を人間が意識しなくてもいいことが多い
    • 普段使ってるUnityでのUniRxだと自前で書くべきことがフレームワークがやってくれてる。
  • Vuexとかは触ってないので、その辺はわからない

Typescript

  • 普段使っているC#より型制約が強いと思う
    • null とかもちゃんと許可してあげないといけない
    • nullable をそのまま使えない(推論されない)
  • JSだけど安心感がある
    • 型がないと安心してコーディングできない弱い人間
    • あとは型の定義とかを自前で用意するのだけどうしたらいいかあんまりわからなかったので次は試したいかな

Golang

  • 構造体の定義とメソッドの紐付けがクラス思考と違ってとっつきにくかったか
  • 全部のクラス?がInterfaceであるというのは、書き方の制約が強くて良さそう
    • Interfaceだけでやりとりすればそこだけ見ればいいからね!ちゃんと実装されてないとコンパイルエラーになるし
    • 人間を信じてない人には良いと思います。僕は人間を信じてないのでよかった。

Firebase

  • 導入も簡単で、アプリケーションに必要な機能が提供されててよかった。
  • 特にAuthenticationは本当に簡単で、いいぞ!
    • メール、Twitter、Facebookと簡単にマルチログインに対応できる
  • 今回はやってないですがフロントのHostingも1コマンドでできて簡単!
  • RealTimeDBは、今回みたいな即時反映が必要ならサクッと使うには便利
    • ただ、大規模なDBとしてはどうかわからない。
    • Firestore?とかいうのもベータだけどあるのでそっちを使うべきかも
  • 全体として、個人開発レベルだったら考えたくねーなーって部分をサクッと解決してくれるので本当に良かった。
    • 大規模に移ってくならGCPへの移行とか考えればいいと思うので。

参考文献

以下に参考にさせていただいたものを記載させていただいております。
大変参考になりました、ありがとうございました。
他にも参考にさせていただいた記事、サイトはあったと思うのですが覚えていないので記載漏れはご了承ください。。。
発見次第追記していきます。

全体として

Vue

Go

46
40
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
46
40