お題
前回の記事でFirebaseAuthを使った単純なSignIn/Outを実装した。
今回は、SignUp(つまり、FirebaseAuth管理ユーザの新規登録)を実装する。
前回はFirebaseAuthのコンソール上で事前にログイン用ユーザを作成したけど、例えばある種の管理画面などではログイン可能なユーザの作成機能を持たせたりするものなので。
SignIn/OutはJavaScriptのSDKで実装したのでSignUpも合わせようと思うところだけど、JavaScriptのSDKでSignUpを行うと**作った途端にそのユーザで自動的にログインした状態になるので、これは使えない。
(つまり、ユーザ作成権限を持つ”ユーザA”でログインして”ユーザB”を作ると、その時点で(”ユーザA”でログインしていたのに)Firebase上は”ユーザB”でログインした状態に変わってしまう。)
なので、SignUpはサーバーサイド側で行う。今回はWebAPIとしてGoを使っているので、Go用のSDKでユーザ作成**機能を実装する。
FirebaseAuth+Nuxt.js+Go(v1.11)+GAE開発シリーズIndex
- 第3回「FirebaseAuthによるログイン・ログアウト導入」
- 第2回「FirebaseAuth導入前(ログインフォーム実装とバックエンドプロジェクトのガワ作成)」
- 第1回「FirebaseAuth導入前(フロントエンドプロジェクトのガワ作成)」
前提
以下は他にいくらでもよい記事があるので省略。
- 開発環境の構築(GolangやらYarnやらのインストール等)
- Google Cloud Platform環境の取得(App Engine有効化)
- Firebase環境の取得
- Vue.jsやNuxt.jsのチュートリアル
開発環境
# OS
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.1 LTS (Bionic Beaver)"
# 依存モジュールのバージョン
package.json
の内容より、以下の通り。
"nuxt": "^2.3.4",
"vuetify": "^1.3.14",
"vuetify-loader": "^1.0.8",
"@nuxtjs/axios": "^5.3.6"
# Yarn
$ yarn -v
1.12.3
# Golang
$ go version
go version go1.11.4 linux/amd64
実践
■ソース全量
今回作ったソースの全量は下記。
https://github.com/sky0621/Dotato-di-una-libreria/tree/faa9e6567718a7ed69847593315b950d0d7e5dca
■フロントエンドにユーザ作成フォームを実装
ユーザ作成画面の見た目
こんな感じにしてみた。
Vuetifyを使っているので、ほとんど手間をかけずともそれなりの画面が作れる。
あと、バリデーションはVuelidateを使った。
ソースは後ほど。
画面表示に関する部分のソース
/frontend/pages/signup.vue
pagesに「ユーザ作成画面」としてのテンプレートを書き、「ユーザ作成フォーム」の部分だけコンポーネントとして切り出し。
(今後、新規作成と変更とを分けて、フォーム部分を流用することを想定してみたけど、実際のところは、そのときになってみないと、この判断が正しかったかはわからない。。。)
<template>
<v-container fluid>
<v-layout
align-start
justify-start
column
>
<v-flex>
<v-label>ユーザー登録</v-label>
</v-flex>
<v-flex>
<signup />
</v-flex>
</v-layout>
</v-container>
</template>
/frontend/components/signup.vue
上記pagesのsignup.vue
で「<signup />
」としていた箇所の中身に当たる。
<template>
<form>
<v-text-field
v-model="name"
:error-messages="nameErrors"
:counter="10"
label="ニックネーム"
required
@input="$v.name.$touch()"
@blur="$v.name.$touch()"
/>
<v-text-field
v-model="email"
type="email"
:error-messages="emailErrors"
label="メールアドレス"
required
@input="$v.email.$touch()"
@blur="$v.email.$touch()"
/>
<v-text-field
v-model="password"
type="password"
:error-messages="passwordErrors"
label="パスワード"
required
@input="$v.password.$touch()"
@blur="$v.password.$touch()"
/>
<v-btn
class="lime lighten-2"
@click="submit"
>
登録
</v-btn>
</form>
</template>
フォームのロジックに関する部分のソース
以降、フォーム内の3つの要素「ニックネーム」、「メールアドレス」、「パスワード」及び「登録」ボタンそれぞれについて、1要素ずつテンプレートとロジックを抜粋して説明。
抜粋しない完全版のソースは下記参照。
https://github.com/sky0621/Dotato-di-una-libreria/blob/faa9e6567718a7ed69847593315b950d0d7e5dca/frontend/components/signup.vue
●「ニックネーム」テキストフィールド
<template>
<form>
<v-text-field
v-model="name"
:error-messages="nameErrors"
:counter="10"
label="ニックネーム"
required
@input="$v.name.$touch()"
@blur="$v.name.$touch()"
/>
</form>
</template>
<script>
import { required, maxLength } from 'vuelidate/lib/validators'
export default {
validations: {
name: { required, maxLength: maxLength(10) },
},
data() {
return {
name: '',
}
},
computed: {
nameErrors() {
const errors = []
if (!this.$v.name.$dirty) return errors
!this.$v.name.maxLength && errors.push('ニックネームは10文字以内にしてください')
!this.$v.name.required && errors.push('ニックネームは必須です')
return errors
},
},
}
</script>
テーマ外なのでバリデーションの具体的な仕組みについては解説なしで。↓参照。
https://monterail.github.io/vuelidate/#getting-started
ともあれ、上記のように実装すると以下のように入力状態に応じてチェックしてくれる。
●「メールアドレス」テキストフィールド
<template>
<form>
<v-text-field
v-model="email"
type="email"
:error-messages="emailErrors"
label="メールアドレス"
required
@input="$v.email.$touch()"
@blur="$v.email.$touch()"
/>
</form>
</template>
<script>
import { required, email } from 'vuelidate/lib/validators'
export default {
validations: {
email: { required, email },
},
data() {
return {
email: '',
}
},
computed: {
emailErrors() {
const errors = []
if (!this.$v.email.$dirty) return errors
!this.$v.email.email && errors.push('適切なメールアドレスを入力してください')
!this.$v.email.required && errors.push('メールアドレスは必須です')
return errors
},
},
上記のように実装すると以下のように入力状態に応じてチェックしてくれる。
●「パスワード」テキストフィールド
<template>
<form>
<v-text-field
v-model="password"
type="password"
:error-messages="passwordErrors"
label="パスワード"
required
@input="$v.password.$touch()"
@blur="$v.password.$touch()"
/>
</form>
</template>
<script>
import { required, minLength } from 'vuelidate/lib/validators'
export default {
validations: {
password: { required, minLength: minLength(8) }
},
data() {
return {
password: ''
}
},
computed: {
passwordErrors() {
const errors = []
if (!this.$v.password.$dirty) return errors
!this.$v.password.minLength && errors.push('パスワードは8文字以上にしてください')
!this.$v.password.required && errors.push('パスワードは必須です')
return errors
}
},
}
</script>
上記のように実装すると以下のように入力状態に応じてチェックしてくれる。
●「登録」ボタン
this.$v.$touch()
でバリデーションを発動させ、if (this.$v.$invalid) return
により、チェックに引っかかったら処理中断。
あとは、バックエンド側のAPI(パス:/users
)にフォームの中身をPOSTするだけ。
<template>
<form>
<v-btn
class="lime lighten-2"
@click="submit"
>
登録
</v-btn>
</form>
</template>
<script>
import * as types from "~/store/types";
export default {
data() {
return {
name: '',
email: '',
password: ''
}
},
methods: {
submit() {
this.$v.$touch()
if (this.$v.$invalid) return
console.log('validate success')
this.$axios
.post(process.env.apiBaseUrl + '/users', {
'name': this.name,
'email': this.email,
'password': this.password
})
.then((res) => {
console.log(res)
this.$store.dispatch(types.ACTIVATE_INFO_NOTIFICATION, {message: 'ユーザー「' + this.name + '」を登録しました。'});
this.$router.push('/')
})
.catch((err) => {
console.log(err)
this.$store.dispatch(types.ACTIVATE_ERROR_NOTIFICATION, {message: err});
})
}
}
}
</script>
●「通知」スナックバー
ユーザ登録成功後は、スナックバー(メッセージ:「ユーザー「○○」を登録しました。
」)を表示する。
ユーザ登録失敗時は、やはりスナックバー(メッセージ:サーバから帰ってきたエラーそのもの)を表示する。
<script>
methods: {
submit() {
this.$axios
.post(process.env.apiBaseUrl + '/users', { 〜〜省略〜〜 })
.then((res) => {
this.$store.dispatch(types.ACTIVATE_INFO_NOTIFICATION, {message: 'ユーザー「' + this.name + '」を登録しました。'});
this.$router.push('/')
})
.catch((err) => {
this.$store.dispatch(types.ACTIVATE_ERROR_NOTIFICATION, {message: err});
})
}
}
}
</script>
このスナックバーの表示内容はコンポーネントないしページをまたいで状態を共有するのでストアに定義。
import * as types from './types'
export const strict = false
export const state = () => ({
notification: {
activate: false,
message: '',
color: ''
}
})
export const getters = {
notification: (state) => {
return state.notification
}
}
export const mutations = {
setNotification(state, data) {
const cdata = _.clone(data)
state.notification = {
activate: cdata.activate,
message: cdata.message,
color: cdata.color
}
}
}
export const actions = {
// 通知を活性化する
activateNotification({ commit }, data) {
commit(types.SET_NOTIFICATION, { activate: true, message: data.message, color: data.color })
},
// INFO通知を活性化する
activateInfoNotification({ commit }, data) {
commit(types.SET_NOTIFICATION, { activate: true, message: data.message, color: 'green' })
},
// ERROR通知を活性化する
activateErrorNotification({ commit }, data) {
commit(types.SET_NOTIFICATION, { activate: true, message: data.message, color: 'red' })
},
// 通知を非活性化する
deactivateNotification({ commit }) {
commit(types.SET_NOTIFICATION, { activate: false, message: '', color: '' })
}
}
スナックバー自体を表示するコンポーネントは下記。
<template>
<!-- 通知用スナックバー -->
<v-snackbar
v-model="snackbar"
:color="color"
:timeout="timeout"
>
{{ message }}
<v-btn
dark
flat
@click="closeSnackbar"
>
閉じる
</v-btn>
</v-snackbar>
</template>
<script>
import * as types from '~/store/types'
export default {
data() {
return {
timeout: 5000,
snackbar: false,
message: '',
color: ''
}
},
computed: {
notification() {
return this.$store.getters[types.NOTIFICATION]
}
},
watch: {
notification(val) {
this.apply(val)
}
},
mounted() {
const val = this.$store.getters[types.NOTIFICATION]
this.apply(val)
},
methods: {
closeSnackbar() {
this.$store.dispatch(types.DEACTIVATE_NOTIFICATION)
},
apply(val) {
this.message = val.message
this.color = val.color
this.snackbar = val.activate
// TODO: スナックバーのタイムアウト発動タイミングをフックする方法が不明のため、苦肉の策で「同じ時間経過後にスナックバーを非活性化」する対応を仕込む
if (this.snackbar) {
console.log('setTimeout')
setTimeout(this.closeSnackbar, this.timeout)
}
}
}
}
</script>
これにより、ユーザー登録完了後は、トップページ(お知らせ画面)に遷移して、↓のようなスナックバーが表示される。
また、エラーが発生した場合は、ユーザー登録画面上で↓のようなスナックバーが表示される。
(※FirebaseAuth上に登録済みのメールアドレスで再度登録を試みた結果)
■バックエンドにユーザ作成APIを実装
バックエンド側の構造は「MVC+S」としている。
各リクエストは controller
パッケージ下の機能別ソースで受け付けて、トランザクション境界を持つ service
パッケージ下の機能別ソースを呼び出す。
service
パッケージ下のソースは適宜、 model
パッケージ下のソースを組み合わせて要件を実現する。
前回まででログイン後のトップページに表示する「お知らせ」機能は実装済み。
今回同じような構成で「ユーザ管理」機能を実装する。
実装後のパッケージ構成(MVC+S部分のみ抜粋)
├── controller
│ ├── apierror.go
│ ├── form
│ │ ├── form.go
│ │ └── user.go
│ ├── notice.go
│ ├── response
│ ├── router.go
│ └── user.go
├── main.go
├── model
│ ├── dto.go
│ ├── notice.go
│ └── user.go
├── service
│ ├── notice.go
│ └── user.go
backend/controller/router.go
func main() {
〜〜省略〜〜
// https://echo.labstack.com/guide
e := echo.New()
〜〜省略〜〜
// ルーティング設定の起点
controller.Routing(e)
appengine.Main()
}
func Routing(e *echo.Echo) {
〜〜省略〜〜
http.Handle("/", e)
// 個人の認証を要するWebAPI用のルート
authGroup := e.Group("/api/v1")
〜〜省略〜〜
// 「ユーザ」機能のルーティング("/users")
HandleUser(authGroup)
}
backend/controller/user.go
func HandleUser(g *echo.Group) {
g.POST("/users", createUser)
}
func createUser(c echo.Context) error {
ctx := middleware.GetCustomContext(c)
user := &form.User{}
if errUser := parse(c, user); errUser != nil {
return c.JSON(http.StatusBadRequest, errorJSON(http.StatusBadRequest, errUser.Error()))
}
if err := service.NewUser(ctx, c.Request().Context()).CreateUser(user.ParseToDto()); err != nil {
return c.JSON(http.StatusBadRequest, errorJSON(http.StatusBadRequest, err.Error()))
}
return c.JSON(http.StatusOK, "{}")
}
backend/service/user.go
トランザクション境界を設け、最初にDBへのユーザー情報登録、成功したらFirebaseAuthのSDKを使ってFirebase上にユーザー作成という流れ。
〜〜インターフェース定義や構造体定義、New関数等は省略〜〜
func (s *userService) CreateUser(u *model.User) error {
tx := s.db.Begin()
defer func() {
if tx != nil {
db := tx.Commit()
if err := db.Error; err != nil {
s.lgr.Errorw("Transaction commit failed.", "error", err)
}
}
}()
u.ID = util.CreateUniqueID()
err := model.NewUserDao(s.lgr, tx, s.firebaseApp).CreateUser(u)
if err != nil {
tx.Rollback()
return err
}
fbAuth, err := s.firebaseApp.Auth(s.requestCtx)
if err != nil {
tx.Rollback()
return err
}
fbUser := &auth.UserToCreate{}
fbUser.Email(u.Mail)
fbUser.Password(u.Password)
_, err = fbAuth.CreateUser(s.requestCtx, fbUser)
if err != nil {
tx.Rollback()
return err
}
return nil
}
■動作確認
登録画面にて
ユーザー情報を入力して「登録」ボタン押下
トップページにて登録完了が通知される。
データベース上の登録を確認
mysql> select * from user where name='taro';
+----------------------------------+------+------------------+-------------+---------------------+-------------+---------------------+------------+
| id | name | mail | create_user | created_at | update_user | updated_at | deleted_at |
+----------------------------------+------+------------------+-------------+---------------------+-------------+---------------------+------------+
| c0a5867c55064ff4b8fb944e89770e82 | taro | taro@example.com | | 2019-02-11 16:09:10 | | 2019-02-11 16:09:10 | NULL |
+----------------------------------+------+------------------+-------------+---------------------+-------------+---------------------+------------+
1 row in set (0.00 sec)
FirebaseAuth上も登録を確認
まとめ
現在ログインしているユーザーとは別の新規ユーザーをFirebase上に登録することができた。
ユーザー管理系に触れたので、次回以降は、ユーザーの削除や、メールアドレス、パスワードの変更などを試していきたい。
が、その前に、
今の実装では、ログイン機能はあっても、ログインしているかどうかノーチェックで画面遷移もAPIコールもできてしまう。
これでは実用には耐えられないので、Firebaseログイン後、JWTがもらえるので、それをもとにログインチェック機能を実装しよう。