WordPressのSNSシェア数集計システムをサーバーレスで構築しました

85件のシェア(ちょっぴり話題の記事)

WordPressのSNSシェア数をサーバーレスで集計する

このたび、本ブログ「Developers.IO」の各記事のSNSシェア数の集計システムを刷新しました。

結果としてサーバーレスで非同期に集計しキャッシュさせることで安定・高速・即時を実現できました。この記事では、どのようなシステムを構築したのかご紹介したいと思います。

具体的には、下図の場所の数値の集計を独自システム化しています。

sns-count

【課題】 キャッシュを強めながら、SNSシェア数を更新したい

まずは、なぜSNSシェア数集計システムを刷新することになったのか、背景をお話ししたいと思います。

とにかくキャッシュを強めたい

Developers.IO は、記事数の多さが特長の技術ブログです。先日はついに記事総数10,000本を達成し、いまもなお新しい記事が増え続けています。

記事数が増えるということは、それだけアクセス数が増えるということです。アクセス数が増えると、記事を生成しているサーバー(EC2)及びデータベース(RDS)の負荷が上がってきます。サーバーの台数を増やすとそれだけランニングコストも増えますし、依然としてサーバーダウンの危険性はつきまといます。

そのような場合はCDN(CloudFront)で記事をキャッシュさせる方法を取ることが一般的な解決策です。キャッシュを長期間に設定することで、ほとんどのアクセスをCDNで捌くことができるため、サーバーに対するアクセスを減らすことができます。公開された記事が改めて更新されることは少ないので、できる限りキャッシュされる期間を伸ばしたいというわけです。

sns-count-01

けれど、動的に変化するデータは更新したい

キャッシュを強めれば全て解決できるかというと、そうではありません。SNSシェア数など動的に変化するデータを更新できないという問題に直面します。

例えば、SNSシェア数の取得をWordPress側で行なっている場合、キャッシュした時点から増えたSNSシェア数を更新することはできません。更新したい場合はキャッシュ時間を短くするか、キャッシュを無効化(Invalidation)しなければならなくなります。これは、前述の「とにかくキャッシュを強める」と相反します。

sns-count-02

「とにかくキャッシュを強める」と「動的なデータを更新する」を両立するためには、動的なデータはクライアント側で取得し、結果を反映させる方法が有効です。具体的にはシェア数を集計するシステム(API)を別に用意し、クライアント(ブラウザ)上でJavaScriptでシステムからデータを取得し、HTMLに反映させるようにします。

sns-count-03

なお、クライアント側で各SNSのシェア数取得APIを直接呼び出す方式を取る場合もありますが、パフォーマンス向上(クライアント側のリクエスト数の削減、SNS側が要因のレイテンシが大きくなる問題の根本的な解消)が見込めることから、シェア数を集計するシステムを別に用意する手段を取りました。

このような構成にすることで、記事自体(HTMLとJavaScript)はキャッシュを使いつつ、動的なデータを表示させることができます。

システムのアーキテクチャ

新規に構築したSNSシェア数集計システムは、下図のようなアーキテクチャにしました。

sns-count-04

集計結果の配信

まず、APIとして振る舞う部分はAmazon S3Amazon CloudFrontで実現しました。S3バケットにSNSシェア数の値が丸ごと入ったJSONファイルを記事ごとに格納し、それをそのままCloudFrontで配信することで、ある程度のキャッシュを効かせた状態でAPIのように振る舞わせることができます。

集計する対象の記事の抽出

SNSシェア数をJSONファイルで表現しましたが、この集計結果は継続的に更新していく必要があります。全件を定期的に回すと確実に更新できますが、記事数がやたらと多いので工夫が必要です。

そこでアクセスログを元にアクティブな記事を抽出するという方法を取りました。「SNSシェア数に変動があった記事 = 新しいアクセスがあった記事」と仮定することができるので、Amazon Athenaを用いてアクセスログの高い記事を集計及びソートすることにしました。また、クエリはCloudWatch Eventを使用してLambda Functionを定期実行する方法を用いて、定期的に動作するようにしています。

Athenaのクエリ結果はCSVファイルとしてS3に格納することができます。以上により、アクティブな記事一覧が定期的にS3に格納されていくようになります。

集計処理の実行

Athenaのクエリ結果がS3に格納されたことを受け、S3 Event Notificationを使ってLambda Functionを発動させます。このLambda Functionでは、CSVファイルを元にアクティブな記事のSNSシェア数を取得し、配信用のS3バケットにJSONファイルを格納する処理を行います。具体的な処理内容は、次項で解説します。

LambdaによるSNSシェア数の集計処理

本システムで最も重要な部分はLambdaによるSNSシェア数の集計処理です。どのような実装を行なっているかご紹介します。なお、Lambda FunctionのプラットフォームはNode.js 6.10です。

利用ライブラリ

各SNSのシェア数を取得するために、RESTクライアントにaxiosを使っています。また、対象URLリスト(CSV)をPromiseに変換するためにstream-to-arrayを使っています。axiosは直感的で非常に使いやすいです。

{
  "name": "sns-count",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "suwa.yuki",
  "private": true,
  "dependencies": {
    "aws-sdk": "^2.92.0",
    "axios": "^0.16.1",
    "csv": "^1.1.1",
    "stream-to-array": "^2.3.0"
  }
}

SNSシェアカウント集計処理

以下はSNSシェアカウント集計処理のJavaScriptファイルです。Facebookのアクセストークンを取得するgetFbTokenと各SNSシェアカウントを取得して1つのオブジェクトにするevaluateという関数をexportしています。

はてブは認証なしで取得できますが、Facebookはアプリ登録が必要です。Facebookについては、ClientIdとClientSecretを環境変数とすることで、ソースコード上に埋め込まないようにしています。

また、Twitterは公式の提供は無くなってしまいましたが、株式会社 ディジティ・ミニミさまが提供されているcount.jsoonというサービスで代用できます。サイト登録することで取得できるようになります。

'use strict'

const axios = require('axios');

const hatenaUrl = 'http://api.b.st-hatena.com/entry.count';
const facebookAuthUrl = 'https://graph.facebook.com/oauth/access_token';
const facebookUrl = 'https://graph.facebook.com/v2.8';
const twitterUrl = 'https://jsoon.digitiminimi.com/twitter/count.json';

const evaluate = (url, fbToken) => {
  return new Promise((resolve, reject) => {
    const data = {};
    Promise.all([
      hatena(url),
      facebook(url, fbToken),
      twitter(url)
    ])
    .then(resps => {
      data.hatena = resps[0];
      data.facebook = resps[1];
      data.twitter = resps[2];
      resolve(data);
    })
    .catch(err => {
      reject(err);
    })
  });
};

const getFbToken = () => {
  return axios.get(facebookAuthUrl, {
    params: {
      client_id: process.env.FACEBOOK_CLIENT_ID,
      client_secret: process.env.FACEBOOK_CLIENT_SECRET,
      grant_type: 'client_credentials'
    }
  })
  .then(resp => {
    return Promise.resolve(resp.data.access_token);
  });
};

const facebook = (url, token) => {
  return axios.get(facebookUrl, {
    params: { id : url, access_token: token }
  })
  .then(resp => {
    return Promise.resolve(resp.data.share.share_count);
  });
};

const hatena = url => {
  return axios.get(hatenaUrl, {
    params: { url: url }
  })
  .then(resp => {
    return Promise.resolve(resp.data);
  });
};

const twitter = url => {
  return axios.get(twitterUrl, {
    params: { url : url }
  })
  .then(resp => {
    return Promise.resolve(resp.data.count);
  });
};

module.exports = {
  getFbToken: getFbToken,
  evaluate: evaluate
}

Lambda Functionのハンドラ

以下はLambda Functionが呼び出されると実行する、エントリポイントとなるJavaScriptファイルです。S3バケットにCSVファイルがアップロードされたら起動させています。

CSVファイルにはURLのフルパスが入るのではなく、パスのみがリストされている想定です。HTTPとHTTPSのそれぞれをエンドポイントに各記事のパスを加え、前述のevaluate関数を呼び出し、SNSシェア数を取得しています。これは、はてブとFacebookはHTTPのURLとHTTPSのURLでデータが分かれているためです。1記事に対して、それぞれ取得したものを合算しています。Twitter(count.jsoon)については、HTTPとHTTPSで分かれていないので、数値が高い方を優先するようにしています。

'use strict';

const AWS = require('aws-sdk');
const s3 = new AWS.S3();
const csv = require('csv');
const toArray = require('stream-to-array');

const social = require('./social');

// HTTPとHTTPSの両方で取得する
const devioUrl = 'http://dev.classmethod.jp';
const devioSslUrl = 'https://dev.classmethod.jp';

module.exports.handler = (event, context, callback) => {

  const record = event.Records[0];
  const bucket = record.s3.bucket.name;
  const key = record.s3.object.key;
  console.log('bucket : ' + bucket + ', key : ' + key);

  const parser = csv.parse({ trim: true, columns: true });
  const transformer = csv.transform((data) => { return data.request_uri; });

  const stream = s3.getObject({
    Bucket: bucket,
    Key: key
  })
  .createReadStream()
  .pipe(parser)
  .pipe(transformer);

  const ctx = {};

  social
  .getFbToken()
  .then(token => {
    ctx.token = token;
    console.log('token : ' + token);
    return toArray(stream);
  })
  .then(uris => {
    return Promise.all(uris.map(uri => {
      const key = 'entry/' + encodeURIComponent(uri.replace('/', ''));
      const targets = [
        devioUrl + uri,
        devioSslUrl + uri
      ];
      return Promise.all(targets.map(target => {
        return social.evaluate(target, ctx.token);
      }))
      .then(results => {
        return Promise.resolve({
          hatena: results[0].hatena + results[1].hatena - 0 ,
          facebook: results[0].facebook + results[1].facebook - 0,
          twitter: Math.max(results[0].twitter - 0, results[1].twitter - 0)
        });
      })
      .then(result => {
        console.log({ key: key, targets: targets, result: result });
        return putObject(key, result);
      });  
    }))
  })
  .then(results => {
    callback(null, results);
  })
  .catch(err => {
    callback(err);
  });
};

const putObject = (key, data) => {
  const params = {
    Bucket: process.env.S3_BUCKET,
    Key: key,
    Body: JSON.stringify(data),
    ContentType: 'application/json'
  };
  return s3.putObject(params).promise();
};

記事のSNSシェア数の取得結果

こうして生成されたSNSシェア数は、下記のようなURLから取得することができます。

$ curl https://eval.classmethod.jp/entry/cloud%252Faws%252Flambda-idempotency%252F
{"hatena":46,"facebook":47,"twitter":46}

こちらを記事のWebページ内(JavaScript)で呼び出し、結果をSNSボタンに反映させています。

まとめ

これからも、読者の皆様がより読みやすくなるような改善を積極的に行なっていきます。どうぞよろしくお願いいたします!