Golang + Gin + Heroku でLineBotを作ってみた

はじめに

コロナ渦大変になってきてますね。
最近かなり暖かくもなってきたので、誰にも迷惑をかけず感染リスクも侵さない娯楽としてバイクにめちゃくちゃ乗りたくなってきたのですが、
残念ながらまだかつての愛車も修理が終わっておらず、新たに購入する資金力も無いため、家族共々自宅に籠もっております。
ふと思い立ってGoを勉強してみたいと思い、勉強し始めました。特に理由はありません。
基本的な文法を学ぶ必要がありますが、モチベーション維持のためにはやはり何か作りながらのほうがいい。
しかし特に作りたいものも無いしどうしたものか。。

現在、妻へ連絡する色々を自動化するためにLineBotを使っているのですが、
バックエンドは昔PHPで実装したものをそのまま継続的に使用しており(学生時代PHPしか書けなかったので。。。)
最近個人的にPHPを使うことも少なくなってきたので、Goで書き直そうとやってみました。
取り合えずSDKとGoのWebフレームワークであるGinを使ってオウム返答するところまでを動かします。

Goの環境をDockerで構築

Flask-AskでAlexaのカスタムスキルを作成 - 雑食へいちゃんの思い出達

この記事でやったことと同じです。
まずはこんなalpineベースのgolangイメージをベースにDockerfileを作成します。

FROM golang:1.14.2-alpine3.11

WORKDIR /go
RUN apk update && \
    apk add vim git

以下コマンドでコンテナの中に入ります。

$ docker build . -t go-env
$ docker run -it -v $PWD:/go -p 8080:8080 go-env /bin/ash

コンテナに入ったらGinをインストールします。

$ go get github.com/gin-gonic/gin

まずは、サンプルのmain.goを書いて動作を確認します。

package main

import (
  "net/http"
  "github.com/gin-gonic/gin"
)
func main() {
  router := gin.Default()
  router.GET("/hello", func(c *gin.Context) {
    c.String(http.StatusOK, "Hello World!!")
  })
  router.Run(":8080")
}
$ go run main.go

ブラウザからlocalhost:8080/helloにアクセスして「Hello World!!」と表示されれば成功です。

次はこれをHerokuにデプロイしていきます。
Dockerのコンテナに作った環境をherokuにデプロイしていくので、
Dockerfileを以下の通り書き換えます。

FROM golang:1.14.2-alpine3.11

WORKDIR /go
ADD . /go
RUN apk update && \
    apk add git vim
CMD go run main.go

また、main.goで立ち上げるポートも8080ではなく、環境変数を使用するように変更します。

package main

import (
  "os"
  "net/http"
  "github.com/gin-gonic/gin"
)
func main() {
  bot, err := linebot.New(
    os.Getenv("LINEBOT_CHANNEL_SECRET"),
    os.Getenv("LINEBOT_CHANNEL_TOKEN"),
  )
  if err != nil {
    log.Fatal(err)
  }
  router := gin.Default()

  router.GET("/hello", func(c *gin.Context) {
    c.String(http.StatusOK, "Hello World!!")
  })
  router.Run(":" + os.Getenv("PORT")) // ここを環境変数を参照するように変更
}

ローカル環境のDockerコンテナ内でGinサーバを立ち上げる際は、以下コマンドで環境変数を追加する必要があります。

$ export PORT=8080

続いてHerokuにデプロイしていきます。
デプロイの方法は先程同様↓の記事を参照ください。

Flask-AskでAlexaのカスタムスキルを作成 - 雑食へいちゃんの思い出達

HerokuのURLにアクセスし、localhostと同様の表示が出れば成功です。

オウム返しするLineBotを作成する

Line Messaging APIにはGolang用のSDKが提供されているので、それを使用します。

github.com

$ go get github.com/line/line-bot-sdk-go/linebot

main.goを以下のように修正します。
基本すべて公式に掲載されているサンプルですが、Ginに合わせて一部修正しています。

line-bot-sdk-go/server.go at master · line/line-bot-sdk-go · GitHub

package main

import (
  "os"
  "fmt"
  "log"
  "net/http"
  "github.com/gin-gonic/gin"
  "github.com/line/line-bot-sdk-go/linebot"
)
func main() {
  bot, err := linebot.New(
    os.Getenv("LINEBOT_CHANNEL_SECRET"),
    os.Getenv("LINEBOT_CHANNEL_TOKEN"),
  )
  if err != nil {
    log.Fatal(err)
  }
  router := gin.Default()

  router.GET("/hello", func(c *gin.Context) {
    c.String(http.StatusOK, "Hello World!!")
  })

  router.POST("/callback", func(c *gin.Context) {
    events, err := bot.ParseRequest(c.Request)
    if err != nil {
      if err == linebot.ErrInvalidSignature {
        c.Writer.WriteHeader(400)
      } else {
        c.Writer.WriteHeader(500)
      }
      return
    }
    for _, event := range events {
      if event.Type == linebot.EventTypeMessage {
        switch message := event.Message.(type) {
          case *linebot.TextMessage:
            if _, err = bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(message.Text)).Do(); err != nil {
              log.Print(err)
            }
          case *linebot.StickerMessage:
            replyMessage := fmt.Sprintf(
              "sticker id is %s, stickerResourceType is %s", message.StickerID, message.StickerResourceType)
            if _, err = bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(replyMessage)).Do(); err != nil {
              log.Print(err)
            }
        }
      }
    }
  })
  router.Run(":" + os.Getenv("PORT"))
}

これをHerokuにデプロイし、LINEBOT_CHANNEL_SECRETLINEBOT_CHANNEL_TOKEN環境変数として設定し、Line Developersサイトでcallback URLにHerokuのサイトURLを設定すれば完了です。

終わり

とりあえず動くところまではできたので、文法についても勉強しながら、現行で動いている機能を移植していきたいと思います。

Google Photos API でアルバムの画像取得

はじめに

我が家ではこどもたちの写真を撮影月ごとにGoogle Photosの共有アルバムにまとめて両親に共有しています。
これらの写真をAPIで取得して、フォトフレームなど使えるといいなーと思っていましたが、以前はGoogle Photos のAPIは整備されておらず、Picasa APIのみでした。
Picasa自体も既にサービス終了しており、APIもあまりメンテナンスされている雰囲気でもなくいつか終わりそうな気がしたので、こっちを使うことは避けてました。)
気が付くと2018年にGoogle Photos APIがリリースされていたので、これを使っての写真取得を試してみたいと思います。
最終的には、年月を指定すると、その月の共有アルバムに含められている写真を取得する、というプログラムにしたいと思います。

公式リファレンスはこちらです

developers.google.com

Credentialを取得

まず初めに、APIを使用するのに必要なCredentialを取得します。

Google Photos APを使うにはOAuthでのユーザー認証が必要になります。
OAuthの認証にはPythonのクライアントライブラリが提供されているのでそちらを使います。

github.com

上記の公式にも下記の記述がある通りGoogle Photos APIでは、Service Account方式は使えないようです。

Note that the Library API does not support service accounts; to use this API, users must be signed in to a valid Google Account.

なので、OAuth 2.0 for Web Server Applicationsか、OAuth 2.0 for Installed Applicationsのどちらかをつかうのですが、今回はWeb Serverを立てるわけではないので、Installed Applicationsタイプで実装しました。

github.com

client_secretの取得

まずはOAuthの認証に必要なclient secretを取得します。 Google API Consoleにアクセス。

console.developers.google.com

f:id:ti_taka:20200403225259p:plain 左上の「APIとサービス」より「認証情報」を選択

f:id:ti_taka:20200403225321p:plain 「+認証情報を作成」をクリック

f:id:ti_taka:20200403225337p:plain

f:id:ti_taka:20200403225355p:plain

f:id:ti_taka:20200403225410p:plain

これでダウンロードよりclient_secret.jsonをダウンロードします。

credentialの取得

ダウンロードしたclient_secret.jsonを読み取り、credentialを取得します。

# google_photos.py
import google.oauth2.credentials
import google_auth_oauthlib.flow

SCOPES = ['https://www.googleapis.com/auth/photoslibrary']
API_SERVICE_NAME = 'photoslibrary'
API_VERSION = 'v1'
CLIENT_SECRET_FILE = 'client_secret.json'

flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE,scopes=SCOPES)        
credentials = flow.run_console()

上記プログラムを実行すると、

$ python google_photos.py
Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id={$cliens_id}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fphotoslibrary&state={$state}&prompt=consent&access_type=offline
Enter the authorization code:

のように、URLが出力されるので、上記URLをブラウザに入力すると以下の画面になるので、Googleアカウントでログインしてください。
ログイン後は、以下のように認可コードが払い出されるので、コピーしてターミナルに貼り付けます。
f:id:ti_taka:20200403225429p:plain

上記でCredentialは取得出来るのですが、その中にはtokenやRefresh tokenも含まれており、その後はRefresh tokenを使用してCredentialを取得することが出来るようになるため、毎回ログインする必要がなくなります。
しかし、現在のgoogle_auth_oauthlibまだcredentialの保存をサポートしていないので、credentialをそのままjsonのファイルで保存して、今後はRefresh Tokenを使用してcredentialを取得するようにします。

github.com

google-auth-oauthlib does not currently have support for credentials storage. It may be added in the future. See oauth2client deprecation for more details.

credentialの中にはdatetimeオブジェクトが入っているので、↓のコードを頂戴してcredentialをシリアライズして保存しておきます。

qiita.com

def support_datetime_default(o):
  if isinstance(o, datetime):
    return o.isoformat()
  raise TypeError(repr(o) + " is not JSON serializable")

with open(CREDENTIAL_FILE, mode='w') as f_credential_w:
    f_credential_w.write(json.dumps(vars(credentials), default=support_datetime_default, sort_keys=True))

こうすることで、一度credentialを取得したあとは、ログインをし直す必要なく、Refresh Tokenでcredentialの取得が出来るようになります。

if os.path.exists(CREDENTIAL_FILE):
  with open(CREDENTIAL_FILE) as f_credential_r:
    credentials_json = json.loads(f_credential_r.read())
    credentials = google.oauth2.credentials.Credentials(
      credentials_json['token'],
      refresh_token=credentials_json['_refresh_token'],
      token_uri=credentials_json['_token_uri'],
      client_id=credentials_json['_client_id'],
      client_secret=credentials_json['_client_secret']
    )

credentialを取得後は以下のようにServiceを取得します。

service = build(
    API_SERVICE_NAME,
    API_VERSION,
    credentials=credentials
)

今後このserviceで各種APIを叩いていきます。
これまでのコードの全体像は以下のようになります。

# google_photos.py
import google.oauth2.credentials
import google_auth_oauthlib.flow
import json,os
from datetime import datetime,date
from googleapiclient.discovery import build

SCOPES = ['https://www.googleapis.com/auth/photoslibrary']
API_SERVICE_NAME = 'photoslibrary'
API_VERSION = 'v1'
CLIENT_SECRET_FILE = 'client_secret.json'
CREDENTIAL_FILE = 'credential.json'

def support_datetime_default(o):
  if isinstance(o, datetime):
    return o.isoformat()
  raise TypeError(repr(o) + " is not JSON serializable")

def getCredentials():
  if os.path.exists(CREDENTIAL_FILE):
    with open(CREDENTIAL_FILE) as f_credential_r:
      credentials_json = json.loads(f_credential_r.read())
      credentials = google.oauth2.credentials.Credentials(
        credentials_json['token'],
        refresh_token=credentials_json['_refresh_token'],
        token_uri=credentials_json['_token_uri'],
        client_id=credentials_json['_client_id'],
        client_secret=credentials_json['_client_secret']
      )
  else:
    flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE,scopes=SCOPES)
    
    credentials = flow.run_console()
  with open(CREDENTIAL_FILE, mode='w') as f_credential_w:
    f_credential_w.write(json.dumps(vars(credentials), default=support_datetime_default, sort_keys=True))
    
  return credentials

def main():
    credentials = getCredentials()
    service = build(
        API_SERVICE_NAME,
        API_VERSION,
        credentials=credentials
    )

if __name__ == "__main__":
    main()

各アルバム内の写真を取得

共有アルバムIDの取得

アルバムIDは以下のように取得できます。

sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()

我が家では前述の通り、子供たちの写真を共有アルバムにて両親に共有しています。
その際に子供の写真については、「yyyymm01〜」の命名規則でアルバム名をつけているので、
該当するアルバム名を取得します。

nextPageToken = ''
albums = {}
while True:
  sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()
  for sharedAlbum in sharedAlbums['sharedAlbums']:
    if 'title' in sharedAlbum:
      m = re.search(r'^\d\d\d\d\d\d01', sharedAlbum['title'])
      if m:
        month = m.group()
        if not (month in albums):
          albums[m.group()] = []
        albums[m.group()].append(sharedAlbum['id'])
  if 'nextPageToken' in sharedAlbums:
    nextPageToken = sharedAlbums['nextPageToken']
  else:
    break

その際に子供の写真については、「yyyymm01〜」の命名規則でアルバム名をつけているので、

この運用を始めてからは基本的に一月に1共有アルバムになったのですが、
この運用ルールで実施できていなかった頃に、一月の間に複数アルバムを作ってしまっているので、
以下のように複数取得出来るようにしています。

if not (month in albums):
  albums[m.group()] = []
albums[m.group()].append(sharedAlbum['id'])

また最終的には、年月を指定すると、その月の写真をとってくる、というプログラムにしたいので、毎回共有アルバムのIDを取得するのは面倒なので、
予め子供の写真を登録している共有アルバムのリストを取得してファイルに保存しておき、
写真を取得するときは、指定した年月の共有アルバムIDリストのファイルから読み込む、という動きにしました。

MONTH_ALBUMS_FILE = 'month_albums.json'

def updateAlbumList(service):
  nextPageToken = ''
  albums = {}
  while True:
    sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()
    for sharedAlbum in sharedAlbums['sharedAlbums']:
      if 'title' in sharedAlbum:
        m = re.search(r'^\d\d\d\d\d\d01', sharedAlbum['title'])
        if m:
          month = m.group()
          if not (month in albums):
            albums[m.group()] = []
          monthAlbums[m.group()].append(sharedAlbum['id'])
    if 'nextPageToken' in sharedAlbums:
      nextPageToken = sharedAlbums['nextPageToken']
    else:
      break
  with open(MONTH_ALBUMS_FILE, mode='w') as f_month_albums_w:
    f_month_albums_w.write(json.dumps(albums, default=support_datetime_default, sort_keys=True))

def getAlbumIds(months):
  albumIds = []
  with open(MONTH_ALBUMS_FILE) as f_month_album_r:
    monthAlbumJson = json.loads(f_month_album_r.read())
    
  for month in months:
    monthAlbumIds = monthAlbumJson[month]
    for monthAlbumId in monthAlbumIds:
      albumIds.append(monthAlbumId)

  return albumIds

共有アルバムのIDを取得してMONTH_ALBUMS_FILEというファイルに保存しておくupdateAlbumListという関数と、
年月をyyyymm01形式で渡すと共有アルバムのIDを返却するgetAlbumIdsという関数とで分けました。

共有アルバム内の写真を取得

いよいよ写真の取得ですが、写真の取得は

developers.google.com

mediaItems.searchというIFで取得ができます。 リクエストbodyのJSONの中にアルバムIDを含めてリクエストすると、そのアルバム内の写真を取得できます。

以下ように取得できます。

body = {
  'albumId' : albumId,
  'pageSize' : 50,
}
mediaItems = service.mediaItems().search(body=body).execute()

アルバムIDの配列を渡されたら、そのアルバムID内の写真を取得するような関数にしました。

def getPhotos(service, albumIds):
  photos = []
  for albumId in albumIds:
    nextPageTokenMediaItems = ''
    while True:
      photo = {}
      body = {
        'albumId' : albumId,
        'pageSize' : 50,
        'pageToken' : nextPageTokenMediaItems
      }
      mediaItems = service.mediaItems().search(body=body).execute()
      # print(mediaItems)
      for mediaItem in mediaItems['mediaItems']:
        photo['id'],photo['url'] = mediaItem['id'],mediaItem['baseUrl']
        photos.append(photo)
      if 'nextPageToken' in mediaItems:
        nextPageTokenMediaItems = mediaItems['nextPageToken']
      else:
        break
  print(photos)

最終的な全体の実装は以下の通りです。

from googleapiclient.discovery import build
import google.oauth2.credentials
import google_auth_oauthlib.flow
import json,os,re
from datetime import datetime,date

SCOPES = ['https://www.googleapis.com/auth/photoslibrary']
API_SERVICE_NAME = 'photoslibrary'
API_VERSION = 'v1'
CLIENT_SECRET_FILE = 'client_secret.json'
REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
CREDENTIAL_FILE = 'credential.json'
MONTH_ALBUMS_FILE = 'month_albums.json'

def support_datetime_default(o):
  if isinstance(o, datetime):
    return o.isoformat()
  raise TypeError(repr(o) + " is not JSON serializable")

def getCredentials():
  if os.path.exists(CREDENTIAL_FILE):
    with open(CREDENTIAL_FILE) as f_credential_r:
      credentials_json = json.loads(f_credential_r.read())
      credentials = google.oauth2.credentials.Credentials(
        credentials_json['token'],
        refresh_token=credentials_json['_refresh_token'],
        token_uri=credentials_json['_token_uri'],
        client_id=credentials_json['_client_id'],
        client_secret=credentials_json['_client_secret']
      )
  else:
    flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE,scopes=SCOPES)
    
    credentials = flow.run_console()
  with open(CREDENTIAL_FILE, mode='w') as f_credential_w:
    f_credential_w.write(json.dumps(vars(credentials), default=support_datetime_default, sort_keys=True))
    
  return credentials

def updateAlbumList(service):
  nextPageToken = ''
  albums = {}
  while True:
    sharedAlbums = service.sharedAlbums().list(pageSize=20,pageToken=nextPageToken).execute()
    for sharedAlbum in sharedAlbums['sharedAlbums']:
      if 'title' in sharedAlbum:
        m = re.search(r'^\d\d\d\d\d\d01', sharedAlbum['title'])
        if m:
          month = m.group()
          if not (month in albums):
            albums[m.group()] = []
          albums[m.group()].append(sharedAlbum['id'])
    if 'nextPageToken' in sharedAlbums:
      nextPageToken = sharedAlbums['nextPageToken']
    else:
      break
  with open(MONTH_ALBUMS_FILE, mode='w') as f_month_albums_w:
    f_month_albums_w.write(json.dumps(albums, default=support_datetime_default, sort_keys=True))

def getPhotos(service, albumIds):
  photos = []
  for albumId in albumIds:
    nextPageTokenMediaItems = ''
    while True:
      photo = {}
      body = {
        'albumId' : albumId,
        'pageSize' : 50,
        'pageToken' : nextPageTokenMediaItems
      }
      mediaItems = service.mediaItems().search(body=body).execute()
      # print(mediaItems)
      for mediaItem in mediaItems['mediaItems']:
        photo['id'],photo['url'] = mediaItem['id'],mediaItem['baseUrl']
        photos.append(photo)
      if 'nextPageToken' in mediaItems:
        nextPageTokenMediaItems = mediaItems['nextPageToken']
      else:
        break
  print(photos)

def getAlbumIds(months):
  albumIds = []
  with open(MONTH_ALBUMS_FILE) as f_month_album_r:
    monthAlbumJson = json.loads(f_month_album_r.read())
    
  for month in months:
    monthAlbumIds = monthAlbumJson[month]
    for monthAlbumId in monthAlbumIds:
      albumIds.append(monthAlbumId)

  return albumIds

def main():
  credentials = getCredentials()
  service = build(
    API_SERVICE_NAME,
    API_VERSION,
    credentials=credentials
  )
  updateAlbumList(service)  # ここは必要に応じて実施するように後々処理を分けるつもり
  months = ['20190101','20190201']
  albumIds = getAlbumIds(months)
  getPhotos(service, albumIds)
    
if __name__ == "__main__":
  main()

まとめ

Pythonを使って、Google Photos APIを使ってみました。
共有アルバムから写真は取得出来るようになったので、今度はAlexaと連携して、声で指示した写真を取得出来るようにしたいと思います。

スティード400を復活 〜サスペンション&オイルフィルター交換編〜

昨年の夏に実家に眠っているスティード400 のキャブ洗浄を行いました。
kittagon.hateblo.jp

今年のGWにはオイルエレメントの交換と、サスペンションの交換をしたのでまとめます。
手探りでやったのであまり写真に残せませんでしたが。。

サスペンションの交換

僕のスティード400はリジットバーをつけているので、今回はそれを交換したいと思います。
リジットバーを付けていると車体が下がってカッコいいんですが、なにせショック吸収が皆無なのでお尻が結構辛いです。
高校生の頃はそれで良かったのですが、今の歳になると絶対にキツい、、、
なので純正とまでは行きませんが、最低限のショック吸収があるサスペンションに交換したいと思います。

今回はヤフオクで購入した、デイトナのサスペンションを装着します。
f:id:ti_taka:20190804005917j:plain

前回と同様に、シートとサイドカバーを外しておきます。
f:id:ti_taka:20190804010011j:plain
f:id:ti_taka:20190804010023j:plain
f:id:ti_taka:20190804010033j:plain

とりあえずリジットバーが固定されている、シート下側のボルトを緩めてしまいます。(↓の赤丸箇所) f:id:ti_taka:20190804010110j:plain

ここのボルトを引き抜くためには、後輪が動ける状態でないと外せないので、この辺りをジャッキアップします。
f:id:ti_taka:20190804010137j:plain

他のブログ等見てみると、ボルトにテンションを掛けておいて、スルッと外れる高さまでうまいこと丁度にジャッキアップすると書いてありますが、僕は微調整も面倒だったので、がっつりジャッキアップした後、後輪を手で持ち上げながらボルトを引き抜きました。
どうせ後から付けるサスペンションは高さが違うのでここで微調整してもつけるときはまた高さ調整しなくてはなりませんからね。

左右それぞれこれぐらいの高さまで上げてしまいました。 f:id:ti_taka:20190804010211j:plain

リジットバーが付いているので、リアのタイヤも浮いた状態になります。写真を撮るのを忘れましたが)

ボルトを外しておいて、スイングアームの↓の赤丸の辺りを持ち上げると、ボルトにかかるテンションが緩むので、持ち上げた状態でボルトを引き抜くと簡単に取れます。
f:id:ti_taka:20190804010226j:plain
f:id:ti_taka:20190804010238j:plain
f:id:ti_taka:20190804010637j:plain

こうしてシート側のボルトが無事外せたので、今度はスイングアーム側に固定されているボルトを緩めて外せばOKです。
f:id:ti_taka:20190804010821j:plain
f:id:ti_taka:20190804010832j:plain
f:id:ti_taka:20190804010844j:plain

若干ステーが邪魔ですが、シートの下からボルトを回して外せます。
f:id:ti_taka:20190804010948j:plain
f:id:ti_taka:20190804011002j:plain
f:id:ti_taka:20190804011016j:plain

次はいよいよサスペンションを取り付けていきます。
外すときはタイヤの横から手を突っ込んで外せたので意識する必要はなかったのですが、取り付けるときはシート下のステーを外す必要があります。
最初からステーを外してしまうほうが、リジットバーを外す際も楽です。僕は外すのが面倒臭かかったので横着してステーを外さないままリジットバーを外したのですが、結局交換のサスを取り付ける際に外す必要があったので、最初から外しておけばよかったと思いました。
↓これです。 f:id:ti_taka:20190804011111j:plain

これが少し面倒臭くて、シート下からは取れず、左右のサイドカバー下にあるボルトを外さないと取れません。
↓左側 f:id:ti_taka:20190804011154j:plain
f:id:ti_taka:20190804011203j:plain

↓右側。ステーの表にイグニッションコイルがついているので一旦外す必要があります。
f:id:ti_taka:20190804011230j:plain

イグニッションコイルを外した下に見えるボルトを外します。
f:id:ti_taka:20190804011302j:plain

こんな感じでシート下でサスペンションを固定しているところが見えるようになるので、あとは先程リジットバーを取り外したのと逆の手順でサスペンションを取り付ければOKです。
f:id:ti_taka:20190804011325j:plain

取り付けるときは、スイングアーム側を固定するのは簡単ですが、シート下を固定するためにはジャッキの高さを微調整してボルトの穴がちょうど良い位置にしないといけません。
これがかなり面倒臭いです。。

無事取り付けることができました。
リジットバーをつけていたときと比べるとだいぶ車高が高くなりました。
これなら乗りやすそうですね。
f:id:ti_taka:20190804011353j:plain

オイル、オイルフィルターの交換

去年せっかくキャブを洗浄したので、オイルも交換しようと思いますが、オイルフィルターも前回いつ交換したんだっけ?という感じだったので、今回の機に交換しようと思います。

オイルフィルターについてはあまり知識が無いのですが、とりあえず型をみてスティードに適合しそうなものの中から以下を選定しました。

また、オイルフィルターを外すためにはオイルフィルターレンチが必要になります。
バイク屋さん曰く、オイルフィルターレンチのサイズは、スズキ車とホンダ・カワサキヤマハで違うそうです。
ホンダ車用のこちらを購入し使用しました。

まずは、オイル交換です。こちらは簡単ですね。
エンジンの下にあるボルトを外してオイルを出します。
f:id:ti_taka:20190804011421j:plain

市販の廃油吸収剤を買ってきて下に敷いておきます。
f:id:ti_taka:20190804011452j:plain

敷いた状態で↑のボルトを外すとオイルが出てきます。
f:id:ti_taka:20190804011531j:plain

次にオイルフィルターを交換します。
ティードのオイルフィルターはここ↓にあります。

f:id:ti_taka:20190804011603j:plain

これを先程のオイルフィルターレンチで回して外します。
このオイルフィルターを外す際も、外したあとオイルが出てくるので、廃油吸収剤を下に置いておいたほうが良いです。

写真を取り忘れてしまいましたが、オイルフィルターを交換したら、オイルを適量入れて作業終了です。

終わりに

サスペンションの交換は、高校の時リジットバーに交換した際も、詳しい先輩にやっていただいたのですが、とても大変そうだった印象だったので、とりあえず自力でやりきれてよかったです。
ちゃんと安定させてジャッキアップできれば特に難しい部分はありませんでした。
オイルも交換したし改めてエンジンをかけてみようとしたらかからなくなってました。
色々調べた結果プラグが悪くなっている可能性があるので、次回時間があるときはプラグの交換をしたいと思います。

iOSのheic形式画像ファイルを一括でjpg形式に変換

単にsipsコマンドとxargsコマンドを実行すればOK。
xargsコマンドを使いこなせてなかったのでメモ。

$ ls | grep heic | xargs -t -n 1 -I{} sips --setProperty format jpeg {} --out {}.jpg

$ ls | grep heicで取得したheicファイル一覧をxargsコマンドに渡し、-Iオプションで引数の位置を明示的に指定してあげるだけ。

hogehoge.heic→hogehoge.heic.jpgとして変換される。

参考

qiita.com

orebibou.com

Vue.js + Bootstrap-vue でモーダルウィンドウとフラッシュメッセージの表示サンプル

忘れそうなのでメモしておきます。子が親のメソッドを呼び出したり親が子のメソッドを呼び出したりして分かりづらくなっているので後日もっと良い方法を検討したいと思います。

基本的にはBootstrap-vueの公式に書いてあった方法で実装していきます。
https://bootstrap-vue.js.org/docs

作成したファイルは以下の4つです。
簡単なメモアプリ上でメモを削除する動きを作ってみます。

↓メインのコンポーネント

// App.vue
<template>
  <div id="app">
    <Modal @delete="deleteMemo" ref="childModal"/>
    <Memos @confirm="deleteConfirm" ref="childMemos"/>
    <Message ref="childMessage"/>
  </div>
</template>

<script>
import Modal from './components/Modal.vue'
import Message from './components/Message.vue'
import Memos from './components/Memos.vue'

export default {
  name: 'app',
  components: {
    Modal,
    Memos,
    Message
  },
  methods: {
    deleteConfirm(i) {
      this.$refs.childModal.showModal(i)
    },
    deleteMemo(i) {
      this.$refs.childMemos.deleteMemo(i)
      this.$refs.childModal.hideModal(i)
      this.$refs.childMessage.showMessage(true)
    },
  }
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

↓メモリストのコンポーネント
これは適当に書きました。

// Memos.vue
<template>
  <div class="container">
    <b-list-group>
      <b-list-group-item v-for="memo,i in memos"><b>{{ memo.title }}</b>{{ memo.text }}<b-button variant="danger" @click="deleteComfirm(i)">削除</b-button></b-list-group-item>
    </b-list-group>
  </div>
</template>

<script>
export default {
  name: 'Memos',
  data() {
    return {
      memos: [
        {"title": "title1", "text": "memomemoemomeo"},
        {"title": "title2", "text": "memomemomemomemo"},
        {"title": "title3", "text": "memomemomemomemo"},
        {"title": "title4", "text": "memomemomemomemo"},
        {"title": "title5", "text": "memomemomemomemo"},
        {"title": "title6", "text": "memomemomemomemo"},
        {"title": "title7", "text": "memomemomemomemo"},
        {"title": "title8", "text": "memomemomemomemo"},
        {"title": "title9", "text": "memomemomemomemo"},
      ]
    }
  },
  methods: {
    deleteComfirm(i) {
      this.$emit('confirm', i);
    },
    deleteMemo(i) {
      // 実際にはここに削除処理を記述
      this.memos.splice(i, 1);
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

↓モーダルのコンポーネント
https://bootstrap-vue.js.org/docs/components/modal/#using-show-hide-and-toggle-component-methods
こちらの
Using show(), hide(), and toggle() component methods
の方法を使って、モーダルの表示、非表示を操作しています。

// Modal.vue
<template>
  <b-modal ref="modal" size="xl" hide-footer hide-header>
    <div class="d-block text-center">
      <p>本当に削除しますか?</p>
    </div>
    <b-button class="delete-button" variant="success" block @click="hideModal()">いいえ</b-button>
    <b-button class="delete-button" variant="danger" block @click="deleteMemo()">はい</b-button>
  </b-modal>
</template>

<script>
export default {
  name: 'Modal',
  data() {
    return {
      deleteIndex: ""
    }
  },
  methods: {
    showModal(i){
      this.deleteIndex = i;
      this.$refs.modal.show()
    },
    hideModal(){
      this.deleteIndex = "";
      this.$refs.modal.hide()
    },
    deleteMemo(){
      this.$emit('delete', this.deleteIndex);
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

↓フラッシュメッセージのコンポーネント
https://bootstrap-vue.js.org/docs/components/alert/#auto-dismissing-alerts
Auto dismissing alerts
の実装サンプルを使い表示後2秒経過すると消えるようにしています。
また、transitionでアニメーションを入れています。
https://jp.vuejs.org/v2/guide/transitions.html

// Message.vue
<template>
  <transition name="message">
    <div v-if="messageActive" id="message-parent">
      <div id="message">
        <b-alert :show="dismissCountDown" v-bind:variant="variant" @dismiss-count-down="countDownChanged">
          {{ message }}
        </b-alert>
      </div>
     </div>
  </transition>
</template>
<script>
export default {
  name: 'Message',
  data() {
    return {
      // message表示用データ
      dismissSecs: 2,
      dismissCountDown: 0,
      message: '',
      variant:'',
      messageActive:false,
    }
  },
  methods: {
    countDownChanged (dismissCountDown) {
      this.dismissCountDown = dismissCountDown
      if (dismissCountDown == 0) {
        this.messageActive = false;
      }
    },
    showMessage: function (success) {
      this.message = success ? "削除しました" : "エラーが発生しました";
      this.variant = success ? "success" : "danger";
      this.dismissCountDown = this.dismissSecs;
      this.messageActive = true;
    },
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.message-enter-active, .message-leave-active {
  transition: opacity 0.5s;
}
.message-enter, .message-leave-to {
  opacity: 0;
}
#message-parent {
  width:100%;
}
#message {
  position: fixed;
  top: 5px;
  right: 0;
  left: 0;
  margin-left: auto;
  margin-right: auto;
  box-sizing:border-box;
  width:95%;
}
</style>

↓こんな表示になります。
f:id:ti_taka:20190409014407g:plain

処理の順番としては、

  1. Memos.vue中のdeleteComfirm(i)が発火
  2. App.vue中の@confirm="deleteConfirm" → deleteConfirm(i)が発火
  3. Modal.vue中のshowModal(i)が発火
  4. Modalの表示
  5. 「はい」を選択
  6. Modal.vue中のdeleteMemo()が発火
  7. App.vue中の@delete="deleteMemo" → deleteMemo(i)が発火
  8. Memos.vue中のdeleteMemo(i)が発火
  9. Modal.vue中のhideModal(i)が発火
  10. Message.vue中のshowMessage(true)が発火
  11. countDownChanged (dismissCountDown)にて1秒毎にカウントダウンし、設定秒経過したらメッセージを消す

という流れになっています。

とりあえず作りたいものは作れましたが、状態を管理するデータがいろんなコンポーネントに散在してしまっているので、今後はVuexなどを使って管理できるようにしたいと思います。

人間の時間の捉え方は結局今やりたい事、やらなくてはならない事がどれぐらいあるかに依存する

小さい頃から時間の感じ方の違いがなんで起きうるのか疑問に思っていました。
どうして楽しい時間は早く感じるのか、めんどくさい作業をしている時間は長く感じるのか。
小学校の時、国語の教科書に代謝時間という考え方が載っていたのを覚えています。
自分の体の持っている全エネルギーのうち単位時間でどの程度エネルギーを消費しているか、どの程度の心拍数があるか。
それが大きければ時間は早く感じ、小さければ時間は長く感じる
という理屈です。
(この辺りあまり覚えてませんがそんな定義だったと思います。)
なので、蟻にとっては一日もあっという間に過ぎるけれども、象にとっては長く感じると。
この考え方に、「確かにそうかもなぁ〜。」と思う反面、なんとなく納得しない部分もありました。
エネルギー消費が大きく、疲れる退屈な仕事でも時間は長く感じるし、消費が少なくても時間が短く感じることはある。
その違いはなんだろうかと思いました。

大人になってから、誰かから「1歳児にとっての一年間は一生分と同じ時間だが、50歳にとっての一年間はせいぜい人生の中の50分の1に過ぎない。」という話を聞きました。
x時間生きた人にとっての単位時間は人生の中での1/xでしか無い、それを積分していくと対数の関数になる、だから時間の感じ方は齢とともに短く感じるようになっていくんだなぁと思ってました。
と思ったら、既に「ジャネの法則」というものが、フランスの哲学者・ポール・ジャネにより発案されていたようです。

人間の五感は対数に変換されている

この記事を見て妙に納得したのを覚えています。

しかし、このジャネの法則は、あくまで過去を振り返っての感覚時間における法則であり、この先の1時間、1年間、更には自分の人生の長さをどう捉えるかの感覚とはまた違います。

仕事をし始めて、あらゆるプロジェクトに携わる中で、「これだけのプロジェクトをあと一年でやりきるのか、、あと一年しかないのか、、」などと思う事が増えたり、自身のキャリアを考えた時にも、「**年後には**が出来るようになっていたい!!その為には毎日勉強時間を確保したとしても***時間しかない。。全然時間ないなぁ〜。」と思う事が多くなりました。
また、子供ができた事で、「子供が**歳になるまでには家が欲しいなあ〜、その為には毎年**円貯金しなきゃー」と考えるようになりましたが、この先の数年間はとても短い時間のように感じられます。

こうしたことから、新たに以下の仮説を提唱したいと思います。
「人間の未来に向けた感覚時間は、この先の時間の中でやるべき、やりたいと考えている事柄の総量に反比例し、その内ある時間の中で実施し切れる(と想定される)量に比例する」

つまり、やりたいこと、やるべきことが多い人は、その中である時間の中で実施しきれる割合は相対的に少なくなります。
逆に、あまりタスクを抱えていない状態だと、時間は相対的に長く感じるようになります。
当たり前の事を言っているようですが、退屈な作業をしているときに時間が長く感じられるのは、作業のゴールイメージが無いか、やりたくもなく、やるべきことだとも認識出来ていないため、分母が極限まで0に近くなってしまうためかとも考えられ、なんとなく説明が付く気がします。

  1. 過去の時間を振り返る際の感覚時間は、現在自分が持っている記憶の量に反比例し、ある時間の中で増加した記憶の量に比例する。(ジャネの法則
  2. 未来時間を意識した際の感覚時間は、この先の時間の中でやるべき、やりたいと考えている事柄の総量に反比例し、その内ある時間の中で実施し切れる(と想定される)量に比例する

時間の捉え方が短くなったとか、長く感じる場合は、1.だけでなく、2.の要素も複合的絡み合っての結果なのかなと思います。
年を重ねて時間が無いと思うようになるのは、ある時間の中で得られる経験値が、自分の人生の中での占める割合が少しづつ減っていく為、だけでなく、いろんな考え方ができるようになったぶん、やりたいことが増えたため、または立場も変わっていく中でやるべきことが増えていったためという側面もあります。

そう考えると、時間が短く感じるのは「歳とったな〜。。」と嘆くためのネタになるだけでなく、この先の人生の中で自分はどういった事をやりたいのか、それに向けてどの程度のことが成し遂げられそうか、を意識するための丁度よい指標のような物になるのかもしれません。
長期的にやりたいこと、近い将来やるべきことなど、時にはそういったことから一旦頭を切り離し、ゆったりとした時間を過ごしたり、目の前の物事に取り組んで充実感を感じるというのが一番精神衛生上健康的な時間の過ごし方なのかなーと思いました。
常に、「時間が短い」と感じる場合は意識的にそういう時間を設けるのも必要かもしれません。

時間を意識しながらの方が効率的に動けることもありますが、いつも時間を意識するのも辛いですからね。

今日はそんな事を考えました。
明日からまた頑張っていこうと思います。

pythonでping監視 〜〜その2〜〜

前回、あるNWセグメント内のクライアントにpingを送って常時監視することを試みました。

kittagon.hateblo.jp

その別バージョンです。xargsで並列処理をしています。

# ping_xargs.py

import subprocess, requests, sys

def is_connectable(host):
    # ping = subprocess.Popen(["ping", "3", "-c", "1", host], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
    ping = subprocess.Popen(["ping", "-W", "1", "-c", "1", host], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
    ping.communicate()
    return ping.returncode == 0

ipaddress = sys.argv[2]
num = sys.argv[1]
result = is_connectable(ipaddress)
print(ipaddress)
if result is not True:
    url = "https://my-line-bot-url.com/alert" + num
    requests.get(url)
$ while true; do sed -e 's/,/ /g' IPAdressTable.csv | xargs -L 1 -P20 python ping_xargs.py; done;

特に効率的になったわけではないですが。

しかも今回監視をしたいと思っていたNW設計を聞いたら一番上のL3SWではポート間のICMPを禁止しているらしい。。。
L3SWにぶら下がってるHUB間で全然疎通しないからなんでかなと思っていたら、そもそもpingで監視が出来るNW環境じゃなかった。。