LaravelのCollectionパッケージでPHPの配列操作を劇的に快適にする

こんにちは、池田です。最近家の鍵をsesame4というスマートロックにしました。鍵の向きを考えずにスマートフォンをかざすだけでドアが開くというのは、スマートフォンの充電端子がType-Cになったときと、駅の改札を交通系ICで通れるようになったときを合わせたような感動があります。まあどっちも昔のことで大して覚えていないので、適当言ってるだけなんですが。

本題

さて、皆さんはこの世で最も邪悪なものを知っていますか。そう、PHPでの配列操作のやりにくさですね。どれだけ配列操作がやりにくいかについては、以下の記事を読むのが良いと思います。
PHPを使いもせずDISってる君達へ 
特に記事のPHP Tips 5 : array_filterが歯抜けになるのに気をつけようの現象には、
自分「よし、配列をフィルタして最初の値を取ろう!」
array_filter([1,2,3], fn(int $num) => $num > 1)[0]
PHP「だめだよ」
自分「???」
理由:array_filterされた配列は[1 => 2, 2 => 3]になっており、0のキーは存在しないので取れないから
と何度も苦しめられました。さすがに学習した今でもこの挙動に関しては納得できず、思い出す度にFワードが出そうになります。会社のテックブログじゃなければ出してます。
また、連続で配列操作関数を使用した際のネストの深さ、読みにくさもピカイチです。こちらは先程の記事で一番最初に紹介されてますね。
これは Symfony という素晴らしいフレームワークを使っていても、如何ともし難いものがありました。先程の記事にもあったように、Symfonyでは配列をラップしたDoctrineの Collectionクラス が存在するんですが、メソッドの数が痒い所に手が届かない感じで(ソートができない!)(フラットにできない!)(ユニークにできない!)、複雑な配列操作をしようとするなら一度ネイティブの配列に戻すことが必要になります。なんか気持ち悪いですね。継承してオレオレ実装もあまりしたくありません。絶対バグを生み出すので。
ならばどうすれば良いのか……?ここで本題です。LaravelのCollectionパッケージを使いましょう。

何が良いのか

公式ページ にアクセスして、メソッド一覧を見てください。たくさんのメソッドありますね。131個あるらしいです。これだけ数があるということは、ニーズにあったメソッドも存在する可能性が高いということです。
例えば、先程挙げた3個の「ソート」「フラット」「ユニーク」は全て用意されたメソッドで可能です。また、無駄にネストを深くする必要もありません。
collect([1, [2, 3, [4, 2]]])
  ->flatten()
  ->unique()
  ->sort();
上記のように、非常にシンプルな記述をすることが可能です。
中でも、私がイチオシのメソッドは mapWithKeys です。皆さんも幼稚園くらいの頃に「以下のように連想配列に変換したいな……」と考えたことがあると思います。
↓これを
[
    [
        'name' => 'John',
        'department' => 'Sales',
        'email' => 'john@example.com',
    ],
    [
        'name' => 'Jane',
        'department' => 'Marketing',
        'email' => 'jane@example.com',
    ]
]
↓こうする
[
    'john@example.com' => 'John',
    'jane@example.com' => 'Jane',
]
ネイティブの配列操作関数でやるなら、恐らくarray_reduceでやる必要があると思います。ですが読みにくくなるので、使わないに越したことはありません。LaravelのCollectionを使えば以下のように簡単に変換することができます。
$collection = collect([
    [
        'name' => 'John',
        'department' => 'Sales',
        'email' => 'john@example.com',
    ],
    [
        'name' => 'Jane',
        'department' => 'Marketing',
        'email' => 'jane@example.com',
    ]
]);

$keyed = $collection->mapWithKeys(function ($item, $key) {
    return [$item['email'] => $item['name']];
});

$keyed->all();

/*
    [
        'john@example.com' => 'John',
        'jane@example.com' => 'Jane',
    ]
*/
別の言語だとJavaScriptは結構配列操作がやりやすいと思っているんですが、それでもこの変換をここまでシンプルにはできなかったと思うので、mapWithKeysには驚きました。 しかし、中にはLaravelを使っていない人もいるでしょう。Laravelのパッケージをまるごと入れてCollectionだけ使うしかないのでしょうか。当然そんなことはありません。以下のコマンドでこれだけ(+α)導入可能です。
composer require illuminate/support
これにより、Symfony環境であってもLaravelのCollectionの素晴らしさを享受することができます。
……え、フレームワーク使ってない?Composerなんてない?……それは……その……どうすればいいんでしょうね……?

まとめ

  • PHPネイティブの配列操作はやりにくい
  • LaravelのCollectionパッケージを使えば途端にやりやすくなる
  • 単体(+α)導入も可能

一度に1つのことを(リーダブルコードより)

こんにちは。3行コードを書くとそれより前に書いたコードの記憶が飛んでいる池田です。
決して口開けて空眺めながらコードを書いているわけではないと弁明させてほしいんですが、それでも1日経てばもう終わりです。昨日書いたコードの記憶は消し飛び、「このコード書いたの誰だよ…。…俺か…」と自己嫌悪の波に飲まれ……ることはありませんが、自分の書いたコードの解釈に体力を使うことになります。過去の自分に苦しめられることほど腹の立つことはありません。ではどうすれば良いか。読みやすいコードを書けばいいんです。簡単ですね。何が簡単なんだ。

リーダブルコード――より良いコードを書くためのシンプルで実践的なテクニック

https://www.oreilly.co.jp/books/images/picture_large978-4-87311-565-8.jpeg これは私が新卒の頃に借りて読んだ本です。新卒の頃は今の100分の1程度の技術力しかありませんでしたし、初版も2012年(原著は2011年)と古めですが、それでもこの本は非常に読みやすかったです。それから数年経ち、改めてまた読みたくなったので自分で買いました。この本の内容は現在も通用し、全て重要で、どの部分が一番であるといった優劣は付け難いんですが、今回はこの本の中から私が一番気を付けていることを紹介します。タイトルにもある一度に1つのことをです。

一度に1つのことを

本よりの引用ですが、以下の2つのJavaScriptコードを見比べてみてください。vote_changedはユーザがブログで投票するときに使われる関数です。初版が10年近く前なのでconstではなくvarを使っていたりしますが、原文ママで載せています。皆さんはconstを、定数ではどうしようもないときだけletを使用してください。letのほうが読みやすいし、くらいの理由ならconstを使ってください(過激派)。
var vote_changed = function(old_vote, new_vote) {
    var score = get_score();

    if (new_vote !== old_vote) {
        if (new_vote === 'Up') {
            score += (old_vote === 'Down' ? 2 : 1);
        } else if (new_vote === 'Down') {
            score -= (old_vote === 'Up' ? 2 : 1);
        } else if (new_vote === '') {
            score += (old_vote === 'Up' ? -1 : 1);
        }
    }
    
    set_score(score);
}
var vote_value = function (vote) {
    if (vote === 'Up') {
        return +1;
    }
    if (vote === 'Down') {
        return -1;
    }
    return 0;
}

var vote_changed = function (old_vote, new_vote) {
    var score = get_score();
    
    score -= vote_value(old_vote); // 古い値を削除する
    score += vote_value(new_vote); // 新しい値を追加する
    
    set_score(score);
}
恐らく下のほうが読みやすいと感じたと思います。
「下のほうはコメントがあるからずるい!」という意見が聞こえてきました。ではscore += (old_vote === 'Down' ? 2 : 1);にコメントを付けるとすると、どう付ければ良いでしょうか。少なくとも「古い値を削除する」だけでは実態に合っていません。下は一度に「古い値を削除する」というひとつのことが行われているからこそ、これほど単純なコメントを付けるだけで済んでいます。
バグがあったとしても、上の方は「一番最初の条件分岐か?それとも2個目?それとも三項演算子?」と考えることが多くありますが、下の方は「多分vote_valueの条件分岐だろう」くらいには当たりを付けることができます。

最後に

今回書いた内容はとても基本的なことで、「この世に生を受けたときからこんなの知ってるわ」という方もいると思います。実際、この本の内容は非常に基本的なことが紹介されていて、ドメイン駆動設計クリーンアーキテクチャといった完璧な秩序をプロジェクトにもたらす内容、というものではないと思います。ただ、この本の内容を完璧に守ったコードがあったとしたら、それは高尚な設計思想が使われていなくても非常に読みやすいコードだと思います。
簡単な内容で200ページとちょっとなので、一度図書館とかで借りて流し読みしてみましょう。それだけでもきっと明日の自分が書くコードが少しだけ晴れ晴れとして見えると思います。

ついでに

株式会社イメージ・マジックではエンジニアを募集しています。PHP未経験者でも大丈夫です。
「PHPはちょっとなあ…」という方もいるかもしれませんが、弊社ではSymfonyというMVCフレームワークを採用しており、JavaのSpring BootやRuby on Railsと同じような書き味ですし、何より使いやすいです。PHPはわかりませんが、Symfonyのことはきっと好きになれると思います。

puppeteerでスクレイピングしてブログの投稿を検知する

こんにちは、イメージ・マジックの池田です。
今回はタイトルにもある通り、puppeteerのスクレイピング(とついでにブログの投稿検知)についてやっていきます。なぜなら最近プライベートで触ったからという、とても個人的な理由です。

puppeteerとは?

puppeteerというのは、Node.jsを用いてスクレイピングをするライブラリです。
はい、死ぬほど雑な説明をしました。違うわ!とまではいかないまでもお叱りを受けそうですね。詳しくは公式サイトをご覧ください。公式の英語サイトに投げる暴挙。とにかくNode.jsでスクレイピングのできるライブラリです。しかもChromeを作っているGoogle製。使わない理由はありませんね。

どうやって使うの?

  1. Node.jsの最新版をインストールします
  2. 好きな場所にフォルダを作り、npm init -yと打ち、npm i puppeteerします
用意はこれだけです。試しに公式サイトに載っているコードを実行してみましょう。同じフォルダにexample.jsを作成し、以下のコードを打ち込み、PowerShellなどからnode example.jsで実行してみてください。
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  await page.screenshot({ path: 'example.png' });

  await browser.close();
})();
同じフォルダにexample.pngというファイルが作成されたと思います。ファイルには見知らぬサイトのスクリーンショットが。puppeteerがブラウザを画面に出したりせずに撮ってきてくれたスクリーンショットです。実際にこのページにアクセスしてみると、画像そのままのサイトが表示されたと思います。
これを応用することで、ブログの投稿を検知することが可能となります。RSSがあればこういうことをしなくても大丈夫だったりしますが、そもそもRSSが下火だったり、RSSを提供するようなサイトじゃなかったりもしますからね。

プログラムの流れ

前提条件:昨日や一週間前に取ってきたタイトル一覧を手元に持っている(持っていなければ、下記手順の1~2、5だけを行います)
  1. ブログにアクセスする
  2. タイトル一覧を取得する
  3. 手元で持っているタイトル一覧と照合する
  4. 手元にないタイトルがあれば、そのタイトルを通知する
  5. 最新のタイトル一覧を手元に保存する
  6. おわり
簡単ですね。これをTypeScriptで書いたコードが下記です。私はVisual Studio Codeと組み合わせて使うTypeScriptが大好きなので、本来ならばこれの宣伝もしたいのですが、TypeScriptの素晴らしさは恐らく全ての生物が既に知っているので、ここでは取り扱いません。
import puppeteer, { Page } from "puppeteer";
import { readFileSync, writeFileSync } from "fs";
import csvParse = require("csv-parse/lib/sync")
import csvStringify = require("csv-stringify/lib/sync")
import { difference } from "./setUtils";

const CURRENT_FILE_NAME = 'current.csv';
const TITLE_SELECTOR = '.entry-title';

const readCurrentCsv = (): Set<string> | null => {
  try {
    const file = readFileSync(CURRENT_FILE_NAME);
    return new Set((csvParse(file) as Array<Array<string>>).flat());
  } catch (error) {
    // Error NO ENTryらしいです
    if (error.code === 'ENOENT') {
      return null;
    } else {
      throw new Error("知らんエラー。君誰?");
    }
  }
}

const fetchTitles = async (page: Page): Promise<Set<string>> => {
  const elements = await page.$$(TITLE_SELECTOR);
  return new Set(await Promise.all(elements.map(async ele => (await ele.getProperty('textContent'))!.jsonValue())) as Array<string>);
}

const writeCsv = (newTitles: Set<string>) => {
  writeFileSync(CURRENT_FILE_NAME, csvStringify([...newTitles].map(title => [title])));
}

const notify = (diffTitles: Set<string>) => {
  console.info(`
「${[...diffTitles].join('、')}」が開発者ブログに投稿されました。見てみましょう!
https://techblog.imagemagic.jp/
`.trim());
  // みたいのを通知したりしたいよね
}

const main = async (page: Page) => {
  const currentTitles = readCurrentCsv();
  const newTitles = await fetchTitles(page);

  if (currentTitles === null) {
    writeCsv(newTitles);
    return;
  }

  const diffTitles = difference(newTitles, currentTitles);
  if (!diffTitles.size) {
    return;
  }

  notify(diffTitles);
  writeCsv(newTitles);
}

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://techblog.imagemagic.jp/');

  try {
    await main(page);
  } finally {
    await browser.close();
  }
})();
main関数の中身を見れば、先程のプログラムの流れと大体同じなことがわかると思います。このプログラムをたとえばAWSのLambdaに乗せて、CloudWatchで定期的に叩くなどすれば、差分検知通知システムの完成です。
細かいコードの解説は省きます。皆さんも興味のあるサイト(たとえば本テックブログ!)をスクレイピングしたりして、実際に触ってみてください。

※スクレイピングは少し油断するとアクセス負荷を相手のサーバーにかけてしまったり、そもそもスクレイピングを許可していないサイトもあります。使い方を間違えれば違法行為です。この辺りのサイトを見つつ、用法用量を守って正しくお使いください。