GAミント至上主義

コストにうるさいWEBアプリ開発者のブログ。最近はPython, Vue.js, Kubernetesがメイン@株式会社ビズオーシャン。https://github.com/uyamazak/

Vue.js のmountedでaddEventListenerやsetIntervalするとき、後始末を忘れない

当たり前だけどvue routerで読み込んでいるコンポーネントのmountedでaddEventListenerやsetIntervalをしていると、ページの行き来のたびに登録されて、大変なことになる。

destroyedで削除をセットにするのを忘れないようにする。

removeEventListenerとclearIntervalには同じ関数オブジェクトを渡す必要があるため、いつもは無名関数で書いてしまうが、methodsに書いて使う。

setIntervalの方は、返り値のオブジェクトをclearIntervalするのでdataに入れておく。

コードを書いてちゃんと削除できているかどうかは、登録する関数でconsole.logするなどして、ページを移動して不要に何度も登録して実行されていないか確認した。

  data: function(){
    return {
       updateMessageTimer : null
    }
  },
 methods: {
    updateMessage: function(){
       // do something
    },
    changeState: function(){
      // do something
    }
  },
  mounted: function(){
    window.addEventListener('popstate', this.changeState, false)
    this.updateMessageTimer = setInterval(this.updateMessage, 5000)
  },
  destroyed: function () {
    window.removeEventListener('popstate', this.changeState, false)
    clearInterval(this.updateMessageTimer)
  }

addEventListenerのuseCaptureオプションはとりあえずfalseだけど実はよくわかっていない。登録時、削除時で揃えないといけないのは分かる。

developer.mozilla.org

GCPを使ったPWAのサーバー構成を考える

Vue.jsとwebpackといくつかの独自サーバー処理が必要なアプリをどうGCPでデプロイするかを考えた。

静的ファイルとWEB API的な部分で分けて考える。

1、静的ファイル配信

Webpackでビルドしたjs、HTMLとかjs、CSS、画像とかメインになるファイルたち。

選択肢は3つ考えた。

Cloud Storage

AWSでいうS3。静的ファイルには一番安そう。
でも独自ドメインSSL化は単体では無理。
手前にHTTPSロードバランサーを入れるとそれだけで2,000円/月程度かかってしまう。

静的ウェブサイトのホスティング  |  Cloud Storage ドキュメント  |  Google Cloud

Nginx + HTTPSロードバランサー

やるとしたらGoogle Container Engine(GKE)でやる。Nginxで証明書もってもいいけど、GKEで外部IPを取るとTCPロードバランサーが使われ、HTTPSと値段は一緒なのでSSL処理を外に出せるHTTPSの方を使った方がお得感がある。

Nginxは鉄板だけど、変更の度にDockerイメージのデプロイがいるのは面倒な気がする。

Firebase Hosting

いまのところ最強くさい。

Hosting を使ってみる  |  Firebase

CLIでのデプロイとロールバック、管理画面でもいろいろできる。

さらにドメインのAレコードを向けるだけでSSL証明書もやってくれるもよう。

気になる料金は会社のアカウントでは有料プラン(Flame、月25ドル)になってしまっているので、容量10 GB、転送量50GB/monthまで使えて十分すぎる。

Firebase

さらにCloud Functions(以下CF)も組み合わせて使うことができ、同一ドメインの特定のパスをCFに渡せる。
今回は難しいけどサーバーサイドを全てCFのNode.jsで済ませることができればいわゆるサーバーレスが実現できそう。
レスポンス速度が気になるので今度試してみる。

2、WEB API

今回は、ヘッドレスChromeを利用したHTML→PDF変換サーバーがあるため、Dockerが必要。
またその前処理、画像のリサイズをPythonDjangoで作っており、これもDockerで動かしたいのでGKE一択となりそう。

他の選択肢も一応まとめる。

DjangoだけならスタンダードのApp Engineという手もあるが、いまさらPython2で書きたくない。

App Engine 環境の選択  |  App Engine Documentation  |  Google Cloud

フレキシブルのApp EngineでDockerを使う手もあるが、DjangoとPDF変換サーバーで最低2台必要で、PDF変換サーバーはグローバルに出したくないので、やっぱGKEが良さそう。
あとGKEに慣れてしまったので自由度の低いフレキシブルを使うには抵抗がある。

アクセスに大きな波があり、必要なスケール幅が読めないとかならApp Engine系の方がサーバー管理は楽そうだが今回は新規でそこまで波があるサービスではないのでGKEでなんとかできそう。

あと上記に出たCloud Functions。
今回はPDF変換サーバーは無理としてもDjango部分は何とかできるかもしれないが、どうせGKEを使うことになるのでまとめた方が良さそう。

まとめ

ということで、
静的ファイルはFirebase Hosting
APIや各種サーバーはGKEで構築するのが今回は良さそう。

Cloud FunctionでできることならHostingとの連携もできるので今後はどんどん使って行ったほうが良いと思う。

ブラウザでログインしたFirebaseのユーザー情報をサーバー側で取得する

前回からの引き続きやりたかったのは、Vue.jsとFirebase(ブラウザ上)でログイン済みユーザーのメールアドレスに、サーバー側からメールを送りたかった。

Vue.jsのPWAでバックエンドをDjangoからFirebaseに移行した - GAミント至上主義

もちろん、js側でFirebaseのユーザーのメールアドレスを取得して、サーバー側にPOSTなんかで一緒に送ればできる。

詳細は公式
Interface: User  |  Firebase


でもサーバー側ではそのメールアドレスが本当にそのユーザーのものなのかはわからず、でたらめなメールアドレスでも受け取ってしまうので危険なメール発射台にされてしまう可能性がある。
リクエストはユーザーのWEBブラウザから送られてくるのでIP制限もできない。

そのため、ブラウザのjs側からはtokenを送り、サーバー側でそのtokenを使ってユーザーのメールアドレスを取得して送信すれば、確実にユーザーのものに送ることができる。

今回Googleログインのみを使っているため、そのアクセストークンを取得し、GoogleのOAuthのAPIサーバー側に送ればいいかと思ったけど、そのトークンを取得できるのは下記のようなサインイン時のメソッドしか見当たらなかった。

サインイン直後なら使えるけど、期限が1時間で切れてしまうが、それを更新する方法が再ログインさせるしか見当たらず、ユーザー側の流れが切れてしまうし、リダイレクトを挟んだり処理が複雑になるので避けたい。

firebase.auth().signInWithPopup(provider).then(function(result) {
  // This gives you a Google Access Token. You can use it to access the Google API.
  var token = result.credential.accessToken;
  // The signed-in user info.
  var user = result.user;
  // ...
})

で、しばらくどうしようと思ってたけど、わざわざGoogleのOAuth APIからではなく、FirebaseのAPIから直接取ればいいと気づいた。

ログイン完了後は公式通りonAuthStateChangedでユーザーオブジェクトを取得できるので、グローバルなプロパティか、vuexなどに入れておくと便利。

Firebase でユーザーを管理する  |  Firebase

firebase.auth().onAuthStateChanged(function(user) {
  if (user) {
    // User is signed in.
  } else {
    // No user is signed in.
  }
});

Firebase Auth REST API  |  Firebase

Firebaseのトークンは公式通りgetIdTokenで取得する。forceRefresh は付けといた方がいいのかな?

ID トークンを確認する  |  Firebase

firebase.auth().currentUser.getIdToken(/* forceRefresh */ true).then(function(idToken) {
  // Send token to your backend via HTTPS
  // ...
}).catch(function(error) {
  // Handle error
});


メールアドレスなどのユーザー情報を取得するエンドポイントは下記でfirebaseのAPI_KEYが必要。これは初期導入時にゲットしているはず。

https://www.googleapis.com/identitytoolkit/v3/relyingparty/getAccountInfo?key=[API_KEY]

このエンドポイントにサーバー側にAPI_KEYと、トークンを渡せば取得できそう。
有効期限はあるもののパスワードと同レベルの情報なので、HTTPSでPOSTで送らないとセキュリティ上まずいはず。


Python側(DjangoのView内)で、試しにVue側から送られてきたAPI_KEYとトークンを使って
ユーザー情報を取得する下記のような関数を作って動作確認

API_KEYはリクエストURLに含めるので正規表現でバリデーションか、エンコード等した方がよさげ。

import requests
import json


def get_userinfo(apikey, token):
    resp = requests.post('https://www.googleapis.com/identitytoolkit/v3/'
                                      'relyingparty/getAccountInfo'
                                      '?key={}'.format(apikey),
                                      params={'idToken': token})
    logger.debug("userinfo:{}".format(resp.text))
    return json.loads(resp.text)

logger.debugで下記のようなJSONで返ってくる

2018-05-17 12:24:07,080 [DEBUG] shanyang.views: userinfo:{
  "kind": "identitytoolkit#GetAccountInfoResponse",
  "users": [
    {
      "localId": "xxxxxxxxxxxxxxxxxxxxxxxxxx",
      "email": "xxxxxxxxxxxxx@example.co.jp",
      "displayName": "なまえ",
      "photoUrl": "https://lh3.googleusercontent.com/xxxxxxxxxxxxxxxxxxxxxxxxxxx/photo.jpg",
      "emailVerified": true,
      "providerUserInfo": [
        {
          "providerId": "google.com",
          "displayName": "なまえ",
          "photoUrl": "https://lh3.googleusercontent.com/xxxxxxxxxxxxxxxxxxxxxxxxxxx/photo.jpg",
          "federatedId": "000000000000000000",
          "email": "yu_yamazaki@bizocean.co.jp",
          "rawId": "xxxxxxxxxxxxx@example.co.jp"
        }
      ],
      "validSince": "1525917073",
      "lastLoginAt": "1526362431000",
      "createdAt": "1525917073000"
    }
  ]
}

上記の関数でjson.loadsまでやっているので、下記のような感じでemailを取得できるとこまで確認。
失敗したときの例外処理がもちろん必要になる。

user_info = get_userinfo(apikey, token)
user_email = user_info['users'][0]['email']

これでFirebaseで認証済みユーザーの情報をサーバー側で取得することができた。

Vue.jsのPWAでバックエンドをDjangoからFirebaseに移行した

現在開発中のVuejsを使ったPWAで、開発当初はログインやデータの保存にDjangoを使っていたけど、ずっと気になっていたFirebaseに変更してみることにした。

クライアント側のコードはほぼそのまま流用し、機能も少なかったので、1日程度でとりあえず動いた

とりあえず移行した機能は下記。

  • Googleログイン(Firebase Authentication)
  • ユーザーごとのデータバックアップ+ロード(Cloud Firestore ※まだベータ)

もちろん移行できなかった、しなかった機能もある。

  • PDF生成サーバーLinuxでヘッドレスChromeを使うため)
  • アップロードされた画像をリサイズしてBase64で返すもの(Djangoのまま、これは自分のJS力次第でCloud Functionsで行けそう)

印象としては、もうモバイルに限らず、新規WEBアプリはこれでいいんじゃないかという印象。
足りないものはCloud Functionsなり、自分でサーバーを立てる別途GKEで立ててAPIとして使えばほぼ大丈夫そう。
安いし、管理不用で、スケールアウトは実質無限大。

導入のながれとしては、すでにGCPを使っており、プロジェクトもあるのでそのまま進む。

firebase.google.com

そうするといろいろ設定が表示されるので使う。

webpackでやっているのでnpmでインストール

npm install --save firebase

Vueのプラグインという形でラッパーを作り、どこからでもthis.$firebaseという形で呼び出せるようにした。
やることが増えたら機能ごとに分けたほうが良さそう。

とりあえず動いたって感じなので不用なコピペしたコードが残っており、めっちゃ汚い。

import firebase from 'firebase/app'
import 'firebase/auth'
import 'firebase/firestore'
import VueLocalForage from 'vue-localforage'
const _ = require('lodash')
// ↓登録後にもらえるやつ
const firebaseConfig = {
  apiKey: 'yourKey',
  authDomain: 'projectName.firebaseapp.com',
  databaseURL: 'https://projectName.firebaseio.com',
  projectId: 'projectName',
  storageBucket: '',
  messagingSenderId: '00000000'
}
firebase.initializeApp(firebaseConfig)
// ↓これいれないとなんかエラーでる
const storeSettings = {
  timestampsInSnapshots: true
}

const saveKeys = ['format_rirekisho', 'basicData', 'yagiProfile', 'recentEdit']
const BACKUP_VERSION = 'v20180511-01'
const BACKUP_COLLECTION_KEY = 'backups'
const BACKUP_LIMIT_NUM = 10


const FirebaseLib = {
  install: function (Vue, options) {
    Vue.use(VueLocalForage)
    const main = new Vue({
      data: function () {
        return {
          authProvider: new firebase.auth.GoogleAuthProvider(),
          db: {},
          user: {},
          backupList: []
        }
      },
      computed: {
        isAuthenticated: function () {
          if (this.user && this.user.uid) {
            return true
          } else {
            return false
          }
        }
      },
      methods: {
        signIn: function () {
          firebase.auth().signInWithRedirect(this.authProvider).then(function (result) {
            // This gives you a Google Access Token. You can use it to access the Google API.
            console.log(result)
            var token = result.credential.accessToken;
            // The signed-in user info.
            var user = result.user;
            app.$message({
              message: 'ログインしました:' + user.email,
              type: 'info'
            })
            // ...
          }).catch(function (error) {
            console.log(error)
            // Handle Errors here.
            var errorCode = error.code;
            var errorMessage = error.message;
            // The email of the user's account used.
            var email = error.email;
            // The firebase.auth.AuthCredential type that was used.
            var credential = error.credential;
            // ...
          })
        },
        signOut: function () {
          const app = this
          firebase.auth().signOut().then(function () {
            // Sign-out successful.
            app.$message({
              message: 'ログアウトしました',
              type: 'info'
            })
          }).catch(function (error) {
            app.$message({
              message: 'ログアウトに失敗しました',
              type: 'error'
            })
            console.log(error)
          })
        },
        showResultMessage: function (message, type) {
          this.$message({
            message: message,
            type: type
          })
        },
        setupDb: function () {
          this.db = firebase.firestore()
          this.db.settings(storeSettings)
        },
        loadBackup: function (id) {
          this.setupDb()
          let errors = []
          const app = this
          this.db.collection(BACKUP_COLLECTION_KEY).doc(id).get()
            .then(
              function (documentSnapshot) {
                console.log(documentSnapshot.data())
                let data = JSON.parse(documentSnapshot.data().data)
                _.forEach(saveKeys, function (key) {
                  if (data.hasOwnProperty(key)) {
                    app.$setItem(key, data[key],
                      function (err) {
                        if (err) {
                          console.log(err)
                          errors.push(err)
                          return
                        }
                        console.log('sync-loaded:', key)
                        app.$eventHub.$emit('sync-loaded-' + key, data[key])
                      })
                  }
                })
                if (errors.length === 0) {
                  app.$message({
                    message: 'バックアップを読み込みました' + documentSnapshot.data().label,
                    type: 'success'
                  })
                }
              }
            )
            .catch(function (error) {
              app.$message({
                message: 'バックアップの読み込みに失敗しました',
                type: 'error'
              })
              console.log(error)
            })

        },
        deleteBackup: function(id){
          this.db.collection(BACKUP_COLLECTION_KEY).doc(id).delete().then(function() {
              console.log("Document successfully deleted!");
              return true
          }).catch(function(error) {
              console.error("Error removing document: ", error);
              return false
          })
        },
        getBackupList: function () {
          if (!this.isAuthenticated) {
            return
          }
          this.setupDb()
          const uid = this.user.uid
          this.backupList = []
          const app = this
          this.db.collection(BACKUP_COLLECTION_KEY)
            .where('uid', '==', this.user.uid)
            .limit(20)
            .orderBy('timestamp', 'desc')
            .get()
            .then(
              function (querySnapshot) {
                let count = 0
                querySnapshot.forEach(function (doc) {
                  // doc.data() is never undefined for query doc snapshots
                  // console.log(doc)
                  if (count < BACKUP_LIMIT_NUM) {
                    app.backupList.push({
                      id: doc.id,
                      value: doc.data()
                    })
                  } else {
                    console.log(count, doc.data().label)
                    app.deleteBackup(doc.id)
                  }
                  count++
                });
              });
        },
        addBackup: async function (label) {
          if (!this.isAuthenticated) {
            this.$message({
              message: 'ログインしてください',
              type: 'error'
            })
            return
          }
          let storageData = {}
          await this.$iterateStorage(function (value, key, iterationNumber) {
            if (saveKeys.includes(key)) {
              console.log(key, value)
              storageData[key] = value
            }
          }, function (err) {
            if (!err) {
              console.log('Iteration has completed');
            }
          })
          if (!storageData) {
            this.$message({
              message: 'データがありません',
              type: 'error'
            })
            return
          }
          var ts = Date.now()
          this.setupDb()
          const backupData = {
            label: label,
            uid: this.user.uid,
            timestamp: ts,
            data: JSON.stringify(storageData),
            version: BACKUP_VERSION
          }
          console.log(backupData)
          const app = this
          this.db.collection(BACKUP_COLLECTION_KEY).add(backupData).then(
            function () {
              app.$message({
                type: 'success',
                message: '保存成功:' + label
              })
            }
          ).catch(function (error) {
            app.$message({
              type: 'error',
              message: '保存失敗:' + label
            })
          })
        }
      }
    })
    firebase.auth().onAuthStateChanged(function (user) {
      main.user = user
    })
    firebase.auth().getRedirectResult().then(function (result) {
      console.log('getRedirectResult', result)

      if (result.credential) {
        // This gives you a Google Access Token. You can use it to access the Google API.
        // var token = result.credential.accessToken;
        main.showResultMessage('ログインしました:' + result.user.email, 'success')
        // ...
      }
      // The signed-in user info.
      var user = result.user;
    }).catch(function (error) {
      // Handle Errors here.
      var errorCode = error.code;
      var errorMessage = error.message;
      // The email of the user's account used.
      var email = error.email;
      // The firebase.auth.AuthCredential type that was used.
      var credential = error.credential;
      main.showResultMessage('ログインに失敗しました:' + error.message, 'error')
    });
    Vue.prototype.$firebase = main
  }
}
export default FirebaseLib

これをmain.jsで読み込む。

import FirebaseLib from './assets/lib/FirebaseLib'
Vue.use(FirebaseLib)

はまったところ

Cloud Firestoreの権限

バックアップはログインしたユーザーで、自分のしか見れたり、作れたりしないといけない。
ベータのせいか、あんまりドキュメントがなく、下記のような形でひとまず動いている。

service cloud.firestore {
  match /databases/{database}/documents {
    match /backups {
    	allow list: if request.auth.uid != null && request.query.limit <= 20;
      match /{backupid} {
      	allow read, delete: if request.auth.uid == resource.data.uid;
        allow write: if request.auth.uid == request.resource.data.uid;
      }
    }
  }
}

まずdatabaseとかdocumentsとか、collectionとか独自の概念ではまる。
listを付けないと一覧を取る際にエラーになるところではまる。
次に追加するデータはrequest.resource.data.uid、すでにあるのはresource.data.uidの違いがわからずはまる。
あと更新にしばらく時間がかかる上に、更新サれたかどうかも分からないのでちょっと手が止まる。

と、慣れないことなので大分時間をくったけど、こういう細かい権限管理はRDBではできないのですげー便利だと感じた。

ログイン状態を取るには非同期が必須

ページ読み込み時やvueのテンプレート上、mountedなどで現在のユーザー情報を取れなくて困ったが公式に書いてあった。

Firebase でユーザーを管理する  |  Firebase

firebase.auth().onAuthStateChanged(function (user) {
      main.user = user
})

これでユーザー状態をVueの監視下にいれておけば便利。

1日程度のちょっとした勉強で強大な力とスピードを得られた気がする。

Vue.js + Firebaseで個人開発もやってみたいと思う。

Pythonのos.chdir()はスレッドセーフではないので注意

Python3+Django内でZIPファイルを生成する処理で、複数ユーザーが同時に実行すると、一人が成功し、他が失敗するよくわからない状態になり、更にまずいことに成功したユーザーのZIPには他のユーザーのファイルが入ってしまっていた。

処理は下記のようになっていた。
最初はtempfile.TemporaryDirectory()が原因かと思ったけど、そんな雑な作りをしているわけはなく、原因はos.chdirだった。


一部抜粋(動かない)

with TemporaryDirectory() as temp_dir:
    os.chdir(temp_dir)
    subprocess.run(['zip', '-r', file_name, "./"],
                   stdout=subprocess.PIPE,
                   stderr=subprocess.PIPE,
                   check=True)
    temp_zip = File(open(zip_name, "rb"))
    temp_zip.seek(0)
    return {'name': zip_name,
            'content': temp_zip.read()}

os.chdirで変更するカレントディレクトリはスレッドで共有しているため、同時に処理すると順不同だが下記のように処理されてしまっていたと推測

os.chdir('/tmp/aaaa') #一人目
os.chdir('/tmp/bbbb') #二人目 ここで上書きされる
subprocess.run(['zip', '-r', file_name, "./"],  #一人目 ./は'/tmp/bbbb'
subprocess.run(['zip', '-r', file_name, "./"],  #二人目 ./は同じく'/tmp/bbbb'に もうファイルがなくなっておりエラー

zipコマンドの対象ディレクトリに./と、カレントディレクトリを使ってしまったのが諸悪の根源だと思われたので、全部temp_dir込のフルパスを使うように修正すればいいと思った。
が、できたzipファイルにtmpディレクトリが入ってしまうので素直に標準ライブラリのzipfileを使う方向に考え中。

subprocess.run(['zip', '-r', file_name, temp_dir],

そもそもなぜわざわざpython標準ライブラリのzipfileを使わずに、subprocess.runでOSのコマンドを叩いているのかと思ったけど、そういえばzipfileはパスワード付きのzipの作成ができなかったから。展開はもちろんできる。

スレッドとか、並列実行はテストが難しいなと実感。
あとなるべくos系は使わないことと、どうしても使うときはスレッドセーフかどうかを確認が必要だと思った。

13.5. zipfile — ZIP アーカイブの処理 — Python 3.6.5 ドキュメント


独学プログラマー Python言語の基本から仕事のやり方まで

独学プログラマー Python言語の基本から仕事のやり方まで

PWAのゴールはネイティブアプリ"ではない"ことを敢えて意識する

現在、bizoceanでVue.jsを使った履歴書のPWAアプリ(リリースは夏頃できたらいいなぁ)の企画、開発中や、先日下記のセミナーに出たりした思ったことをまとめておく。

japan-android-group.connpass.com

今北産業

  • PWAの技術を使ってもネイティブアプリと全く同じにはできない
  • PWAのメリットもデメリットもWEBブラウザアプリであることによる
  • PWAのゴールはユーザーの利便性を向上させること

そもそもPWAって?という疑問にはやはり言い出しっぺのGoogleさんの記事が間違いないと思う。

はじめてのプログレッシブ ウェブアプリ  |  Web  |  Google Developers

ネイティブアプリと全く同じ体験であることをゴールにするといろいろつらい

PWAは名前のとおりProgressiveな(進んだ)Webアプリであり、もうちょっと正確にいうとWEBブラウザのアプリ。

でも、上述のセミナーや巷では結構、ネイティブアプリとの比較が多く出るし、ネイティブと同じことが善とするような風潮がある。

でも、PWAがスマホのネイティブアプリの代わりになって、ネイティブアプリはいらなくなるとか、PWAの技術(Service WorkerとかPushとか)を使えば、ネイティブアプリと変わらないものを作れるみたいな考えをしたり説明しようとすると、少なくとも現時点ではいろいろつらい。

たとえば下記のようなつらみ

  • アプリなのにアプリストアに出ない(App StoreGoogle Playとか、Windowはもうすぐ出るんだっけ)
  • 日本はiPhone多いのにプッシュ通知できないの?(Service Workerは最近動いたけど)
  • UIがネイティブと違って分かりにくい(似せられるFWもあるみたい)
  • WEBサイト見てたのに突然「インストール」が出てきたらなんか怖いからインストールなんてしないだろ

ネイティブ基準にするとPWAの問題点ばっかりでて暗い雰囲気になるし、まだいいやみたいな感じでやる気もなくなる。

あとUnityとかゲーム等で処理速度を求めて、ハードウェアの低レベル操作が必要なものはネイティブと同等になることは無いと思う。

PWAはあくまでWEBアプリであり、ネイティブアプリと同等の体験を目指さなくていい

よくいわれるPWAの特長としては

  • ブラウザ感を消せる(manifest.jsonでの"display": "standalone")
  • オフライン対応(Service Workerでのキャッシュ)
  • プッシュ通知(pushManager)
  • HOME画面に追加のポップアップ(前からブラウザでやればできてた)

と書いてみるとどれも今までWEBアプリでは難しく、ネイティブでしかできなかったことばかりで自分でも何を言おうとしてたのか分からなくなる。

が、どれも別にネイティブと同じにしようとしてるわけではなく、今までWEBアプリに足りなかった機能を追加することで、ユーザーにとって便利なサービスを提供するのがゴールであると思う。

そして、PWAはWEBアプリなので、上記以外にWEBアプリとしての長所がたくさんある。

PWAの強みと向いているもの

何度もいうようにPWAはWEBアプリなので、ネイティブと比べたWEBアプリの強みそのままかもしれない

大きくWEBアプリの強みとして

  • インストール不要で検索からすぐ開ける
  • 開発コストの削減、スピードアップ
  • アプリストアなどのプラットフォーム依存からの脱却

が挙げられると思う。

そしてそれを活かせるものとして書きが考えられる

使用頻度が少ないもの

例えば、毎日何度も使われるようなInstagramTwitter、LINEなどはロイヤリティが高いもので、それらをずっとWEBブラウザで見ることは普通しない(そもそも見れない場合も)。

セミナーではロイヤリティとかエンゲージメントが低いという言葉を使ってた気がする。

でも、スマホにインストールするアプリの数は、人によって差はあれど限界はあるので、たまーに使うものはインストールせずWEB検索で済ますことが多いはず。

私でいうと、料理はたまーにレシピを探す程度なので、Cookpadみたいなアプリは入れずググってなんとかする。あと釣りにいくときに使う潮見表は以前アプリを入れてたけどそこまで頻繁に行かないのでWEB検索で済ますようになった。

あとプロモーション、ブランディング目的の情報サイト(セミナーでは大手の化粧品サイトが出てきた)や、都度検索する読み物系(How toとか)は、いちいちアプリをインストールすることは少ないと思う。

リアルイベントでのキャンペーンもQRなり、検索ワードなどでインストール不要でアクセスできるWEBアプリの方が便利そう。

また中国での成功事例もEC系が多い。

今回作っている履歴書アプリも一般的にはたまにしか使わないはずなので、WEB検索で使ってもらって、気に入ったら転職活動中だけインストールして使う、みたいな感じで使えるので、ちょいどいい用途だと感じた。

新規サービス系

PWAというかWEBアプリが圧倒的に強いのは開発コストの低さだと思う。私もずっとHTMLを書いたり、jQuery使ったり、WordPressを使ったり、Djangoやったりしてきた人間なので、これが一番恩恵がでかい。

WEB系の開発環境は一番進んでると思うし、なにしろ今までの言語(HTML、CSS、JS)や環境(サーバー、ブラウザ)がそのまま使えるのだから。

まだ何をどう作ればいいかわからない、必要とされるかすら分からない新規サービスでは、限られた予算と時間でiPhoneもAndoroidもPCも同時になんてのは現実的ではない。

その点WEBアプリなら快適さや利便性はそれぞれのネイティブには及ばないとしても、プロトタイプとしては十分以上のものが作れるし、スマホがターゲットならIEを無視して、あまり機種差も気にしなくていい。

さらに変更も、アプリストアの審査も不要でこちらの都合で更新ができ(SWのキャッシュは気をつけないといけないかも)

そして、もしネイティブであることのメリットが少ないサービスであれば、そのまま成功するサービスが日本でも出てくるかもしれない。

まとめ

数日間に分けて、パラパラと書いたのでまとまりがない感じになったけど、PWAもネイティブアプリも、それぞれの長所を生かし、新しい技術を使ってユーザーにとっていいものを作ればいいよねが一点。

あとWEB系開発者としてはWEB開発は自由で快適で面白くて速いから、ブラウザにはどんどん進んでいってほしいなと思いました。

ついにGCPにマネージドRedisがきたーーーー

GCPのアンケートが来るたびにRedisが欲しいと書き続けていた甲斐があったのか、ついにはじまった。
まだベータ。

cloud.google.com

気になる価格は一番高い1-4GBで

$0.049 per GB-hr

割引無しで考えると月で

$0.049 * 24 * 30 = 35.28ドル

たぶんUSでの値段。

新規サービスにはちょっと高い気がするから、本番だけ使って、開発環境とかはGKEで立てて使う感じになるかな