ReactとGoogle Sign-In

Google Sign-In をReactなSPAで使った時の日記です。

サーバーとの連携やセッションの管理について少し悩んだのでメモっておきます。ちょっと長くなりそうなので何回かに分けます。

Google Sign-In

Google Sign-Inと言っているのはこれのこと。 developers.google.com

こちらにしたがって、Google Cloud Platformの認証情報を作成し、クライアントIDを取得する。「承認済みの JavaScript 生成元」として localhost:3000 を指定しておく。

取得できたら早速使ってみる。まずはReactなしで試す。必要なのは以下の4つ。

  • metaタグに取得したクライアントIDを指定する
  • https://apis.google.com/js/platform.js を読み込むscriptタグを用意する
  • g-signin2 というclass属性のついたdivタグを用意する
  • 上のdivタグに data-onsuccess 属性でログイン成功時のコールバックを指定する
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>sample</title>
  <meta name="google-signin-client_id" content="<CLIENT_ID>">
  <script src="https://apis.google.com/js/platform.js" async defer></script>
</head>
<body>
ようこそ!
<div class="g-signin2" data-onsuccess="onSignIn"></div>
<script>
  function onSignIn(googleUser) {
    var profile = googleUser.getBasicProfile();
    console.log('ID: ' + profile.getId());
    console.log('Name: ' + profile.getName());
    console.log('Image URL: ' + profile.getImageUrl());
    console.log('Email: ' + profile.getEmail());
  }
</script>
</body>
</html>

<CLIENT_ID>Google Cloud Platformのコンソールで取得した値にすること。適当なWebサーバーを3000ポートで起動しブラウザで localhost:3000 にアクセスする。

https://i.gyazo.com/554cfc0cfca3bb9bb1d49c76f92648f0.png

ログインボタンをクリックしてGoogleアカウントでログインする。ブラウザのコンソール出力を見ると以下のような出力が。

ID: 111
Name: hoge
Image URL: https://lh4.googleusercontent.com/fuga/photo.jpg
Email: foo.bar@gmail.com

おぉー、簡単!

Reactと一緒に使う

ReactアプリケーションでGoogle Sign-Inを使ってみる。

create-react-appでアプリケーションの雛形を作成する。使った create-react-app のバージョンは

$ create-react-app --version
1.5.2

package.jsonのdependencies。

  "dependencies": {
    "react": "^16.3.0",                      
    "react-dom": "^16.3.0",          
    "react-scripts": "1.1.1"                            
  },

雑にさっきのコードをsrc/App.jsに移植する。jsxでは class 属性は className と書くのでそこは直しておく。

class App extends Component {
  onSignIn = (googleUser) => {
    var profile = googleUser.getBasicProfile();
    console.log('ID: ' + profile.getId());
    console.log('Name: ' + profile.getName());
    console.log('Image URL: ' + profile.getImageUrl());
    console.log('Email: ' + profile.getEmail());
  }
  render() {
    return (
      <div>
        ようこそ!
        <div className="g-signin2" data-onsuccess="onSignIn"></div>
      </div>
    )
  }
}

$ npm start して、 localhost:3000 にアクセス。

https://i.gyazo.com/97aa819625a7923ef60634e2fcd448eb.png

む、ログインボタンが表示されない。metaタグとscriptタグを追加していなかった。public/index.html にmetaタグとscriptタグを追加する。

--- a/public/index.html
+++ b/public/index.html
@@ -19,6 +19,8 @@
       work correctly both with client-side routing and a non-root public URL.
       Learn how to configure a non-root public URL by running `npm run build`.
     -->
+    <meta name="google-signin-client_id" content="<CLIENT_ID>">
+    <script src="https://apis.google.com/js/platform.js" async defer></script>
     <title>React App</title>
   </head>
   <body>

https://i.gyazo.com/554cfc0cfca3bb9bb1d49c76f92648f0.png

表示された。ログインしてブラウザのコンソール出力を見ると...

何も表示されない。 data-onsuccess="onSignIn" で指定したコールバックが呼ばれないですね。まぁそりゃそうだなぁ。

react-google-login

どうしよっかなぁと思ってGitHubを眺めてるとreact-google-loginというのを発見。

github.com

試してみたけど、ログインボタンをrenderするときにクライアントIDを指定する方式になっていて、ログイン済みの状態でアクセスした時にログアウトボタンだけ欲しい場合に都合が悪かったので使わないことにする。

JavaScriptコードからログインボタンを作成する

Googleのドキュメントを読んでみると、自分のJavaScriptコードでログインボタンを作成できることが分かる。この時にコールバックを指定できる。この方法を使えば以下のような感じでログインボタンを描けそう。

gapi.signin2.render('google-signin-button', {
  'onsuccess': this.onSignIn,
  'onfailure': (err) => console.error(err)
});

さらにリファレンスを見ると、metaタグでclient_idを指定してたけど、これもJavaScriptで指定できるようだ。

gapi.load('auth2', () => {
  gapi.auth2.init({client_id: <CLIENT_ID>})
    .then(
      (result) => console.log(result),
      (err) => console.error(err)
    );
})

これらを使って、 initSignInButton メソッドを追加する。

--- a/src/App.js
+++ b/src/App.js
@@ -1,6 +1,20 @@
 import React, { Component } from 'react';
 
 class App extends Component {
+  initSignInButton = (gapi) => {
+    gapi.load('auth2', () => {
+      gapi.auth2.init({client_id: <CLIENT_ID>})
+        .then(
+          (result) => {
+            gapi.signin2.render('google-signin-button', {
+              'onsuccess': this.onSignIn,
+              'onfailure': (err) => console.error(err)
+            });
+          },
+          (err) => console.error(err)
+        );
+    })
+  }
   onSignIn = (googleUser) => {
     var profile = googleUser.getBasicProfile();
     console.log('ID: ' + profile.getId());
@@ -26,7 +42,7 @@ class App extends Component {
     return (
       <div>
         ようこそ!
-        <div className="g-signin2" data-onsuccess="onSignIn"></div>
+        <div id='google-signin-button'></div>
       </div>
     )
   }

あとは gapi オブジェクトをどうやって取得するか。Googleのドキュメントのサンプルにある <script src="https://apis.google.com/js/platform.js?onload=renderButton" async defer></script> というところ。scriptタグでGoogleのjsファイルを読み込んでるところをどう書き直そう(npmになさそう・・・)。先ほどの react-google-login のコードを調べてみると、DOMをごにょごにょしてscriptタグを動的に書き込んでた。それを真似する。

--- a/src/App.js
+++ b/src/App.js
@@ -1,6 +1,19 @@
 import React, { Component } from 'react';
 
 class App extends Component {
+  componentDidMount() {
+    this.downloadGoogleScript(this.initSignInButton)
+  }
+  downloadGoogleScript = (callback) => {
+    const element = document.getElementsByTagName('script')[0];
+    const js = document.createElement('script');
+    js.id = 'google-platform';
+    js.src = '//apis.google.com/js/platform.js';
+    js.async = true;
+    js.defer = true;
+    element.parentNode.insertBefore(js, element);
+    js.onload = () => callback(window.gapi);
+  }
   initSignInButton = (gapi) => {
     gapi.load('auth2', () => {
       gapi.auth2.init({client_id: <CLIENT_ID>})

public/index.html に追記したタグは不要になったので消す。

--- a/public/index.html
+++ b/public/index.html
@@ -19,8 +19,6 @@
       work correctly both with client-side routing and a non-root public URL.
       Learn how to configure a non-root public URL by running `npm run build`.
     -->
-    <meta name="google-signin-client_id" content="<CLIENT_ID>"
-    <script src="https://apis.google.com/js/platform.js" async defer></script>
     <title>React App</title>
   </head>
   <body>

localhost:3000 にアクセスして、ログインしてみると...うまくいった!

おしまい

次回は、react-routerでログイン前と後で画面分けたりする、予定。

最終的に create-react-app した後に編集したのは src/App.js だけ。

JUnit実践入門のMockitoの部分をJMockit (version 1.37) でやってみた

Java アドベントカレンダー 2017 5日目の記事です。

以前書いた記事のJMockit のバージョンが古くていつか更新したいなぁと思っていたので更新しました!

bati11blog.hatenablog.com

この記事は、JUnit実践入門のMockitoについて説明してる部分をJMockitでやってみました、という内容です。

JMockitJUnitなどでテストを書くときに利用できるモックライブラリです。今回使ったJMockitのバージョンは1.37、JUnitのバージョンは4.12です。

準備

build.gradleに書きます。

testCompile 'org.jmockit:jmockit:1.37'
testCompile 'junit:junit:4.12'

書く順番に要注意なんてことが書いてあるので気をつけましょう・・・。

http://jmockit.org/gettingStarted.html#library

あとは、JUnitのテストクラスに @RunWith(JMockit.class) をつけます。

公式のチュートリアルはこちら。

http://jmockit.org/tutorial.html

スタブメソッドの定義

早速、以前のバージョンと違いがあります。 NonStrictExpectations がなくなったようです。代わりっぽいのがないので Expectations を使います。

    @Mocked
    List<String> stub;

    @Test
    public void スタブメソッドの定義() {
        new Expectations() {{
            stub.get(0); result = "Hello"; // スタブメソッドの定義
        }};
        assertEquals(stub.get(0), "Hello");
    }

例外を送出するスタブメソッド

以前は NonStrictExpectations を使ったサンプルコードを書いてました。 NonStrictExpectations の場合は呼び出し回数まで厳密に見ないのですが、 Expectations は定義したスタブ実装が呼び出されないとテストが失敗してしまいます。そのため、以下のテストは失敗します。

    @Test(expected = IndexOutOfBoundsException.class)
    public void 例外を送出するスタブメソッド() throws Exception {
        new Expectations() {{
            // NonStrictExpectationsが使えなくなった
            // Expectationsは定義した内容が呼ばれないとテストが失敗してしまう
            stub.get(0); result = "Hello";
            stub.get(1); result = "World";
            stub.get(2); result = new IndexOutOfBoundsException();
        }};
        stub.get(2);
    }

失敗した時のメッセージは以下のようになります。

Caused by: Missing 1 invocation to:
java.util.List#get(0)
   on mock instance: $Impl_List@5a39699c
instead got:
java.util.List#get(2)
    at SampleTest$2.<init>(SampleTest.java:38)
    at SampleTest.例外を送出するスタブメソッド(SampleTest.java:33)
Caused by: Missing invocations
    at SampleTest$2.<init>(SampleTest.java:36)
    at SampleTest.例外を送出するスタブメソッド(SampleTest.java:33)
Caused by: java.lang.IndexOutOfBoundsException
    at SampleTest.例外を送出するスタブメソッド(SampleTest.java:40)

以下のように、呼び出さないスタブ実装を消してあげればテストが通ります。

    @Test(expected = IndexOutOfBoundsException.class)
    public void 例外を送出するスタブメソッド() throws Exception {
        new Expectations() {{
            // NonStrictExpectationsが使えなくなった
            // Expectationsは定義した内容が呼ばれないとテストが失敗してしまう
//            stub.get(0); result = "Hello";
//            stub.get(1); result = "World";
            stub.get(2); result = new IndexOutOfBoundsException();
        }};
        stub.get(2);
    }

void型を返すスタブメソッド

    @Test(expected=RuntimeException.class)
    public void メソッドの戻り値がvoidのスタブメソッド() {
        new Expectations() {{
            stub.clear(); result = new RuntimeException();
        }};
        stub.clear();
    }

任意の引数に対するスタブメソッド

メソッドの引数にスタブオブジェクトを指定する方式に書き換えます。以前は引数に指定する場合は @Mocked アノテーションがなくても大丈夫だったのですが、

    @Test
    public void 任意の整数に対するスタブメソッド(/* Mockedアノテーションが必要 */ @Mocked final List<String> stub) {
        new Expectations() {{
            stub.get(anyInt); result = "Hello";
        }};
        assertEquals(stub.get(0), "Hello");
        assertEquals(stub.get(1), "Hello");
        assertEquals(stub.get(999), "Hello");
    }

ちなみに @Mocked をつけなかった時のメッセージは以下なので、気がつかないとハマりそうです。。

java.lang.Exception: Method 任意の整数に対するスタブメソッド should have no parameters

スタブメソッドの検証

特に変わらず Verifications は使えました。

    @Test
    public void スタブメソッドの検証(@Mocked final List<String> mock) {
        mock.clear();
        mock.add("Hello");
        mock.add("Hello");
        new Verifications() {{
            mock.clear(); times = 1;
            mock.add("Hello"); times = 2;
            mock.add("World"); times = 0;
        }};
    }

Exceptations を使う方法も変わらず。

    @Test
    public void スタブメソッドの検証_Exceptationsで指定(@Mocked final List<String> mock) {
        new Expectations() {{
            mock.clear();      times=1;
            mock.add("Hello"); times=2;
            mock.add("World"); times=0;
        }};
        mock.clear();
        mock.add("Hello");
        mock.add("Hello");
    }

部分的なモックオブジェクト

これがうまくいきませんでした! ArrayListsize() だけ書き換えたい場合です。

以下のページを読むとこれで良さそうなのですが、 StackOverflowError が起きてしまいました。原因が分からず・・・。

http://jmockit.org/tutorial/Faking.html

    @Test
    public void 部分的なモックオブジェクト() {
        new MockUp<ArrayList<String>>() {
            @Mock
            public int size() {
                return 100;
            }
        };
        final List<String> mock = new ArrayList<>();

        mock.add("Hello");
        assertEquals(mock.get(0), "Hello");
        assertEquals(mock.size(), 100);
    }

2017/12/13 追記

ちなみに、ArrayListではなく、例えばStackを使った場合はうまくいきました。。

    @Test
    public void 部分的なモックオブジェクト() {
        new MockUp<Stack<String>>() {
            @Mock
            public int size() {
                return 100;
            }
        };
        final Stack<String> mock = new Stack<>();

        mock.push("Hello");
        assertEquals(mock.firstElement, "Hello");
        assertEquals(mock.size(), 100);

スパイオブジェクト

こちらは以前から特に変わらず。

    private static class SpyExample {
        Logger logger = Logger.getLogger(this.getClass().getName());
        public void doSomething() {
            logger.info("doSomething");
        }
    }

    @Test
    public void JMockitのMockUpAPIを使ったテスト() {
        // Setup
        SpyExample sut = new SpyExample();
        final StringBuilder infoLog = new StringBuilder();
        new MockUp<Logger>() {
            @Mock
            public void info(Invocation invocation, String message) {
                infoLog.append(message);
                invocation.proceed();
            }
        };
        sut.logger = Logger.getLogger(this.getClass().getName());

        // Exercise
        sut.doSomething();

        // Verify
        assertEquals(infoLog.toString(), "doSomething");
    }

おしまい

うーむ、部分的に書き換える方法がうまくいかないのは悔しいですね。また時間を見つけて調べてみたいと思います!

MobX + Babel + webpack + Flow

地元の勉強会のための準備です。

yokohama-north.connpass.com

前提

$ node -v
v8.2.1
$ npm -v
5.3.0

準備。

$ npm init

MobX

インストールします。

$ npm i mobx -D

MobXを使ったストアのサンプル。このチュートリアル分かりやすいです。

MobX: Ten minute introduction to MobX and React

import { autorun, observable, action, computed } from 'mobx';

export default class TodoStore {
  @observable todos = [];
  @observable pendingRequests = 0;

  constructor() {
    autorun(() => console.log(this.report));
  }

  @computed get completedTodosCount() {
    return this.todos.filter(
      todo => todo.completed === true
    ).length;
  }

  @computed get report() {
    if (this.todos.length === 0) {
      return "<none>";
    } else {
      return `Next todo: "${this.todos[0].task}". ` +
        `Progress: ${this.completedTodosCount}/${this.todos.length}`;
    }
  }

  @action addTodo(task) {
    this.todos.push({
      task, completed: false, assignee: null
    });
  }
}

デバッガー

mobx-remotedevを使います。

GitHub - zalmoxisus/mobx-remotedev: MobX DevTools extension

上のリンク先にも書いてある通り、まずはChrome拡張機能である redux-devtools-extension をインストールします。

GitHub - zalmoxisus/redux-devtools-extension: Redux DevTools extension.

mobx-remotedevをインストールします。

$ npm i mobx-remotedev -D

TodoStoreに @remotedev を追記します。

import remotedev from 'mobx-remotedev/lib/dev';

@remotedev
export default class TodoStore {
  ・・・

babel

上のコードには、export、class、import、getter、デコレータなどが使われています。当然このままではブラウザでは動かないので、Babelでコンパイルする必要があります。

$ npm i babel-cli babel-preset-es2015 babel-preset-stage-1 babel-plugin-transform-decorators-legacy babel-plugin-transform-class-properties -D

.babelrcを用意します。

{
  "presets": [
    "es2015",
    "stage-1"
  ],
  "plugins": [
    "transform-decorators-legacy",
    "transform-class-properties"
  ]
}

package.jsonbuild タスクを定義してbabelを実行するようにします。

"scripts": {
  "build": "babel src -d dist"
}

これでコンパイルできます。

$ npm run build

webpack

次は、webpackを使って依存関係を解決したjsファイルを作ります。

$ npm i webpack babel-loader -D

loaderとして babel-loader を使用する。 .babelrc を削除して webpack.config.js を作る。

module.exports = {
  context: __dirname + "/src",
  entry: {
    "application": "./index.js"
  },
  output: {
    path: __dirname + "/dist",
    filename: "[name].js"
  },
  module: {
    rules: [
      {
        exclude: /node_modules/,
        loader: require.resolve("babel-loader"),
        options: {
          plugins: [
            "transform-decorators-legacy",
            "transform-class-properties"
          ],
          presets: ["es2015", "stage-1"]
        }
      }
    ]
  }
}

webpackのentryとなるjsファイルを用意する。

import TodoStore from './TodoStore';

const observableTodoStore = new TodoStore();

window.observableTodoStore = observableTodoStore;

package.jsonの build をbabelからwebpackを使うように変更する。

"scripts": {
  "build": "webpack"
}

これでBabelを使ってコンパイルして、webpackを使って依存関係を解決したJavaScriptファイルを作成できる。

$ npm run build

おしまい

dist/application.js として出力されるので、これをhtmlで読み込めばTodoStoreを使えます!

package.jsonはこんな状態。

{
  "name": "mobx-todo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "mobx": "^3.2.1",
    "babel-cli": "^6.24.1",
    "babel-loader": "^7.1.1",
    "babel-plugin-transform-class-properties": "^6.24.1",
    "babel-plugin-transform-decorators-legacy": "^1.3.4",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-stage-1": "^6.24.1",
    "mobx-remotedev": "^0.2.8",
    "webpack": "^3.3.0"
  }
}

Flow (おまけ)

Flowで型チェックしてみる。

$ npm i flow-bin flowtype babel-preset-flow -D

TodoStoreを書き換える。

// @flow
import { autorun, observable, action, computed } from 'mobx';
import remotedev from 'mobx-remotedev/lib/dev';

type Task = {
  task: string,
  completed: boolean,
  assignee: ?string,
}

@remotedev
export default class TodoStore {
  @observable todos: Array<Task> = [];
  @observable pendingRequests: number = 0;

  constructor() {
    autorun(() => console.log(this.report));
  }

  @computed get completedTodosCount(): number {
    return this.todos.filter(
      todo => todo.completed === true
    ).length;
  }

  @computed get report(): string {
    if (this.todos.length === 0) {
      return "<none>";
    } else {
      return `Next todo: "${this.todos[0].task}". ` +
        `Progress: ${this.completedTodosCount}/${this.todos.length}`;
    }
  }

  @action addTodo(task: string): void {
    this.todos.push({
      task, completed: false, assignee: null
    });
  }
}

webpack.config.jsを書き換える。

@@ -15,9 +15,10 @@ module.exports = {
         options: {
           plugins: [
             "transform-decorators-legacy",
-            "transform-class-properties"
+            "transform-class-properties",
+            "transform-flow-strip-types"
           ],
-          presets: ["es2015", "stage-1"]
+          presets: ["es2015", "stage-1", "flow"]
         }
       }
     ]

package.jsonにflowタスクを追加します。

"scripts": {
  "build": "webpack",
  "flow": "flow"
}

Flowで型チェック

$ npm run flow