EatSmartシステム部ブログ

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

DNSサーバーの移転ついて

今回は、現状運用してるDNSサーバーを別のサーバーに移転することになり、そのノウハウを記事にしてみました。

DNSサーバーの仕組み

DNSサーバーを移転するに辺り、DNSサーバーの仕組みについて簡単に触れておきたいとおもいます。
DNSサーバーには2つの種類が存在します。

権威DNSサーバー

権威DNSサーバーとは、自身が管理するゾーン情報(ドメイン名とIPアドレスの紐づけ等)を保持し、問い合わせに対して自身が管理している情報のみを答えます。

ゾーン情報

test.co.jp   A   111.22.332.44 //test.co.jpのIPアドレスは「111.22.332.44」
test.co.jp  MX  mail.test.com //test.co.jpのメールサーバーは「mail.test.com」
test.co.jp  NS  old_ns1.test.com //test.co.jpのプライマリDNSサーバーは「old_ns1.test.com」
test.co.jp  NS  old_ns2.test.com //test.co.jpのセカンダリDNSサーバーは「old_ns2.test.com」
    ・
    ・
    ・

DNSキャッシュサーバー

クライアントからあるドメイン名の名前解決のリクエストを受け、該当ドメイン名を管理する権威DNSサーバへの問い合わせを行い結果を返却する。
また、問い合わせ結果は一定期間(TTL値で)保存され、期間内に同じ問い合わせが来た時には保存した内容を返却する。
一定期間が経過すると新に権威DNSサーバへの問い合わせを行う。

TTL

TTL(Time To Live)とは、一旦DNS経由でドメイン名の名前を解決(ドメイン名とIPアドレスの紐づけ)した場合、その情報をキャッシュしておく時間(数値は秒)である。

DNSサーバーの移転で注意すべき点

移転の際に注意すべき事項として下記2点があります。

  1. DNSキャッシュサーバー群からの名前解決要求を、いかにして移転先の権威DNSサーバーに向けさせるか
  2. DNSキャッシュサーバー群に、いかにして新しいDNSデータ(ゾーン情報)を提供するか

DNSサーバーの移転方法について

上記注意すべき点を考慮したDNSサーバーの移転方法は下記のようになります。

1. 移転先の権威DNSサーバーの構築

移転先の権威DNSサーバーに新しいゾーン情報及び新しいNSレコードを設定する。 その他変更が無いゾーン情報はそのまま設定する

2. 移転元ゾーン情報の切り替え

移転元の権威DNSサーバーのゾーン情報を、新しいゾーン情報(移転先データ)に切り替える
※移転元のTTLが、短時間で切り替わる様に短くしておく
※NSレコードは移転先の情報を設定する

3. 親(レジストラ)に登録した委任情報の切り替え

親に登録している委任情報(NSレコード情報等)の変更を申請し、移転先の権威DNSサーバーに切り替える

4. TTLが切れるまで移転元と移転先の権威DNSサーバーを並行運用する

すべてのDNSキャッシュサーバー群が移転先の権威DNSサーバーのみを参照するようになるまで、DNSサーバーを並行運用します。

5.動作確認

TTLが切れたら、DNSサーバーが切り替わった事を確認する方法を記載します
Linux環境
dig NS ドメイン

[test@ ~]# dig ns test.co.jp

;; QUESTION SECTION:
;test.co.jp.                        IN      NS

;; ANSWER SECTION:
test.co.jp.         300     IN      NS      new_ns1.test.com. //NSレコードが移転先に切り替わった事を確認
test.co.jp.         300     IN      NS      new_ns2.test.com. //NSレコードが移転先に切り替わった事を確認

Windows環境
nslookup
set type=ns
ドメイン
コマンドプロンプトから確認

C:\nslookup
> set type=ns
> test.co.jp
サーバー:  xxxx.xxx.xx
Address:  111.22.33.444

権限のない回答:
test.co.jp  nameserver = new_ns1.test.com
test.co.jp  nameserver = new_ns2.test.com
※「権限のない回答とは」DNSキャッシュを使って回答している事を示している
6. 移転元の権威DNSサーバーの停止

DNSサーバーが切り替わった事を確認できたら移転元権威DNSサーバーを停止する

まとめ

DNSサーバーは普段はあまり接する機会が無いので、忘れてしまっている部分がありましたが、今回の移転で振り返る事が出来て良い機会になりました。
DNSサーバーの基本的な仕組みは是非押さえておくと良いと思います。

Background Removal API を試してみる

画像の背景を切り抜くサービスを、Javaから利用してみました。

www.remove.bg

準備

まずは上記サイトで登録とAPIキーの取得を行います。 各言語ごとにサンプルが用意されているので簡単に利用出来ます。

今回はJavaから利用するので、httpclientが必要になりました。 予めダウンロードしてクラスパスに通して置きます。

実装

このAPIでは画像が透過PNGとして出力されるようです。 イートスマートではJPEG形式の画像を利用しているので、最終的にPNGからJPEGへ変換する必要があります。 背景色が白の画像に透過PNGを重ね合わせ、JPEG形式で出力するコードを書いてみました。

public class RemoveBackground {

    final String API_KEY = "[API_KEY]";

    void removeBackground(File from, File to) throws IOException {
        Response response = Request.Post("https://api.remove.bg/v1.0/removebg")
                .addHeader("X-Api-Key", API_KEY)
                .body(
                        MultipartEntityBuilder.create()
                        .addBinaryBody("image_file", from)
                        .addTextBody("size", "auto")
                        .build()
                        ).execute();
        response.saveContent(to);
    }

    void saveAsJpeg(File png, File jpeg) throws FileNotFoundException, IOException {
        BufferedImage source = ImageIO.read(png);
        int width = source.getWidth();
        int height = source.getHeight();

        BufferedImage dest = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
        Graphics2D g2d = dest.createGraphics();
        try {
            g2d.setBackground(Color.WHITE);
            g2d.clearRect(0, 0, width, height);[f:id:eatsmart:20190703203931j:plain]
            g2d.drawImage(source, 0, 0, width, height, 0, 0, width, height, null);
        } finally {
            g2d.dispose();
        }

        ImageIO.write(dest, "JPEG", jpeg);
    }

}

検証

もぐナビニュースで実際に使われた写真をサンプルに、背景画像を切り抜いてみます。

まずは以下の写真でテストを行いました。 お皿に乗ったお菓子がどう扱われるかが気になります。

f:id:eatsmart:20190703203905j:plain

以下のようになりました。

f:id:eatsmart:20190703203931j:plain

綺麗にテーブルクロスだけが切り抜かれお皿が残りました。

続いては、同じくお皿に乗ったパンケーキです。 先程の写真ではお皿が残りましたが、この写真ではホイップクリームがお皿からはみ出しています。 このあたりの影響はあるのでしょうか?

f:id:eatsmart:20190703204237j:plain

以下のようになりました。

f:id:eatsmart:20190703204252j:plain

こちらも先程の写真と同じく、お皿が残りました。 お皿からはみ出したホイップクリームが残っているので、背景と区別がされているようです。 写真の中央にお皿がある場合、背景の判別が行いやすいのかもしれません。

最後に、これまでとは異なる写真を試してみます。 テーブルの上のお皿ではなく、カップに入った状態です。 また、テーブルとスクリーンの2つの異なる背景があります。

f:id:eatsmart:20190703204607j:plain

以下のようになりました。

f:id:eatsmart:20190703204627j:plain

これまでの写真と同様背景は切り抜かれましたが、手前のクッキーが残ってしまいました。 また、カップの左下にも何かしら残っています。 これまでと異なり商品と背景の差があいまいで難易度は高いと思いますが、わずかな手直しで済みそうです。

まとめ

簡単な検証ですが、綺麗に背景が切り抜かれることがわかりました。 プログラムから利用することが想定されており、APIキーさえ取得すれば簡単に利用することが出来ました。 このAPIを利用して画像を事前に処理してけば、人は確認と手直しに専念することができそうです。

Googleのコアアルゴリズムアップデートの影響と対応について

弊社サービスの「もぐナビ」は、Googleの検索結果からサイト訪問する割合が多く、常にGoogleの検索結果の表示順位に気を配っています。 Googleは3月初めと6月初めに検索に関してのアルゴリズムを更新し、検索結果の表示順位に影響を受けました。

【March 2019 Core Update】2019年3月12日、コアアルゴリズムのアップデートをGoogleが開始 | 海外SEO情報ブログ

【June 2019 Core Update】2019年6月、広範囲にわたるコアアルゴリズムのアップデートをGoogleが開始 | 海外SEO情報ブログ

このアルゴリズムアップデートについての影響や変動の調査に際して、気付いたことがあったので記事にしたいと思います。

影響の調査について

Googleの検索結果の表示回数や順位、クリック数などは、Google SearchConsoleの[検索パフォーマンス]-[検索結果]を使って分析します。

f:id:eatsmart:20190628141857p:plain
サーチコンソール

SearchConsoleは少し前にリニューアルされ、使い方が少し変わりました。日付比較などは以前の方が使いやすいところもあったのですが、今後改善されていくでしょう。

また、SearchConsoleはGoogle Analyticsと連携して、Analyticsを使って分析することもできます。

f:id:eatsmart:20190628142509p:plain
Analytics

ただ、詳細にドリルダウンして分析するなら、SearchConsoleの方が使いやすいと思います。

気付いたこと

平均掲載順位は大きく変わらないのに、表示回数が激減

アルゴリズムアップデートの前後で、どんな検索キーワードについて表示回数やクリック数が減ったのかを調べていたのですが、平均掲載順位は大きくは変わらないのに、表示回数やクリック数が大きく減っているキーワードを見つけました。

f:id:eatsmart:20190628143206p:plain
表示回数激減

キーワードに対しての掲載順位や表示回数を詳しくて見ていくと、アルゴリズムアップデートから数日後に掲載順位が大きく低下し、検索結果に表示されないために平均掲載順位は下がらず表示回数が激減しているようでした。
(グラフを見ると、たまに以前の順位で表示されているようです。)

はじめは、対象のキーワードの検索ニーズが急に無くなった(はやりのキーワードだった?)のかと思ったりしたのですが、こういった形になることはSearchConsoleのヘルプにも書いてありました。

表示回数、掲載順位、クリック数とは - Search Console ヘルプ

掲載順位が記録されるためには、そのリンクが実際に表示されなければなりません。ある検索結果が表示されなかった(たとえば検索結果は 3 ページあったのにユーザーが 1 ページ目しか見なかった)場合、その検索結果の掲載順位はそのクエリには記録されません。

表示回数・掲載順位に対して、クリック数が少ない

次に、掲載順位は低くなく表示回数も少なくないにも関わらず、クリック数が少ないURLを見つけました。

掲載順位ごとのクリック率の統計は、以下のようなものが公開されています。

【2017年夏】グーグル検索結果のクリック率データ: 1位は21%、2位は……【SEO記事11本まとめ】 | 海外&国内SEO情報ウォッチ | Web担当者Forum

これに対し、掲載順位が4〜5位なのに、クリック率が0.0%台でした。

f:id:eatsmart:20190628144846p:plain
CTR低い

これもSearchConsoleのヘルプに書いてあったのですが、実はクリック率が低いURLは、検索結果SERP内のサイトリンクでした。
例えば、「菓子パン」でGoogle検索をすると、検索結果のもぐナビのSERPは以下のように表示されます。

f:id:eatsmart:20190628145622p:plain
サイトリンク

ここでサイトリンクとして表示されたURLは、検索キーワードに対し表示回数と掲載順位が加算されますが、クリックはなかなかされないので、クリック数が少なくなっているのでした。

今後の対応について

アルゴリズムアップデートの影響についていろいろ分析したところ、もぐナビニュースで以前は人気記事だったけど情報が古くなっているページの検索順位が落ちて流入が減っていたようでした。今後は、そういった古くなりがちな情報に対して、最新の内容を保てるようにしていきたいと考えています。

もぐナビのクチコミ一覧からもぐナビニュースへ

イートスマートの新人エンジニアが、「もぐナビ」での開発を振り返り、学んだ内容をまとめていきます。 今回は、キャッシュとシーケンシャルスキャンについて考える機会となりました。

今回の実装の内容

弊社サイト「もぐナビ」でトラフィックの大きいユーザーさんのクチコミ一覧から、もぐナビのニュース枠を設置し、そこからもぐナビニュースへ誘導しようという経緯で実装しました。
※もぐナビニュースについて(コンビニ商品を中心に新作や話題性のあるものを紹介しています。) f:id:eatsmart:20190621125606p:plain

実装例(関連するコードのみ抜粋)

CacheTopBean.java---キャッシュの中身に関するクラス

public class CacheTopBean implements CacheInterface {
   private NewsItemDto newsItem = new NewsItemDto();

   public NewsItemDto getNewsItem() {
        return NewsItem;
    }
  public void setNewsItem(NewsItemDto newsItem) {
        this.newsItem = newsItem;
    }
 ※NewsItemDtoには、表示に必要なニュースタイトル、遷移先url、イメージ画像、掲載日などをフィールドにもっています。
}

TopManager.java---キャッシュ作成に関わるクラス

public class TopManager implements ReloadableCacheManager {

    private static TopManager instance = new TopManager();

    private TopManager() {
    }
        public static TopManager getInstance() {
        return instance;
    }
public CacheTopBean createCacheTopBean(Calendar date) throws ServerException  {
    CacheTopBean ctb = new CacheTopBean();
 
  NewsItemDto newsItem = new NewsItemDto();
  //DBから該当のデータを抽出し、newItemに代入します。
    newsItem = jp.mognavi.news.NewsItemDao.getInstance().getNewsItemDto();
    //生成したキャッシュに目的のニュース情報のインスタンスをセットします。
    ctb.setNewsItem(newsItem);

    return ctb;
        }
}

NewsItemDao.java---ニュースのDB操作に関するクラス

public  NewsItemDto getNewsItemDto() {
        return  getSingleResult(
                "SELECT v.guid, v.title as title ,v.contents, v.category, v.pub_date, t.title as ptitle, v.image, v.update_date "
                        + "FROM v_news_item v LEFT JOIN t_news_item_pickup t ON v.guid = t.guid "
                        + "WHERE v.del_kbn = ? " + "AND v.pub_date > now() - interval '2 days' "
                        + EXCLUDE_PR_NEWS(PR商品を除外するsql)
                        + INCLUDE_NEWS_CATEGORY(特定のカテゴリに絞るsql)
                        + "ORDER BY random() LIMIT ?",
                NewsItemDto.class,
                KbnConstant.DEL_KBN_VALID, 1);
    }
※詳細を省きますが、ざっくりと公開から48時間以内で、特定のカテゴリに絞りランダムに1件取得しています。

ListAction.Java---クチコミのリスト表示に関するクラス

public class BlogItemDao extends DaoBase {
public class ListAction extends GenericBaseAction<...Bean> {
   
    CacheTopBean ctb = TopManager.getInstance().getCacheTopBean();
        //キャッシュにセットしたnewsItemをアクションの方でセットします。
        request.setAttribute("newsItem", ctb.getNewsItem());
        return success();
  }
}
上記の"newsItem"をビュー側で利用します。

実装のポイントについて

最初は、データベースから取得してきた値をニュース表示のアクションにセットするという方法で実装していましたが、それだとページの読み込み速度に影響する可能性があったため、キャッシュに保存することでその問題を回避するようにしました。
次に、sqlの中で使用したrandom関数ですが、シーケンシャルスキャンが発生するため対象データが多い場合にはクエリの実行速度に影響します。今回のケースでは、公開から48時間という制約が大きく、母数がかなり限定的されるため特段問題にしませんでした。母数が大きい場合には、一旦キャッシュに100件保存し、キャッシュからランダムに1件抽出する方法などが考えられます。

シーケンシャルスキャン参考

以上になりますが、実装内容は基本的なものになりますが随所で学びポイントや工夫の機会がありました。それらを次のタスクに繋げていければと思います。

LINEのプッシュ通知について

前回の記事で「LINE公式アカウント」の統合について紹介しましたが、今回はプッシュ通知についてまとてみたいと思います。

eatsmart.hatenablog.com

プッシュ通知とは

プッシュ通知とは、 アプリを起動していない状態でもメッセージがユーザーのスマートフォンに通知される為、他のメッセージ送信手段に比べ読まれる確率が高いです。

プッシュ通知のメリット

メールでメッセージを送信すると、メールボックスに溜まって多くのメッセージの中に埋もれてしまったり、意図せぬフィルター制御で未読状態のままとなってしまう可能性がありますが、プッシュ通知の場合スマートフォンに通知が届けば、リアルタイムで ユーザーに気付いてもらえる可能性が高まり開封率の向上が期待できます。

LINE経由のプッシュ通知について

LINE経由のプッシュ通知を行うには、LINEが提供しているMessaging APIを利用して行います。また、プッシュ通知の送信タイプは以下のとおりです。

1人のユーザー

1人のユーザー、グループ、またはトークルームに、任意のタイミングでプッシュ通知を送信するAPIです。

複数のユーザー

複数のユーザーに、任意のタイミングでプッシュ通知を送信するAPIです。
※グループまたはトークルームにメッセージを送ることはできません。

公式アカウントと友だちになっているすべてのユーザー

LINE公式アカウントと友だちになっているすべてのユーザーに、任意のタイミングでプッシュ通知を送信します。

LINE経由のプッシュ通知を試してみる

今回は、curlコマンドでMessaging APIを呼び出して、1人のユーザーへのプッシュ通知を試してみました。 Line Developerサイトには、各種解説やサンプルが多数存在しており参考になりました。送信までの流れを記載します。

Lineプッシュ通知送信までの流れ

1)Line Developerで「LINEログイン」チャンネル開設

https://developers.line.biz/ja/docs/line-login/getting-started/

2)Line Developerで「Messaging API」チャンネル開設

この時、Line Offcial Account Manager側とLINEアカウント連携しておくと、Line Offcial Account Manager側にアカウントが自動で作成されます。

https://developers.line.biz/ja/docs/messaging-api/getting-started/

3)LINE公式アカウントをLINEログインのチャネルにリンクする

これを行っておくと、ログイン連携でアプリの利用同意を行った後に、公式アカウントを友達追加するオプションが表示されます。
表示方法は2種類存在していて、アプリの利用同意画面に組み込む形式と、アプリの同意画面表示後に友達追加を別画面で表示する方法が存在します。
また、APIの呼び出し方法は以下のLINEログインの認可APIbot_promptクエリパラメータを付加します。

https://access.line.me/oauth2/v2.1/authorize?response_type=code&client_id={CHANNEL_ID}&redirect_uri={CALLBACK_URL}&state={STATE}&bot_prompt={BOT_PROMPT}&scope={SCOPE_LIST}

また、bot_promptクエリパラメータに以下の値のいずれかを設定します。
normal:
アプリの利用条件の同意画面に、LINE公式アカウントを友だち追加するオプションを追加します。
aggressive:
アプリの利用条件の同意画面の後に、LINE公式アカウントを友だち追加するかどうか確認する画面を開きます。

https://developers.line.biz/ja/docs/line-login/web/link-a-bot/

4)Line Developerの「Messaging API」チャンネルでアクセストークンを発行する

プッシュ通知を呼び出す際に、チャンネルアクセストークンが必要な為、 「Messaging API」チャンネルの「チャンネル基本設定」タブで、アクセストークンを発行します。

5)curlでプッシュ通知を検証する
curl -v -X POST https://api.line.me/v2/bot/message/push \
-H 'Content-Type:application/json' \
-H 'Authorization: Bearer {チャンネルアクセストークン}' \
-d '{
    "to": "送信先ID(今回はuserId)",
    "messages":[
        {
            "type":"text",
            "text":"Hello, world1"
        },
        {
            "type":"text",
            "text":"Hello, world2"
        }
    ]
}'

https://developers.line.biz/ja/reference/messaging-api/#send-push-message

(補足)
最初、ログイン連携だけして、公式アカウントを友達追加していない状態でプッシュ通知を行っていましたが、その状態ではメッセージが届きまん。
そもそも公式アカウントを友達追加しておかないと、トークに公式アカウントが追加されない為、メッセージも届かくわけないですよね。

最後に

今回はプッシュ通知を試してみましたが、Messaging APIはプッシュ通知以外にもボットを活用した双方向の通信が行える様なのでその辺りの機能も試していきたと思います。

もぐナビのフロントエンド事情

イートスマートではもぐナビという食品クチコミサイトを運営しています。 私が入社する以前から続くこのサービスは、フロントエンドにjQueryと一部prototype.jsを利用しています。 jQueryでDOMを直接操作するため、いわゆるイマドキなWEBサービスに比べるとレガシー感があります。 そんなもぐナビですが、今期リリースを予定する新機能の開発に伴い、ReactとTypeScriptの導入を行いました。

jQueryの利用に関して

個人的な考えですが、現状でもjQueryの利用は"アリ"だと思います。 サービスの要件やメンバーの習熟度を考えると、jQueryの採用が妥当な場面はまだまだあるように思います。 今回ReactとTypeScriptを導入しますが、主要なページではjQueryを利用し続けますし、最新バージョンへの更新も行う予定です。

jQueryは、簡単なDOMの操作やイベントに紐づく処理の実装はとても簡単に出来ます。また、豊富なプラグインを利用出来るのも魅力です。 ですが、Reactを今回導入するきっかけとなった新機能を実装するとなると、だいぶ辛さがありました。

Reactを導入する理由

状態に応じてフォームや遷移を管理するには、jQueryでも可能です。実際に、会員登録やクチコミ投稿ページでは、状態に応じた処理を行っています。 今回の要件を満たすプロトタイプを実装する途中まで、jQueryも頑張れそうでしたが、ReactやVueのような仕組みが欲しくなりました。

実は社内ツールではReact/Vueいずれも導入しています。複雑な実装をしたわけでは無いですが、jQueryに対するメリットを実感することが出来ました。 この中ではなぜReactを選んだかというと、React Nativeでネイティブアプリを開発するための下地を作りたかったからです。 もぐナビでは現在ネイティブアプリの提供を停止しています。いろいろな事情がありますが理由のひとつに、プラットフォームごとにネイティブアプリを開発・運用することの負担が大きかったことがあります。 Reactを経験してReact Nativeによるネイティブアプリの開発の敷居を下げることも、Reactを導入する理由のひとつです。

Reactの導入にあわせてTypeScriptの導入も行いました。量は多くありませんが、新規のものからReactとは関係が無いのものTypeScriptでの実装を行っています。 対応するエディタを利用すると、Javaのようにコードアシストやエラー・警告で問題箇所の指摘が行われるので、以前に比べ実装の負担は減っています。 下記の記事を参考に"がんばらずに"導入したことも良かったです。

employment.en-japan.com

スクランナーの導入

今回からwebpackとgulpを導入しました。

webpackはReactとTypeScriptのビルドに利用しています。 以下のように、共通して利用されるモジュールを別途出力し、ファイルサイズを小さくする工夫をしています。 jsやcssの縮小などwebpackでも可能なようですが、それらはgulpのタスクにしています。

optimization: {
    runtimeChunk: 'single',
    splitChunks: {
        name: 'webpack-chunks',
        chunks: 'initial',
        minChunks: 2
    }
}

gulpでは上記のようにwebpackで行わないjsやcssの縮小や結合等最適化に利用しています。 当初はこれらの処理は既存のyuicompressorで行おうとしていましたが、webpackでビルドしたjsや一部cssの処理に問題があったので、webpack導入と合わせてgulpの導入を行いました。 yuicompressorはJenkinsのジョブから実行するAntのタスクに組み込むことができましたが、gulpはJenkinsのジョブからgulp/webpackを導入したDockerコンテナで実行するようにしています。

開発時はgulpのwatchを利用して、ファイルが変更されたタイミングで自動的にビルドが行われるようにしています。 開発が進むにつれビルドに時間がかかるようになり、反映までの待ちが出来るようになってしまいました。このため、依存関係に無いタスクをparallelで処理するようにしています。 以下のように、jsとcssは依存しないのでparallelで、webpackとjsを結合する"js-concat"タスクはseriesで実行しています。

gulp.parallel(
    gulp.series(
        'webpack',
        'js-concat'
    ),
    'css-concat'
)

おわりに

もぐナビのフロントエンドのこれからを書きました。 React/TypeScriptに関してまだまだ知見が少なく、jQueryでは簡単に実装できそうなものに手こずることもありますが、新たなチャレンジに試行錯誤しながら楽しく取り組むことが出来ています。 未だにjQuery/Vanilla JSの割合が大きいですが、少しづつReact/TypeScriptを増やしていきたいと思います。

イートスマートでは、一緒に自社サービスの開発・運用をするメンバーを募集しています。 www.eatsmart.co.jp

React+Reduxについて

最近、React+Reduxでコンポーネント実装に取り組んでいるのですが、今回はそれを通して知ったことをブログにまとめたいと思います。

Reduxとは

Reactでは、stateという仕組みを通してコンポーネントとデータ(状態)の双方向バインディングを実現しています。通常のstateは単独のコンポーネントで使用するか、propsを通して他のコンポーネントへ渡す必要がありますが、複数のコンポーネントで協調的にstateを使うために、全体のstate(Storeと呼びます)を一元管理する仕組みがReduxになります。

Reduxの仕組み

Reduxでは、複数コンポーネント(アプリケーション)で共有するstateを保持するStoreと、Storeを更新するためのReducer、そしてReducerに対して、どのようにStoreを更新するかを伝えるActionで構成されています。

ユーザー操作で値が変更されるUIは、

  1. UI(VIewComponent)にReduxの処理をconnectする
  2. UIのイベントをハンドリングする
  3. イベントの種類と更新値をセットにしたActionを生成する(Action create)
  4. ActionをReducerに渡す(dispatch)
  5. ReducerでStoreの値を更新する(reduce)
  6. 値の更新がUIに反映される

という流れで実現されます。 この構成(デザインパターン?)は理解しやすかったのですが、次に紹介する実際の使い方を把握するまで苦労しました…。

Reduxの使い方

まあ、使い方と言っても、ほぼReduxのTutorial Basic Tutorial: Intro · Redux のままなんですが。

Action(とActionCreator)

Actionとは、イベントの種類と値をセットにしたものです。

{
  type: SUBMIT,
  value
};

実際は、以下のようにイベントに応じてActionを生成する、ActionCreatorという関数を用意します。

const SUBMIT_PARENT_A = "SUBMIT_PARENT_A";
export const submit = (value) => {
  return {
    type: SUBMIT,
    value
  };
};

Reducer

Reducerは、Actionに応じてStoreの値を更新する処理です。 インターフェースとしては、現在のstateとActionを受け取り、新しいstateを返します。 Reduxでは、Reducerを通してStoreを更新することでUIが再描画され、双方向バインディングが実現されています。

Reduxでは初めにStoreを生成する際に空のreducerを呼び出しstateを取得するようなので、引数のstateのデフォルト値を初期値にしてそのまま返すことで、生成時の初期値を設定するのが常套のようです。

const initialState = {
  name: 'initial name',
  content: 'initial content',
};

export const reducer = (state = initialState, action) => {
  switch (action.type) {
    case SUBMIT:
      return Object.assign({},state,{
        name: action.value.name,
        content: action.value.content,
      });
    default:
      return state;
  };
}

Store

で、上のReducerを渡してStoreを生成します。

const store = createStore(reducer);

アプリケーションが大きくなるとStoreを構成するオブジェクトの構造が複雑になりますが、Reducerを複数の関数に分割して、更新するstateだけを処理することができます。

例えば、userに関する処理を行うReducerをuserReducer、foodに関する処理を行うReducerをfoodReducerと定義した場合、

const store = createStore(
  combineReducers({
    user: userReducer,
    food: foodReducer
  })
);

とすると、userReducerのstateにはstore.user以下のstate、foodReducerのstateにはstore.food以下のstateを受け取ることができます。

そして以下のように、生成したStoreをアプリケーションと紐づけます。

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

connect

コンポーネントからReduxのStoreにアクセスしたりActionを介して値を更新するために、connectする必要があります。

connectには、2つの関数を渡すことで、

  • Storeの値をpropsに設定(mapStateToProps)
  • ActionをReducerに渡す(dispatchする)関数をpropsに設定(mapDispatchToProps)

ができます。

mapStateToPropsは、state(Storeに格納されているstate全体)を引数に受け取るので、以下のように必要な値をpropsに設定します。

const mapStateToProps = (state) => {
  return {
    name: state.name,
    content: state.content,
  }
}

mapDispatchToPropsは、ReducerにActionを渡すdispatch関数を引数に受け取るので、以下のように関数をpropsに設定します。

const mapDispatchToProps = (dispatch) => {
  return {
    onChange: (e) => {
      dispatch(submit({
        name: e.target.name,
        content: e.target.value,
      }))
    },
  }
}

そして、これらをコンポーネントにconnectします。

class TestComponent extends Component<Props> {
  public render() {
    const {name,content,onChange} = this.props;
    return (
      <div>
        {name}:{content}
          <select name="area" onChange={(e)=>onChange(e)>
          <option value="北海道">北海道</option>
          <option value="青森県">青森県</option>
          <option value="岩手県">岩手県</option>
          <option value="秋田県">秋田県</option>
          <option value="宮城県">宮城県</option>
          ・・・
          </select>
      </div>
    );
  }
}

export default connect(mapStateToProps,mapDispatchToProps)(TestComponent);

これによって、コンポーネントにStoreの値を渡し、値を変更できるようになりました。

Reduxを使ってみて

そもそもReactに触れるのもはじめてだったので理解して慣れるまで苦労しましたが、分かってくると案外シンプルで色々と使えるので便利だと感じました。

デバッグについて

chromeで開発しているのですが、こちらの拡張機能がとても役に立ちました。

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

拡張機能をインストール後に、createStore時に

const store = createStore(
  reducer, 
  window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
 );

thunxやrouterなどのmiddlewareを使う場合は

const composeEnhancers = window['__REDUX_DEVTOOLS_EXTENSION_COMPOSE__'] as typeof compose || compose;
const store = createStore(
  reducer,
  composeEnhancers(applyMiddleware(・・・))
);

とする必要があります。

アプリを起動後に開発者ツールのreduxで、dispatchされたActionやStoreの中身を見ることができます。

全体のStateを取得する場合

今回のアプリケーションでは、画面の種類に応じてReducerを作りcombineReducersで結合することで、自画面に関係無いstateを意識しないように設計していますが、最終的にStore全体にわたるデータをサーバーに送信するために、以下を参考にしました。

Reducers · Redux
ReduxのFAQを読み直す - Qiita

具体的には、今回はサーバーとの通信にredux-thunkを使っているので、サーバー送信用のActionで

export const regist = () => (dispatch,getState) => {
  const state = getState();
  ・・・

として全体を取得しました。

ちなみにstoreをimportして直接利用するのは非推奨だそうです。

Store Setup · Redux
ReduxのFAQを読み直す - Qiita

コンポーネントに固有なstate

Reduxを使用するにあたりstateと名のつくものはReduxのStoreで管理することになると勝手に思っていましたが、UIの状態管理などのコンポーネントに固有なstateは通常のstateとして保持してsetStateで状態を切り替える方がシンプルになります。

Organizing State · Redux
ReduxにおけるGlobal stateとLocal stateの共存 - LIVESENSE ENGINEER BLOG

などなど、また新しい発見があったら、更新したいと思います。