技術・Web access_time2019.11.08 00:28 update

WordPressの記事データをFirestoreに同期する

 WordPressブログをNuxt+Firebaseに移行するシリーズ。概要は↓

新・WordPressブログをNuxt+Firebaseに完全移行するプロジェクト

 半年ほど前に、こんな記事を書きました。 [nlink id="5837"]  簡単にまとめると、  ・WordPressの重... 続きを読む

access_time2019-11-03

 今回は最初のステップとして、まずはWordPressの記事をFirestoreに同期します。


 流れとしては

①Firebaseのプロジェクトを作ったりいろいろする

②Cloud FunctionsにaddPost関数を作成

③WordPressの投稿時/更新時にaddPostにリクエストを送る

④現時点で存在する記事を全て同期する

 簡単ですね! たぶん思っているよりも簡単です。

 以下にやり方を載せます。


Firebase側のセットアップ・コンソールログイン・その他いろいろ

 いろいろなところで説明されてると思うので割愛。公式ドキュメント見てください。

Firebase を JavaScript プロジェクトに追加する | Firebase

 ちなみに、この記事の段階では使うのはfunctionsをデプロイするだけなので、ステップ3のFirebase SDK追加も不要ですし、Firebase側で何かすることはありません。

 ただ、Firebaseの使い方としてはそれなりにトリッキーなので、一度Firebaseで真っ当にチャットとかWebアプリ作ってからの方が良い気もします。


Cloud Functions

addPost関数の作成

とりあえずfirebase initしてFirestore functionsを含めてセットアップします。

=== Functions Setup

A functions directory will be created in your project with a Node.js
package pre-configured. Functions can be deployed with firebase deploy.

? What language would you like to use to write Cloud Functions? TypeScript
? Do you want to use TSLint to catch probable bugs and enforce style? Yes

 TypeScriptを使うかだけ聞かれます。とりあえずTypeScriptを使うことにします。


Cloud Functions に TypeScript を使用する | Firebase

スタートガイド: 最初の関数を作成してデプロイする | Firebase

 この2ページを見ればだいたいの手順はわかると思うのですが、

 注意点としては公式チュートリアルはRealtime Databaseのままになっているということです。

 今からFirebaseのデータベースを使うならFirestoreを使いましょう。

import * as functions from 'firebase-functions';
const admin = require('firebase-admin');
admin.initializeApp();
const db = admin.firestore();

// Start writing Firebase Functions
// https://firebase.google.com/docs/functions/typescript

export const addPost = functions.https.onRequest(async (req, res) => {
  await db.collection('posts').doc(req.query.id).set(req.query);
  res.send(req.query);
});

 といっても難しいことはなく、このまま書けば動きます。

 req.queryの中身を展開するのが面倒だったのでそのまま渡しています。


 あとはコンソールでfirebase deploy --only functionsをするだけ。

 私の場合は

error TS2688: Cannot find type definition file for 'istanbul-lib-coverage'.

 みたいな警告が出ましたが、再度yarn installしたら直りました。


 デプロイが終わると

Function URL (addPost): https://us-central1-********.cloudfunctions.net/addPost

 と丁寧にURLを教えてくれます。


 PostManなどでリクエストしても良いですが、URL末尾にクエリ付けるだけでも一旦は大丈夫でしょう。

https://us-central1-****.cloudfunctions.net/addPost?id=1000&body=あああああ

 このURLにアクセスするとFirestoreに

image-20191104184744107

 以下のようなデータが格納されます。.set() なので同じIDで再度パラメータを渡せば上書きされます。このあたりはCloud FunctionsではなくFirestoreの技術ですが。


 ということは、queryに必要な記事データを全部埋め込んで都度渡せば良いわけですね。


WordPressの投稿時/更新時にaddPostにリクエストを送る

file_get_contentsでPOSTする

 記事の投稿・更新時に特定の処理を行うフックがWordPressには用意されています。

Saba note | WPで記事公開(更新)時のフックポイント publish_post

 PHPからのPOSTリクエストの方法もいろいろありますが、とりあえず元々ある機能のfile_get_contentsを使ってみます。

【php】file_get_contents()関数でPOSTリクエストを送る at softelメモ


<?php
function convert_category_to_id($cat) {
  return($cat->term_id);
};
function save_post_to_firestore($post_id) {
  $url = "https://us-central1-********.cloudfunctions.net/addPost";
  $post = get_post($post_id);
  $categories = get_the_category($post_id);
  $data = array(
    'id' => $post->ID,
    'slug' => $post->post_name,
    'category_ids' => implode(",", array_map('convert_category_to_id', $categories)),
    'content' => $post->post_content,
    'created_at' => $post->post_date,
    'updated_at' => $post->post_modified,
    'title' => $post->post_title
  );
  $httpquery = http_build_query($data, '', '&');
  $options = array(
    'http' => array(
      'method' => 'POST',
      'header' => "Content-type: application/x-www-form-urlencoded\r",
      'content' => $httpquery
    )
  );
  $context = stream_context_create($options);
  $response = file_get_contents($url, false, $context);  
  return;
};
//publish_post
add_action( 'publish_post', 'save_post_to_firestore', 1, 6 );
?>

 わかりにくい処理はないですよね。

 強いて言えばcategoriesをcategory_idsとしてカンマ区切りの文字列に変換する処理でしょうか。

 array_mapはむしろJavaScripterにはお馴染みの配列処理。

 どうせ渡すのは文字列なので、カンマ区切りの数字を渡して、それを配列に変換するのはcloud functionsでやってもらおうという流れです。


 さて、publish_postは記事更新でもトリガーするので、試しに1つ前の記事を更新してみました。

 その結果は?

image-20191105011115982

 ダメでした

 発行されるURLと同じものをブラウザに渡してもエラーが出るので、

 要するにURLのクエリが長すぎたようです。

 カウントしたらクエリ32000字あったので当然といえば当然。

 短い本文ならどうにかなるのかもしれませんが、それでは意味がないので、別の方法を調べてみます。


cURLを使ってPOST

 PHPにはcURLというより高機能なHTTPSリクエストを送る方法があるようです。

 全ての環境で使えるわけではないようですが、少なくともロリポップなら使えました。

PHPでHTTPリクエスト(cURL&PUTでパラメータを渡す際の注意) – Qiita

【PHP入門】cURL関数の使い方をマスターしよう! | 侍エンジニア塾ブログ(Samurai Blog) – プログラミング入門者向けサイト

JSON形式のデータをPOST送受信する方法(PHP) | 合同会社スマート

 ここの書き方でめちゃくちゃハマりましたが、試行錯誤の末に何とか送信。

  $url = "https://us-central1-********.cloudfunctions.net/addPost";
  $post = get_post($post_id);
  $categories = get_the_category($post_id);
  $data = [
    'id' => $post->ID,
    'slug' => $post->post_name,
    'category_ids' => array_map('convert_category_to_id', $categories),
    'content' => $post->post_content,
    'created_at' => $post->post_date,
    'updated_at' => $post->post_modified,
    'title' => $post->post_title
  ];
  $header = [
    "Content-Type: application/json"
  ];
  $ch = curl_init();
  curl_setopt_array($ch, [
      CURLOPT_URL => $url,
      CURLOPT_HTTPHEADER => $header,
      CURLOPT_CUSTOMREQUEST => 'POST',
      CURLOPT_POST => true,
      CURLOPT_POSTFIELDS => json_encode($data),
      CURLOPT_RETURNTRANSFER => true
  ]);
  $response = curl_exec($ch);
  curl_close($ch);

 Cloud Functions側ではqueryではなくbodyとして受け取ります。

export const addPost = functions.https.onRequest(async (req, res) => {
  await db.collection('posts').doc(req.body.id).set(req.body);
  res.send(req.body);
});


 これでいけるかと思いきや、今度は別のエラーが。

Error: Value for argument "documentPath" is not a valid resource path. Path must be a non-empty string.

 ここもしばらくハマりましたが、

 この渡し方だとドキュメント名を指定するIDが文字列ではなく数字になっていることに気づきました。

export const addPost = functions.https.onRequest(async (req, res) => {
  await db.collection('posts').doc(`${req.body.id}`).set(req.body);
  res.send(req.body);
});	

 再送信。

image-20191106021923416

 完璧!!!!


 categoryも配列で渡されているし、長い本文もしっかり保存されています。


送受信するデータを加工

 文字列ではない形式で渡せることがわかるともう少ししっかりとデータを入れたくなりますね。

 Firestoreは文字列以外にも様々な形式に対応しています。


 ・Dateをタイムスタンプ型にする

 ・Categoriesを連想配列ごと渡す

 ・というかWP_POSTに入ってるパラメータ基本全部投げる

function convert_tax_to_id($tax) {
  return($tax->term_id);
};
function save_post_to_firestore($post_id) {
  $url = "https://us-central1-********.cloudfunctions.net/addPost";
  $post = get_post($post_id);
  $categories = get_the_category($post_id);
  $tags = get_the_tags($post_id);
  $data = [
    // base
    'id' => $post->ID,
    'author' => $post->post_author,
    'slug' => $post->post_name,
    'title' => $post->post_title,
    'post_content' => $post->post_content,
    'post_excerpt' => $post->post_excerpt,
    'created_at' => $post->post_date,
    'updated_at' => $post->post_modified,
    // taxonomy
    'categories' => $categories,
    'category_ids' => array_map('convert_tax_to_id', $categories),
    'tags' => $tags,
    'tag_ids' => array_map('convert_tax_to_id', $tags),
    // meta
    'post_type' => $post->post_type,
    'post_status' => $post->post_status,
    'ping_status' => $post->ping_status,
    'comment_status' => $post->comment_status,
    'comment_count' => $post->comment_count
  ];

 categoriesとtagsを、連想配列とidsの両方渡してるのは、単純な配列であればarray-containsクエリで絞り込めるようになることを想定しています。まあこの構造は実際に表示するときに変えると思います。


全ての記事データを同期する

PHP – 【WordPress(おそらく超難題)】全記事の更新ボタンを自動で押したい|teratail

 適当な固定ページ(page-sync.phpとか)作って、

<?php
$myposts = get_posts('posts_per_page=-1');
foreach($myposts as $mypost) :
    do_action('publish_post', $mypost->ID, $mypost);
endforeach;
?>

 とだけ書いてアクセスしたら勝手に全記事でpublish_post関数が呼び出されます。

(※publish_postに自動ツイートなど別のアクションもフックしてたら大変なことになるので注意)


 というか別にpublish_post使う必要ないですね。このループの$mypostを、上記のsave_post_to_firestoreの$postとして使えば良いはず。

 ただ、完成形が見えていない現時点で全部の記事を同期させても、後でデータが足りなかったりして絶対何度もやることになるので、

 とりあえず20件くらい同期させておいて、Nuxtで呼び出す動きが完成したら同期させます。


今後の方針

 前回の記事で、最終的にWordPressを捨てるというロードマップを書いたのですが、

 WordPressは捨てない方が良いのではと思い始めてきました。


 理由としては、

①WordPressのエディターとしての信頼感

②バックアップに使える安全性

③FirestoreのNoSQLに由来する様々な問題を解決できる


 3の問題が何かというと、例えば連番でIDを振れない、などもあるのですが、一番はリレーショナル。

 複数のクエリから情報を取得するか、更新時に複数のデータベースを同時に更新しつつ整合性を取るか選ばなくてはならない、ということです。


 具体的には、記事を表示する際に必要となる

・記事本文

・カテゴリー名

・タグ名

・前後の記事のタイトルとURL

・コメント

 はWordPressでは全て別々に管理されて紐づけられているはずです。

(記事自体を更新しなくても「カテゴリーを編集」で更新した新しいカテゴリー名が反映されるので)


 これをFirestoreでやろうと思うと、

A. 記事データに全てのデータを入れておき、カテゴリーが更新されたらそのカテゴリーに属する記事データを全て検索して上書きする処理を自動で入れておく

B. 記事データにはcategory_idやtag_idだけを入れておき、表示時に記事データ取得→記事データに入っているIDを元にカテゴリーデータやタグデータを取得する

 のどちらかになります。

 が、AはCloud Functionsにやらせる処理量が相当多くなりますし、ちゃんと設計しないと整合性が取れなくなります。

 BはFirestoreとのデータのやり取りに何往復もするので、時間がかかる上にあっという間に読み取り回数も上限を超える可能性が高くなります。

 

 この点、WordPressで基本データを保存しておいてFirestoreに同期させる方法であれば、

 Aの方法でデータを詰める際に、整合性を考えずに「WordPress側でデータの更新があればその情報をFirestoreに一括で送信する」だけで良くなりますし、

 IDの連番処理もFirestoreなら余裕。そもそも、本来そういう面倒なことを自動でやってくれるのがWordPressの魅力であるわけで、Firestore移行によって時代に逆行する必要は特にないわけです。


 Cloud Functionsの「12.5万/月」という呼び出し回数制限はありますが、とりあえずこのブログの5000件の記事をループで全部投げても5000回しか使わないし、

 更新のたびに毎日全記事同期させると破綻しますが、記事投稿した時に更新かけるのは前後記事を含めた3記事だけで良いはず。

 カテゴリー名変更や記事の順番入れ替えなどがあると面倒ですが、いざという時に一括で全記事更新かける処理も、1ヶ月に10回くらいなら許されるはず。そう考えるとそれなりに余裕はある。

 もちろんCloud Functionsで何の処理を他にするかによって変わりますし、逆に複数記事を一括で処理するCloud Functionsを作ってしまえばもっと回数節約できるかも。


 というわけで次回はFirestoreから記事を取ってくる処理を書きます。

 併せて、今まではSSR前提だったので何でもasyncDataで取っていましたが、今後はasyncDataで取るもの=静的化するデータと、mountedで取るデータを分けないといけないですね。OGPに使うような部分以外は全部mountedで取りたいが、クエリ回数制限との兼ね合いが難しい。できれば静的サイト化の時に各ページ1回の読み取りで済むようにしたい……。


 このプロジェクト、完遂したらそれなりに需要がある気がしてきたので、全部終わったら総括Qiitaに上げようと思ってます。……このブログとの関わり濁して実名Qiitaでやればよかったー。。。新しくアカウント作らなくては。

WordPressの記事データをFirestoreに同期する への{{comments_list.length}}件のコメント

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください

*技術・Web* カテゴリーの最新記事