1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

GAE+golangで乗り換え案内をしてくれるlinebotを作った話

1
Posted at

はじめに

5月頃から内定先の会社でインターンをはじめたら、それまで機械学習とフロントの勉強がメインだったけど、golang使ってるサーバーサイドに配属されたので、日々これ精進しているアウトプットとしてこれからちょっとずつGO+GAE(GCP)の記事を書いていこうかと思います。バチバチの初心者向け記事になると思います。

今回やったこと

今回はGO+GAEでとりあえず動くもの作ろうということで、乗り換え案内のURLを返してくれるline-botを作ることにしました。とりあえず動くものをと思ったので設計などはほぼ皆無です。

環境

  • Macbook Pro
  • OS Mojave

botの概要

今回のbotの仕様はこんな感じ

  • 使う人(俺)が投げるメッセージは「〇〇(駅名)から〇〇(駅名)」のみ、他は受け付けない(この辺自然言語処理とかで色々対応させたりしたい(したい))
  • お返事はその駅からその駅までの乗り換え経路のyahoo乗り換えのページのURLで出発時間は使う人(俺)がメッセージを送った時

早速作っていこう

 line-botのはじめの設定とかは今回の主題ではないので、割愛以下のページを参考にするといいと思います。
LINE BOTの作り方を世界一わかりやすく解説(1)【アカウント準備編】

まずはGAEのプロジェクト作成

 GCP上で自分のプロジェクトを作ります。これはGCPのページから簡単に作れます

コードを書いていく

 GAEはデプロイしたサービスにmainパッケージが存在しないと動かないので、まずはmainパッケージのmain.goを書いていきます。中身はこんな感じ。

main.go

package main

import (
	"commuting-time-line-bot/handler"
	"fmt"
	"github.com/line/line-bot-sdk-go/linebot"
	"log"
	"net/http"
	"os"
	"strings"
)

func main() {
	log.Print("[Start] start main")
	bot, err := linebot.New(os.Getenv("CHANNEL_SECRET"), os.Getenv("CHANNEL_TOKEN"))
	if err != nil {
		log.Fatal(err)
	}

	port := os.Getenv("PORT")
	log.Printf("port : %s", port)
	if port == "" {
		port = "8080"
		log.Printf("Defaulting to port %s", port)
	}

	// 実際にRequestを受け取った時に処理を行うHandle関数を定義し、handlerに登録
	http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
		log.Print("[Start] start handle events")
		defer log.Print("[End] end handle events")

		events, err := bot.ParseRequest(r)
		if err != nil {
			if err == linebot.ErrInvalidSignature {
				w.WriteHeader(400)
				log.Print(err)
			} else {
				w.WriteHeader(500)
				log.Print(err)
			}
			return
		}

		for _, event := range events {
			if event.Type != linebot.EventTypeMessage {
				return
			}

			switch message := event.Message.(type) {
			case *linebot.TextMessage:
				// 飛んできたメッセージが正しい形式化を確認
				if err := strings.Contains(message.Text, "から"); err != true {
					replyMessage := "経路のお願いの仕方が違うぞ!\n (駅名)から(駅名)でたのむ!!"
					if _, err := bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(replyMessage)).Do(); err != nil {
						log.Printf("contains : %s", err)
					}
				}
				// 出発駅と到着駅を分割
				slice := strings.Split(message.Text, "から")
				stations := map[string]string{}
				key := [...]string{"startStation", "targetStation"}
				log.Printf("message from line : %v", slice)
				for i, station := range slice {
					stations[key[i]] = station
				}

				// 経路情報のページへアクセスするurl
				routeUrl, err := handler.GetUrl(stations[key[0]], stations[key[1]])
				if err != nil {
					log.Printf("failed create url for route page : %s", err.Error())
				}

				// replyメッセージの作成
				rowReplyMessage := "こんな経路が見つかったぞ!\n" + routeUrl
				replyMessage := linebot.NewTextMessage(rowReplyMessage)
				log.Print(replyMessage)

				// 返信の実行
				if _, err = bot.ReplyMessage(event.ReplyToken, replyMessage).Do(); err != nil {
					log.Print(err)
				}
			}
		}
	})

	// /callback にエンドポイントの定義
	// HTTPサーバの起動
	log.Printf("Listening on port %s", port)
	if err := http.ListenAndServe(fmt.Sprintf(":%s", port), nil); err != nil {
		log.Printf("[Fail] failed exec server :%s", err)
	}
}

 本当に大したことはやっていないのですが、上から説明していきます。
logパッケージの出力はGCPのstackdriverが拾ってくれるので、デバッグなどに使えます。

bot, err := linebot.New(os.Getenv("CHANNEL_SECRET"), os.Getenv("CHANNEL_TOKEN"))

これでlineとやりとりする為のクライアントを作成します。CHANNEL_SECRETCHANNEL_TOKENはline-botの設定ページから取ってこれます。これは別のファイルに記述しておいて、読み込むようにしています。ベタ書きはよくないので。

port := os.Getenv("PORT")
    log.Printf("port : %s", port)
    if port == "" {
        port = "8080"
        log.Printf("Defaulting to port %s", port)
    }

次にGAEで使うポート番号を決めるのですが、ポート番号はGAE側が勝手に決めてくれるので、環境変数から取ってきます。

// 実際にRequestを受け取った時に処理を行うHandle関数を定義し、handlerに登録
    http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
        log.Print("[Start] start handle events")
        defer log.Print("[End] end handle events")

        events, err := bot.ParseRequest(r)
        if err != nil {
            if err == linebot.ErrInvalidSignature {
                w.WriteHeader(400)
                log.Print(err)
            } else {
                w.WriteHeader(500)
                log.Print(err)
            }
            return
        }

      

ここで/callbackのハンドラーを登録します。このとき少しハマったのですが、line-botSDKがhttp.handlefuncと同じような、リクエストをパースしたものを引数に持った関数をハンドラーとして登録する関数を用意してくれているのですが(名前を忘れてしまいました)、これを使うと僕の環境ではリクエストの認証に失敗してしまいうまくいきませんでした。なので、http.handleFuncを使っています。そのあとのbot.ParseRequest(r)を使うと、認証もうまくいき、無事動きました。

for _, event := range events {
            if event.Type != linebot.EventTypeMessage {
                return
            }

            switch message := event.Message.(type) {
            case *linebot.TextMessage:
                // 飛んできたメッセージが正しい形式化を確認
                if err := strings.Contains(message.Text, "から"); err != true {
                    replyMessage := "経路のお願いの仕方が違うぞ!\n (駅名)から(駅名)でたのむ!!"
                    if _, err := bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(replyMessage)).Do(); err != nil {
                        log.Printf("contains : %s", err)
                    }
                }
                // 出発駅と到着駅を分割
                slice := strings.Split(message.Text, "から")
                stations := map[string]string{}
                key := [...]string{"startStation", "targetStation"}
                log.Printf("message from line : %v", slice)
                for i, station := range slice {
                    stations[key[i]] = station
                }

                // 経路情報のページへアクセスするurl
                routeUrl, err := handler.GetUrl(stations[key[0]], stations[key[1]])
                if err != nil {
                    log.Printf("failed create url for route page : %s", err.Error())
                }

                // replyメッセージの作成
                rowReplyMessage := "こんな経路が見つかったぞ!\n" + routeUrl
                replyMessage := linebot.NewTextMessage(rowReplyMessage)
                log.Print(replyMessage)

                // 返信の実行
                if _, err = bot.ReplyMessage(event.ReplyToken, replyMessage).Do(); err != nil {
                    log.Print(err)
                }
            }
        }
    })

    // /callback にエンドポイントの定義
    // HTTPサーバの起動
    log.Printf("Listening on port %s", port)
    if err := http.ListenAndServe(fmt.Sprintf(":%s", port), nil); err != nil {
        log.Printf("[Fail] failed exec server :%s", err)
    }
}

 lineからはメッセージだけでなく、スタンプや画像などいろんな形式のメッセージが飛んでくるので、テキストメッセージが飛んできていることを確認して、処理に入ります。まず、〇〇から〇〇というメッセージしか受けないので、strings.Contains(message.Text, "から")で「から」という文字を含んでいるかを確認し、分割します。そして以下のコードでURLを作ります。

ackage handler

import (
	"log"
	"net/url"
	"strconv"
	"time"
	"unicode/utf8"
)

func GetUrl(start string, target string) (string, error) {
	routeUrl, err := makeUrl(start, target)
	if err != nil {
		return "", nil
	}
	return routeUrl, nil
}

func makeUrl(start string, target string) (string, error) {
	baseUrl, err := url.Parse("https://transit.yahoo.co.jp/search/result")
	if err != nil {
		return "", err
	}
	t := time.Now()
	nowUtc := t.UTC()
	jst := time.FixedZone("Asia/Tokyo", 9*60*60)
	nowJst := nowUtc.In(jst)
	// リクエストbodyの作成
	values := url.Values{}
	values.Add("flatlon", "")
	values.Add("fromgid", "")
	values.Add("from", start)
	values.Add("togid", "")
	values.Add("viacode", "")
	values.Add("via", "")
	values.Add("to", target)
	values.Add("y", strconv.Itoa(t.Year()))
	month := strconv.Itoa(int(t.Month()))
	if utf8.RuneCountInString(month) == 1 {
		month = "0"+month
	}
	values.Add("m", month)
	log.Printf("m:%s", values.Get("m"))
	values.Add("d", strconv.Itoa(nowJst.Day()))
	log.Printf("d:%s", values.Get("d"))
	values.Add("hh", strconv.Itoa(nowJst.Hour()))
	log.Printf("hh:%s", values.Get("hh"))
	// 分数は別々に扱うので、分割する
	min := strconv.Itoa(nowJst.Minute())
	var m1 string
	var m2 string
	if utf8.RuneCountInString(min) == 2 {
		m2 = min[1:]
		m1 = min[:1]
	} else {
		m2 = min
		m1 = "0"
	}
	values.Add("m1", m1)
	log.Printf("m1:%s", values.Get("m1"))
	values.Add("m2", m2)
	log.Printf("m2:%s", values.Get("m2"))
	values.Add("type", "1")
	values.Add("ticket", "ic")
	values.Add("expkind", "1")
	values.Add("ws", "3")
	values.Add("s", "0")
	values.Add("al", "1")
	values.Add("shin", "1")
	values.Add("ex", "1")
	values.Add("hb", "1")
	values.Add("lb", "1")
	values.Add("sr", "1")
	values.Add("kw", target)

	baseUrl.RawQuery = values.Encode()

	routeUrl := baseUrl.String()
	log.Printf("made url : %s", routeUrl)

	return routeUrl, nil
}

ここではティップス的なものを紹介していきます。
UTCからJSTへの変換

t := time.Now()
    nowUtc := t.UTC()
    jst := time.FixedZone("Asia/Tokyo", 9*60*60)
    nowJst := nowUtc.In(jst)

time.Monthは文字列で帰ってくるので、数値に変換

month := strconv.Itoa(int(t.Month()))

文字列の長さを数えるのにlen()を使うとbyte列の長さが帰ってくるので、文字列の長さを測るのにはruneを使う

utf8.RuneCountInString(month)

こんな感じでしょうか。
最後にさっき取ったポート番号のポートを開けて、完了です。
コードはこんな感じです。

設定ファイルapp.yamlの作成

 GAEにサービスをデプロイする際の設定を書いていくのがapp.yamlです。
今回はとりあえず動くものということで、最小設定でapp.yamlを書いています。

app.yaml
runtime: go112

includes:
  - secret.yaml

handlers:
- url: /*.
  script: auto
  secure: always
  redirect_http_response_code: 301

一つずつ意味を紹介していくと、

runtime: go112

これでGAEのインスタンスのgoのバージョンは1.12になります。9月で1.9までのバージョンは使えなくなるので、1.11以降のバージョンを使うようにしましょう。1.12からはappengineパッケージも使えないので、appengineパッケージを使いたい場合は1.11を指定するようにしましょう。この以降期間が非常に短くて結構大変だなって思ってました。

includes:
  - secret.yaml

includeを指定することで、表に公開したくない環境変数などを書くことができます。今回の場合はsecret.yamlの中にアクセストークンなどを記述しておいて、.gitignoreに記述しておくことで、公開したくない環境変数を持たせることができます。

 handlers:
- url: /*.
  script: auto
  secure: always
  redirect_http_response_code: 301

script:autoはそのURLのハンドラを設定するのですが、autoしか値が許されていないみたいです。(これはもうちょっと深く調べたいです)secureはリクエストがHTTPでもいいのかHTTPSじゃないと許さないのかといったセキュリティの設定をできます。そしてHTTPSしか許さない場合、HTTPでのリクエストはHTTPSへリダイレクトされます。それがredirect_http_response_code: 301で指定しています。line-botはHTTPSでの通信しか許していないので、こうしています。

デプロイする

 最後にデプロイして完成です。gcloud initでプロジェクトを指定して、gcloud app deployでデプロイして見事line-botが動くようになりました。

最後に

すごく長くなってしまったのですが、アウトプットとして今回使ったGAEとgolangの話をつらつらと書いて見ました。
あまりGAEとline-botの組み合わせ記事は見なかったので少しでも役に立ったら嬉しいです。
なんでわざわざline-botなのかというと、フロントがわりに使いやすくて、サーバーの勉強をしていて、ものとして作れるからいいなって思って選びました。
あと、個人利用だから大丈夫だと思うけど、勝手にyahoo乗り換えのURL作ってるのっていいのかな。GETリクエストにしてるし大丈夫だと思うけど。

あと、基本的にはこの記事を参考にしました。ありがとうございます。
https://qiita.com/moja0316/items/a726ef746476fe470a66

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?