EatSmartシステム部ブログ

ウェブサイトの開発や運営に関する情報です。

JAVAとReact

弊社のサービスは、主にサーバーサイドはJAVAServlet/JSP、フロントエンドはJQueryで作られている所が多いのですが、色々と新しいアーキテクチャを使えるよう取り組んでいます。

サーバーサイドについては、プラットフォームをdockerコンテナにすることにより、spring-bootを使ったりrubyやGo、node.jsなど他言語でサービスを作ったりしています。 フロントエンドも、全く新しく作るページについてはVue.jsで実装してみたりしているのですが、サービス本体のページに対してもモダンなスタイルのJavaScriptを使えないかと検討しています。

JavaScriptフレームワークの課題

モダンJSを使うにあたり、ReactやVue.js、Angularなどのフレームワーク利用を考えますが、弊社のサービス的には以下の課題があります。

  1. SEOを強化したいページについて、静的HTMLとしてコンテンツを出力したい
  2. ファーストビューの表示が遅いと、ユーザーが離脱してしまう

1については、GoogleはJSの解釈も行うと言われていますが、メインコンテンツを動的に出力しようという所までその話を信頼できないと思っています。

今回は、これらの課題に対応しつつJSフレームワークを使うために、Servlet/JSPとJSによるサーバーサイドレンダリングを組み合わせる方法を模索してみました。

JAVAでReactを実行

今回はJSフレームワークとして、Reactで試してみようと思います。 Servlet/JSPとReactの組み合わせ方として、

案1 別プロセスでJavaScriptを実行するnode.jsを動かし、ServletからHTMLを出力する時にnode.jsでのSSR結果をincludeする 案2 JAVAのプロセス内でReactのSSRを実行し、その結果をJSP内に埋め込む

の2通りを考えたのですが、できたら面白そうだなということで、案2を試してみました。

JAVAにはNashornというJSスクリプトエンジンがあるので、それを使ってReactを動かしてみます。

Reactテストアプリ作成

まず、npmでcreate-react-appをインストールし、

npm install -g create-react-app

次にReactのテストアプリを作ります。

$ create-react-app react-test

Creating a new React app in /Xxxxx/xxxx/react-test.

Installing packages. This might take a couple of minutes.
Installing react, react-dom, and react-scripts...

...

We suggest that you begin by typing:

  cd react-test
  yarn start

Happy hacking!

$ cd react-test
$ npm start

Starting the development server...

以上でブラウザが立ち上がり、Reactのサンプルページが出ます。

まず、ReactのサンプルページをSSRしてみます。

babelインストール

JAVAのNashornではJavaScriptは動かせますが、TypeScriptは解釈できません。ReactのサンプルページはTypeScriptで書かれているので、babelでTypeScriptからJavaScriptコンパイルします。

まずプロジェクトにbabel-cliとbabel-preset-reactをインストールします。

npm install --save-dev babel-cli babel-preset-react

次に、プロジェクトフォルダのpackage.jsonに以下を追加しました。

  "scripts": {
    "start": "react-scripts start",
+    "build": "babel src -d lib",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },

TypeScriptのビルド

これで、以下でビルドできました。

$ npm run build

> react-test@0.1.0 build /Xxxxx/xxxx/react-test
> babel src -d lib

src/App.js -> lib/App.js
src/App.test.js -> lib/App.test.js
src/index.js -> lib/index.js
src/jvm-npm.js -> lib/jvm-npm.js
src/serviceWorker.js -> lib/serviceWorker.js

node.jsのrequire

Nashornにはnode.jsのrequire構文は無いので、jvm-npmを使いました。

jvm-npm - npm

JAVAの実装

以上の環境を作ってから、JAVA上でReactを実行するコードを以下のようにしました。

public class ReactTest {

    public static void main(String[] args) throws Exception {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByMimeType("application/javascript");

        // requireを使用できるように
        engine.eval("load('/Xxxxx/xxxx/react-test/src/jvm-npm.js');");

        // node.jsのグローバル変数対応
        engine.eval("var console = {warn: function() {}};");
        engine.eval("var process = {};");
        engine.eval("process.env = {};");
        engine.eval("process.env.NODE_ENV = 'development';");
        engine.eval("var global = {};");
        engine.eval("global.window = {};");

        // スクリプト読み込み
        BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("/Xxxxx/xxxx/react-test/lib/server.js")));
        StringBuilder sb = new StringBuilder();
        String line = reader.readLine();
        while (line != null) {
          sb.append(line).append('\n');
          line = reader.readLine();
        }

        String script = sb.toString();
        Object ret = engine.eval(script);
        System.out.println(ret);
    }
}

実行

SSRを実行するために、index.jsを書き換えserver.jsとして以下のコードを作りました。

import 'core-js/es6/map';
import 'core-js/es6/set';
import React from 'react';
import './lib/index.css';
import App from './lib/App';
import { renderToString } from 'react-dom/server'

renderToString(<App />);

上記コードを実行すると、以下のHTMLが取得できました。

<div class="App" data-reactroot=""><header class="App-header"><p>Edit <code>src/App.js</code> and save to reload.</p><a class="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React</a></header></div>

これをServletで実行しJSPに埋め込めば、JAVA上でのReactのSSRを実現できると思います。

ところが…

Reactのサンプルは単純なHTMLの生成だけだったので、別のRESTのAPIをaxiosで呼び出しページに出力するサンプルを作ろうとしたのですが、Nashornでaxiosを使えるようにすることができませんでした…。 axiosをimportすると、どうしてもload moduleのエラーが出てしまいます。

しかも、そもそもNashornはECMAScriptの仕様に追いつけないために非推奨となり、将来的に廃止となるようです。

JavaでJavaScriptを実行する「Nashorn」が非推奨に、ECMAScriptの速い進化に追いつけないと。代替案はGraalVM - Publickey

なので、NashornでのSSRは諦めて、GraalJSで試す(本気か!)か上記案1(確実)でSSRしてみようかなと思います。

Javaのインターフェースを使った実装について

イートスマートの新人エンジニアが、Javaの開発業務の中でインターフェースを使った実装を行ったので学んだ内容を振り返ってみたいと思います。

Javaのインターフェースについて

インターフェースとは、それ自体には具体的な処理をもたせずに、別のクラスでメソッドを追加することで利用されるものです。そのため、インターフェースには変数とメソッドの型のみを定義しておきます。

実装する内容について

今回、スマートフォンのサイト側でもぐナビ(弊社が提供する食品の口コミサイト)で開催した「イベント」、「お知らせ」、「ニュース」と3つの異なる情報を一覧として表示させたいと思います。

インターフェース2.png

なぜインターフェースが必要になるのか

今回必要な情報は、
①出来事の開始日
②タイトル
③ラベル表示(イベント、お知らせ、ニュースの表示分け)
④遷移先URL
になります。
表示する内容は、「イベント」、「お知らせ」、「ニュース」で共通ですが元々3つのリソースで定義しているフィールド名が違うため(あるいは元々存在しない)、問題が生じます。上記タイトルで言えば「イベント」はeventTitleですが、「お知らせ」ではinformationTitleなどと同一の名前として、扱えないためそれらを共通化するような処理を挟みたいと考えます。 そこで、便宜的にIEventというインターフェースを作り、リソース間で生じる差異をなくし、共通のフォーマットに落とし込むことを実現します。

実装例

以下に実装例を示します。(※関係する部分のみを記載しています) 今回は、「イベント」情報のフォーマットに「お知らせ」情報も共通化させて、一覧に必要な情報(開始日、タイトル、ラベル、URL)を取得するという流れをみていきます。

IEvent.java(インターフェース)

public interface IEvent {

    public String getEventTitle();
  //タイトルを取得
    public String getEventUrl();
  //遷移先URLを取得
    public Date getInformationStartDate();
  //開始日を取得
    public String getEventLabel();
  //ラベルの色を取得
    public String getEventLabelClassName();
  //ラベル名を取得
}

InformationBean.java(お知らせ情報に関するBean)

import jp.mognavi.event.IEvent;

public class InfomationBean implements IEvent{
    /**implementsと記述し、インターフェースを実装*/
    private String informationTitle;
    private Date   informationStartDate;
    
    public String getInformationTitle() {
        return informationTitle;
    }
    public void setInformationTitle(String informationTitle) {
        this.informationTitle = informationTitle;
    }
    public Date getInformationStartDate() {
        return informationStartDate;
    }
  /**元々InformationStartDateという同一のフィールド名のため、以下のような
      オーバーライドの必要がない。 */
    public void setInformationStartDate(Date informationStartDate) {
        this.informationStartDate = informationStartDate;
    }
     /**以下では、IEventで定義したメソッドをオーバーライドし、お知らせのBeanの
  フィールド名に該当するものを返却している。*/
    @Override
    public String getEventTitle() {
        return informationTitle;
    }

    @Override
    public String getEventUrl() {
        return "/do/information/show/param/informationId/" + informationId;
    }
  
    @Override
    public String getEventLabel() {
        return "お知らせ";
    }

    @Override
    public String getEventLabelClassName() {
        return"infolabel";
    }
  /**※表示側のクラスにinfolabelという名前を付与することで、CSSで
  お知らせ用のカラーが当たるようにしている。 */
}

以上になります。インターフェース(IEvent)を作ることで、この先例えば「リリース」情報などが一覧に追加されても実装が容易になることが想像できます。 Javaのインターフェースは一見わかりにくい概念だと思うのですが、実際に使ってみるととても便利なものだと実感しました。

バックアップストレージの運用について

各システムのインフラ担当の方は、万が一の事態に備えバックアップの運用をされている事と思います。

バックアップ運用をするに辺り、どのデータを、何世代までバックアップするか等を検討されると思いますが、ストレージが無限に存在すれば、何世代でもバックアップ可能ですが現実はそうもいかないです。

また、出来る限りバックアップに掛けるランニングコストを抑えたいというニーズも多いのではないでしょうか?

そこで、弊社で実施している、低コストで世代バックアップ残す形のストレージ運用について紹介したいと思います。

バックアップストレージの構成

バックアップストレージを検討するに辺り、(S3、Googleドライブ、DropBox等)外部のストレージを検討してみましたが、低コストで柔軟に運用を行うことが困難だったので、弊社はオンプレミス構成を採用しました。

また、バックアップは日次で行っており、バックアップ対象は、サービス利用画像/日次DBスナップショット/日次ログ等さまざまですが、その中でも、最も大容量な日次DBスナップショットデータに着目し、以下の構成をとる事にしました。

構成図

f:id:eatsmart:20190314202659j:plain

補足
  • バックアップサーバー1は、内臓で2Tbyteのストレージが存在しており、直近2週間分の日次DBスナップショット等が存在している。

  • バックアップサーバー2は、「バックアップサーバー1」で直近2週分からあふれたデータを外付け(4TBbyte)に保存していく。(現状は約半年分位バックアップ可能)

  • バックアップサーバー2の外付け(4TBbyte)のデスク容量が逼迫した場合、新規にHDDを購入し、外付ドライブのHDDを交換する。

  • 万が一過去分が必要になった場合、外付けドライブに交換前のHDDを入れて参照する。

上記構成を採用したポイント

  • バックアップサーバー(ストレージ)は2つの物理構成を採用し、利用頻度が高い直近のデータをバックアップサーバー1に、利用頻度が低いデータをバックアップサーバー2に置く事にしました。そうする事で、HDDを交換した際にバックアップデータが全て無くなることを回避しました。

  • バックアップサーバー2に関しては、HDDの交換のしやすさを考慮し、外付けのストレージをを採用しました。 懸念点として上がったのが、外付けストレージの転送速度の問題でした。 転送速度が遅いと、ギガ単位のデータを転送するのに時間がかかってしまい運用が成り立ちません。 そこで、外付けストレージの接続にはeSATAを採用しました。

www.elecom.co.jp

ランニングコストについて

  • ストレージの費用ですが、半年に一度 3.5インチSATA HDD(2Tbye×2)を購入しており、1万5千円前後かかります。

  • ストレージの交換作業ですが、こちらも半年に一度 1時間程度で交換可能です。

最後に

バックアップの運用は各システム構成に依存する為、一概には適用できないかと思いますが、何かの参考になれば幸いです。

PostgreSQLの関数でパラメータのチェックを行う

引き続きSQL関連のネタを書いてみたいと思います。

Javaでクラス外から参照出来るpublicなメソッドを作成する時は、値のチェックとエラーメッセージを行うように心がけています。 これは、クライアントへ想定外の利用を正しく伝えるためです。

public String doSomething(int id, String name) {
  if (id <= 0) {
    throw new IllegalArgumentException("idは0以上を指定して下さい。id:" + id);
  }
  if (name == null || name.isEmpty()) {
    throw new IllegalArgumentException("nameが空です。name:" + name);
  }
  if (name.length() < 8) {
    throw new IllegalArgumentException("nameは8文字以上を指定して下さい。name:" + name);
  }
  ....
}

正常な処理が出来ないことを伝える方法としてこの場合nullを返す等も考えられますが、クライアント側で正常な値であるか判別する必要があります。 仮に判別を行わなかった場合、nullが値として利用されてしまい思わぬ障害を引き起こす可能性があります。 この例ではIllegalArgumentExceptionをスローしていますが、独自の例外を定義する場合もあると思います。

これと同様のことを、SQLの関数で実装してみました。

CREATE OR REPLACE FUNCTION do_something(id NUMERIC, name VARCHAR)
RETURNS CHARACTER VARYING
AS '
BEGIN
  IF id <= 0 THEN
    RAISE EXCEPTION ''idは0以上を指定して下さい。id:%'', id;
  END IF;
  IF name IS NULL OR LENGTH(name) <= 8 THEN
    RAISE EXCEPTION ''nameは8文字以上を指定して下さい。name:%'', name;
  END IF;
  ....
END;
'
language 'plpgsql';

値のチェックを行い、"RAISE EXCEPTION"でエラーメッセージを出力します。 Javaの実装と若干異なりますが、引数の範囲のチェックを行っています。

実行すると、以下のようになります。

postgres=> select do_something(1, '1234');
ERROR:  nameは8文字以上を指定して下さい。name:1234

SQLでエラーが発生することで、トランザクションロールバックさせる対処を取ることが出来ます。 これで、意図しない処理が行われることが無くなります。

PostgreSQLのPL/pgSQLで複数行の戻り値を返す

SQL関連の記事が続いているので、今回もそれに乗っかってみました。

サーバーサイドの実装をしていると、少しややこしいが共通的な処理について、どこに実装するのが良いか迷う時があります。

基本的にはビジネスロジックに関する内容であれば、アプリケーションサーバーで実装し、共通的な処理だったらモジュール化してサーバーへデプロイして使います。 ただ、プログラムからだけでなくSQLからも使用する場合や、処理速度のためにアプリケーションサーバーとDB間の通信を減らしたい場合などは、ビジネスロジックに深く関わらない内容なら、DBにViewや関数として実装することもあります。

今回は、弊社で使用しているPostgreSQLPL/pgSQL関数で複数行の結果を返す処理をを実装し、プログラムからは通常のテーブルをSELECTするように呼び出して使用した例を紹介します。

関数の作成

PL/pgSQLの関数は、以下の様に作成します。

CREATE OR REPLACE FUNCTION func_test(p_in_param varchar)
    RETURNS TABLE(
        id numeric(13,0),
        name varchar(128)
    ) AS $$
declare
    cur_food cursor(p_param varchar) for
        SELECT * FROM FOOD_TABLE WHERE FOOD_NAME ~ p_param
        ;
begin
    FOR rec_food IN cur_food(p_in_param) LOOP
        id := rec_food.food_id;
        name := rec_food.food_name;
        RETURN NEXT;
    END LOOP;
    RETURN;
end;
$$ LANGUAGE plpgsql;

ポイントは

  • RETURNS TABLE句

戻り値として複数の項目(列)を返すために、RETRUNS TABLEとして、戻り値の構造体を定義します。

  • RETURN NEXT句

RETURN TABLEで宣言した変数に値を入れてRETURN NEXTを呼ぶと、その列を戻り値として返すことができます。 この例では、カーソルでループする回数だけRETURN NEXTを呼ぶので、その件数の戻り値が返ります。

例では取得した値をそのまま戻り値の変数に入れていますが、ここに複雑な処理を実装することで、Viewでは複雑になりすぎるものも実装することができます。

クエリの実行

以下の様にSELECT句のFROMに関数を指定することで、クエリを実行できます。

database=> select * from func_test('ご飯');
   id   |                          name
--------+---------------------------------------------------------
 526583 | 人形町今半 牛炊き込みご飯 箱165g
 526673 | はごろも わかめご飯 鮭 袋250g
 526674 | はごろも わかめご飯 明太子昆布 袋250g
 526675 | はごろも わかめご飯 袋250g
 537351 | カネカ 本格ごはん 鶏肉のバジル炒めご飯 袋83g
 543441 | マルちゃん 炊き込みご飯の素 鶏五目 袋41.7g
 543442 | マルちゃん 炊き込みご飯の素 鶏五目 袋13.9g×10
 543443 | マルちゃん 炊き込みご飯の素 きのこ 袋13.1g×10
(8 rows)

通常のクエリとして実行できるので、ORマッピングフレームワーク等を使用して、簡単にオブジェクト化して使うことができると思います。

終わりに

個人的にはプログラムの実装はシンプルなのが好きなことと、アプリケーションサーバーとDB間のオーバーヘッドが気になるので、いわゆるビジネスロジックでは無いものをViewなどでDB側に置きたくなります。(処理が色々な所に分散されて管理が煩雑になることや、DBに負荷が集中してしまうデメリットも、もちろん気になりますが…。)

今回紹介した方法を使うと、クエリ時に少し複雑なロジックがあっても、ストアド関数として実装できて、選択の幅が広がると思いました。

イベント情報のテーブル設計で考えたことまとめ

タスクの趣旨

弊社サービスのもぐナビでは、様々なイベント、キャンペーンなどをオンライン、オフライン問わず実施しています。開催イベントの一覧が情報として見れるといいよねということで、新らたにイベント情報をもつテーブルの設計を考える機会がありました。
【過去の実施イベント例】
もぐナビベストフードアワード2018
カップ麺選手権
もぐナビ試食会

以上の背景を踏まえて、実際に作成したスキーマが下記になります。
(PostgreSQLを使用)

※⑴CREATE SEQUENCE seq_event;

CREATE TABLE m_event(
event_id        NUMERIC(13, 0) DEFAULT ※⑵nextval('seq_event') PRIMARY KEY,
event_title     character varying(128) NOT NULL,
event_url       character varying(128) NOT NULL,
information_start_date  TIMESTAMP WITHOUT TIME ZONE NOT NULL,
information_end_date    TIMESTAMP WITHOUT TIME ZONE ※⑶DEFAULT '2100/12/31',
※⑷new_end_date TIMESTAMP WITHOUT TIME ZONE NOT NULL,
event_label CHAR(2) NOT NULL DEFAULT '01',
picture_path1   character varying(128) NOT NULL,
picture_path2   character varying(128) NOT NULL,
upd_date        TIMESTAMP WITHOUT TIME ZONE,
ins_date        TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
del_kbn         CHAR(2) DEFAULT '01');

COMMENT ON COLUMN m_event.event_label IS '01:イベント';
COMMENT ON COLUMN picture_path1  IS '300x250';
COMMENT ON COLUMN picture_path2  IS '320x100';

※⑸CREATE INDEX idx_m_event ON m_event(information_start_date);

設計する過程で、考えたことについて内容を振り返ってみます。

INDEXについて

event_id(イベント固有に振られるID)が主キーの扱いとなるため、自動的にインデックスが貼られます。それに加えて、掲載した時期を元にイベント表示をしていくので掲載開始時期(information_start_date)についてもインデックスを貼ります※⑸。

シーケンスについて

event_idに対し、連番で固有の番号を割り振るため、シーケンスオブジェクトという特別なテーブルを作成します※⑴。 ※⑵でシーケンス関数を利用し、シーケンスオブジェクトの値を次に進めその値を返す、つまり自動で採番していく処理を行います。

NOT NULL制約について

イベントに関する基本情報は、デフォルトでNOT NULLにしておきます。イベントの掲載開始時期(information_start_date)は、全て必要になるためNOT NULL制約をつけますが掲載終了時期(information_end_date)は制約を外しました。これは、終了期間を未指定で掲載するケースもあるためです。何も入力しない場合には適当な年月日※⑶が自動で入るようにしてあります。

New!(新着アイコン)の表示について

新着のイベントにNEW!アイコンを表示します。掲載開始開始から一定期間経過後、非表示とする必要があるため、※⑷new_end_date(NEW!表示終了日)を設けて対応することにしました。当該期間とnew_end_dateの差が0以上なら、NEW!をつける、そうでないならNEW!をはずすというメソッドを作り、VIEW側でアイコンの出しわけを行いました。

truncate処理について

上記のイベント実装に関連し、テストコードの実装を行いました。その中でtruncate処理を知りました。 truncateは、deleteと違い、テーブルスキャンを行なわいため、テーブルの削除が高速で実行できます。下記※⑹で、イベントのテーブルデータを一度削除し、再作成するということをセットアップ処理の中で行っています。

public class EventBeanTest {
    @Before
    public void setUp() throws ServerException {
        TestManager.getInstance().initialize();
        DBConnectionManager.getInstance().getConnection().execute(※⑹"truncate m_event ");
    }

以上となります。

SQLのnullの扱いについて

あるカラムに文字列を追記したい為、update分で元のカラムにパイプ(||)を使って連結して更新を行った際、元カラムがNULLの場合意図しないNULLに更新されたので原因を調査してみました。

調査対象

Postgresql9.3.4

事象

例
update table_name 
set description = 'abcd' || description
where  
・・・
※descriptionがnullの場合、nullで更新されてしまう

原因

NULL値は不明の値を表しているため、不明な値との連結は行えない。 これは標準SQLに従った動作となります。

https://www.postgresql.jp/document/9.3/html/functions-comparison.html

解決策

COALESCE関数でNULLを置換する

COALESCE関数は、NULLでない自身の最初の引数を返します。全ての引数がNULLの場合にのみNULLが返されます。

COALESCE関数の使用例

SELECT COALESCE(description, short_description, '(none)') ...

descriptionがNULLでなければをそれ返します。そうでない場合(NULLの場合)は、
short_descriptionがNULLでなければそれを返します。そうでもない場合は(none)が帰ります。

結果、COALESCE関数で、NULLの場合スぺ―ス('')に置換することで解決しました。

update table_name 
set column_name = 'abcd' || coalesce(description,'')
where  
・・・
※descriptionがnullの場合、'abcd'として更新される

参考

COALESCE関数とは逆に、NULLに変換する必要がある場合、NULLIF関数をつかうと便利です。NULLIF関数の利用用途も交えて紹介します。

NULLIF関数の使用例

SELECT NULLIF(value1, value2) ...

value1がvalue2と等しい場合NULL値を返します。 等しくない場合はvalue1を返します。 

例 SQLの除算でゼロ除算エラーを回避する

例
SELECT
    column1 / column2
FROM
    table_name

※column2に0が設定されているとゼロ除算エラー(ERROR: division by zero)となる

ゼロ除算の回避方法

SELECT
    column1 / NULLIF(column2, 0)
FROM
    table_name

※column2が0の場合、ゼロ除算が回避され結果はNULLとなる

更に、結果にNULLを返したくない場合に、COALESCEと組み合わせることで回避できます

SELECT
    COALESCE(column1 / NULLIF(column2, 0), 0)
FROM
    table_name

※column2が0の場合、結果が0となる

よかったら、参考にしてみて下さい。