このエントリーをはてなブックマークに追加

はじめに

タイトルのとおりですが、意外と苦労したのでやり方をまとめておきます。

使う道具

  • CirleCI 2.0
  • markdown-pdf
    • MarkdownをPDFに変換するためのCLIツール
    • npmでインストール
  • gdrive
    • Googleドライブを操作するためのCLIクライアント
    • PDFのアップロードに使う
    • バイナリをダウンロードしてインストール
    • ソース(golang)をコンパイルしてインストール(後述)

具体的な方法

事前準備

CircleCIからGoogleドライブにファイルをアップロードするためには、サービスアカウント での認証が必要なので、以下のとおり準備しておきましょう。

  1. Google API Console でサービスアカウントを作る
  2. デプロイ先にしたいGoogleドライブ上のフォルダを、サービスアカウントに共有する image
  3. CircleCIのプロジェクト設定で、サービスアカウントの認証情報のJSON文字列を一行化して環境変数として登録する image

markdown-pdfでのPDF生成方法

例えば、src/ 配下にMarkdownファイル群が階層構造で格納されているとして、それらを同じ階層構造で build/ 配下に拡張子だけを .pdf に変えて出力したい場合、以下のようなシェルスクリプトで実現できます。

for file in `find src -type f` ; do
    name=`echo $file | sed -r 's/src\\/(.*)\\.md/\\1/'` # src/<ファイル名>.md から <ファイル名> を取り出す
    npx markdown-pdf -s css/style.css -o build/$name.pdf $file
done

-s css/style.css でレンダリングに使用するCSSファイルを指定しています。これは本来は必須ではありませんが、こちらで指摘されているとおり、デフォルトのCSSだと [text](url) というMarkdownが text (url) という表記に変換されてしまうため、これを回避するために最低限以下のCSSを当てる必要があります。

abbr[title]:after,
a[href]:after {
    content: "";
}

GitHub風のCSSを適用

markdown-pdfはMarkdownパーサーとして remarkable を使っており、remarkableはデフォルトで GFM(GitHub Flavored Markdown)をコンパイルできるようです

せっかくなのでGitHub風のCSSを適用して、それらしい見た目でPDFが生成されるようにしておきましょう。

とりあえず今回は泥臭く、github-markdown-cssのcss をコピペして .markdown-bodybody に置換しました。フォントサイズも必要に応じて変更するとよいでしょう。

body に置換すればいいと判断したのは、markdown-pdfのデフォルトのcss がそうなっていたからです。

ワンライナー化してpackage.jsonにscriptsとして追加

以下のようにワンライナー化して、npm scriptsから実行できるようにしておくと便利です。(ついでに、ビルド時に build/ 配下を一旦全削除するようにしてあります)

{
  "dependencies": {
    "markdown-pdf": "^8.1.1"
  },
  "scripts": {
    "build": "rm -rf build/* && for file in `find src -type f` ; do npx markdown-pdf -s css/style.css -o build/`echo $file | sed -r 's/src\\/(.*)\\.md/\\1/'`.pdf $file ; done"
  }
}

gdriveでのアップロード方法

gdriveコマンドで build/ ディレクトリを丸ごとGoogleドライブの特定のフォルダ配下にアップロードする方法は、以下のとおりです。

gdrive --config $(pwd) --service-account credential.json upload -p <ここにデプロイ先フォルダのID> -r build

credential.json が置かれている場所を --config で指定する必要があります。--config の値はデフォルトでは $HOME/.gdrive になっています。ここでは、credential.json がカレントディレクトリに置いてある想定で、 $(pwd) としています。

後述しますが、--service-account オプションは2018/05/14現在の最新版v2.1.0にはまだ入っていません。最新のソースコードをコンパイルしてインストールしないと使えませんので、ご注意ください。

CircleCIの設定ファイル

上記を踏まえて、CircleCIの設定ファイル .circleci/config.yml を書いてみます。

キャッシングなどを省いて処理の要点だけを書くと、以下のような内容になります。(書式の詳細については 公式リファレンス をご参照ください)

version: 2
jobs:

  # markdown-pdfを使ってPDFを生成するジョブ
  build:
    docker:
      - image: circleci/node
    working_directory: ~/wd
    steps:
      # 日本語のフォントがないと、PDF生成時に日本語部分がレンダリングされない
      - run: sudo apt-get update && sudo apt-get install fonts-ipaexfont -y
      - checkout
      - run: npm i
      - run: npm run build
      # ビルドの成果物(PDFが入っているディレクトリ)をキャッシュしてデプロイジョブに渡す
      - save_cache:
          key: build-{{ .Revision }}
          paths:
            - ~/wd/build

  # gdriveを使ってPDF(が入っているディレクトリ)をデプロイするジョブ
  deploy:
    docker:
      # gdriveをソースからコンパイルするのでgolang環境が必要
      - image: circleci/golang
    working_directory: ~/wd
    steps:
      # gdriveをインストール
      - run: go get github.com/prasmussen/gdrive
      # 成果物をリストア
      - restore_cache:
          keys:
            - build-{{ .Revision }}
      - deploy:
          command: |
            # 環境変数から認証情報を取得してJSONファイルに出力
            echo $GOOGLE_SERVICE_ACCOUNT_CREDENTIAL > credential.json
            # 成果物のディレクトリ名を日時でリネーム
            dirname=`date +%Y%m%d_%H%M%S` && mv build $dirname
            # 成果物のディレクトリを丸ごとデプロイ
            gdrive --config $(pwd) --service-account credential.json upload -p <ここにデプロイ先フォルダのID> -r $dirname

workflows:
  version: 2
  build_and_deploy:
    jobs:
      - build
      - deploy:
          requires:
            - build

build ジョブでPDFを生成して、deploy ジョブでGoogleドライブにデプロイしています。

日本語フォントが何も入っていない環境だとPDFを生成するときに日本語部分がレンダリングされないので、build ジョブのはじめで fonts-ipaexfont をインストールしています。インストールするフォントは何でもOKです。

deploy ジョブのほうでは、CircleCIのコンテナにサービスアカウントの認証ファイルを設置するために、事前準備で登録しておいた環境変数の中身をJSONファイルに書き出しています。また、build というディレクトリ名のままだと分かりにくいので、日時の名前にリネームしてからデプロイするようにしています。

gdriveの最新バージョンがリリースされた暁には

なお、gdriveをソースからコンパイルしてインストールしていますが、これはgdriveの --service-account 機能が まだバイナリとしてリリースされておらず、最新のソースを自分でコンパイルしないと使えない が故の措置です。(README からダウンロードできるv2.1.0には、この機能は入っていません)

v2.2.0(多分)がリリースされた暁には、インストールはバイナリをダウンロードしてくるだけでよくなるため、golang環境も不要になり、以下のようにジョブを1つにまとめることができるようになります。

version: 2
jobs:
  build:
    docker:
      - image: circleci/node
    steps:
      - run: sudo apt-get update && sudo apt-get install fonts-ipaexfont -y
      - checkout
      # gdriveのバイナリをダウンロードして、チェックサムを確認して、実行パーミッションをつける
      - run: |
          wget "<ここにgdrive-linux-x64のダウンロードURL>" -O gdrive
          [ `sha1sum gdrive | awk '{print $1}'` = '<ここにgdrive-linux-x64のshasum>' ]
          chmod +x gdrive
      - run: npm i
      - run: npm run build
      - deploy:
          command: |
            echo $GOOGLE_SERVICE_ACCOUNT_CREDENTIAL > credential.json
            dirname=`date +%Y%m%d_%H%M%S` && mv build $dirname
            ./gdrive --config `pwd` --service-account credential.json upload -p <ここにデプロイ先フォルダのID> -r $dirname

実装例

以下のGitHubリポジトリに実装例を上げてみたので、参考にしてみてください。

https://github.com/ttskch/markdown-pdf-googledrive-ci-sample

実際に動かすと、以下のように日時のフォルダ配下に成果物がデプロイされます。

image

おわりに

簡単なことのようで意外とつまずきポイントが多かったので自分のためのメモも兼ねてまとめてみました。
どこかの誰かのお役に立てば幸いです。


このエントリーをはてなブックマークに追加

はじめに

以前の記事で、CLIツールをPharファイルで作成する手順を簡単に紹介しました。
今回はPharファイルの作成と配布までをCircleCIで自動化する手順を紹介したいと思います。

まずは、Pharファイルを生成処理をbuildコマンド、GitHubへのリリース処理をreleaseコマンドとして、Composerのscriptsを利用して実装したいと思います。
最後にそれらをCircleCIから呼び出すような設定ファイル(.circleci/config.yml)を作成したいと思います。

buildコマンドの実装

Pharファイルを生成する処理を、buildコマンドとしてまとめます。

composer.json

Boxを利用してPharファイルの作成をcomposer-scriptsbuildコマンドとして作成します。

"scripts": {

    "build": [
       "rm -rf ./dist",
       "mkdir -p ./dist",
       "php -d phar.readonly=0 ./bin/box.phar build"
    ],

box.json

上記のbuildコマンドは、distディレクトリにPharファイルが作成されることを期待した内容になっていますので、以前のブログ記事で作成したbox.jsonの内容を以下のように修正します。

 {
     "alias": "hello.phar",
     "chmod": "0755",
     "compactors": [],
     "directories": ["src", "vendor"],
     "main": "main.php",
-    "output": "hello.phar",
+    "output": "dist/hello.phar",
     "stub": true
 }

releaseコマンドの実装

PharファイルをGitHubリリースページへアップロードする処理を、releaseコマンドとしてまとめます。

composer.json

リリースの前には、buildコマンドが実行されるように設定し、必ずPharファイルが作成してある状態になるようにしてあります。
その後、ghrというツールを使って、ビルドしたPharファイルをGitHubのリリースページにアップロードしています。

"scripts": {

    "release": [
        "@build",
        "./bin/ghr -u qcmnagai v$(./dist/hello.phar --version | cut -d \" \" -f 2) ./dist"
    ]

ghr

簡単にGitHubへリリースするためのGolang製CLIツールです。
タグとバイナリファイルが含まれるディレクトリを指定するだけで、GitHubへリリースをしてくれます。
今回は構成を簡単にする為にbin/ghrにバイナリ配置し、Gitリポジトリにも含めています。

タグについて

hello.phar --version でバージョン番号を表示させて、cutコマンドでバージョン番号部分だけ抜き出して、GitHubのタグとしてghrに渡すようにしています。
これにより、PHPのソースコードを修正するだけで、タグの追加からリリースページの作成まで、自動的に行なってくれるようになります。

ちなみにghrはデフォルトで、既に存在するタグに対する更新はエラーになりますので、一度リリースされたバージョンが上書きされるようなことはありません。
これでバージョン番号を更新し忘れても、安心ですね。

CircleCIの設定ファイル

今回は、qcmnagai/hello-phpというリポジトリで作成しましたので、以下のようになりました。

.circleci/config.yml

version: 2
jobs:
  build:
    docker:
      - image: circleci/php:7.0-node-browsers
    working_directory: ~/hello-php
    steps:
      - checkout
      - restore_cache:
          key: composer-v1-{{ checksum "composer.json" }}
      - run: composer install --no-interaction --prefer-dist --dev
      - save_cache:
          key: composer-v1-{{ checksum "composer.json" }}
          paths:
            - bin
            - vendor
      - run: composer test
  release:
    docker:
      - image: circleci/php:7.0-node-browsers
    working_directory: ~/hello-php
    steps:
      - checkout
      - restore_cache:
          key: composer-v1-{{ checksum "composer.json" }}
      - run: composer install --no-interaction --prefer-dist --no-dev
      - run: composer release

workflows:
  version: 2
  test_and_release:
    jobs:
      - build
      - release:
          requires:
            - build
          filters:
            branches:
              only: master

masterブランチへプッシュされた時だけ、releaseジョブが走るようになっています。
リリース処理をcomposer releaseコマンドでまとめたのでスッキリしていますね。

おわりに

このようにしておけば、GitHubのmasterブランチへマージしたタイミングで、ビルドからGitHubのリリースページへのアップロードをCircleCIが自動でやってくれます。
利用する際には、GitHubのリリースページからPharファイルダウンロードしてそのまま実行することが出来るようになり、とても便利でオススメです。


カルテット開発部では、小さな業務でも自動化して周りを幸せにしたいエンジニアを募集しています!


このエントリーをはてなブックマークに追加

Ray.Di を知っていますか?
私は普段の開発ではSymfonyを利用しているので、Symfonyの DependencyInjection 機能を使っていますが、最近Symfonyフレームワークを使うほどでない小さなcliアプリで実際に使ってみました。初めて使う場合に戸惑うところ・引っかかったところがあるので、使い方をまとめました。

Ray.Diとは

Javaの Guice というDIフレームワークの主要な機能を網羅したPHPのDIフレームワークです。
PHPフレームワークの BEAR.Sunday で使われていますが、単独でも利用できます。
BEAR.Sundayで使う場合は専用のアノテーションを使うことが多いのですが、アノテーションを使わずにPHPコードだけでも設定できるので、サードパーティのクラスを使う場合でも問題なく利用することができます。

Ray.Diを使う

早速使ってみましょう。

例として時間帯によって違う挨拶を返すcliアプリを作ってみることにします。
環境変数 lang によって言語を切り替える機能がついています。

# 朝9時に実行
$ php scripts/run.php 77web
Good morning, 77web.

DIを使わずに書いてみると、アプリケーションの入口になる scripts/run.php が依存関係のコードで雑然としていますね。
https://github.com/77web/ray-di-sample/tree/no-di/scripts/run.php

#!/usr/bin/env php
<?php

use PHPMentors\DomainCommons\DateTime\Clock;
use Psr\Log\NullLogger;
use Quartetcom\RayDiSample\Command\GreetingCommand;
use Quartetcom\RayDiSample\Greeter;
use Quartetcom\RayDiSample\GreetingFormatter;
use Quartetcom\RayDiSample\GreetingRegistry;
use Quartetcom\RayDiSample\TimeDetector;
use Symfony\Component\Console\Application;
use Symfony\Component\Yaml\Yaml;

require __DIR__.'/../vendor/autoload.php';

ini_set('date.timezone', 'Asia/Tokyo');
if (!getenv('lang')) {
    putenv('lang=de');
}

$timeDetector = TimeDetector::createFromConfig([
    'morning' => range(4, 10),
    'day' => range(11, 15),
    'night' => array_merge(range(0, 3), range(16, 23)),
]);
$greetings = Yaml::parseFile(__DIR__.'/../src/Resources/config/greetings.yml')['greetings'];
$lang = getenv('lang');
$greetRegistry = new GreetingRegistry($greetings[$lang]);
$greeter = new Greeter($greetRegistry, $timeDetector, new GreetingFormatter('%greeting%, %name%.'), new Clock());
$command = new GreetingCommand($greeter, new NullLogger());

$app = new Application();
$app->add($command);
$app->setDefaultCommand($command->getName(), true);

$app->run();

Ray.Diを使って書き直していきます。

composerでray/diを入れる

composerで追加します。

$ composer require ray/di

Moduleクラスを作る

DI設定をするためのModuleクラスを作ります。
Ray\Di\AbstractModule クラスのサブクラスにする必要があります。

<?php
namespace Quartetcom\RayDiSample\DependencyInjection;

use Ray\Di\AbstractModule;

class GreeterModule extends AbstractModule
{
}

DIを設定する

いよいよ GreeterModule に実際にDIの設定を書いていきましょう。
AbstractModule には抽象メソッドとして configure() メソッドが定義されており、実際のDI設定は GreeterModule::configure() メソッドに記述します。

<?php
// ...

class GreeterModule extends AbstractModule
{
+    public function configure()
+    {
+    }
}

コンストラクタ引数がない、インターフェイスで指定されたクラスの設定 to()

一番基本の書き方です。
bind() にインターフェイス名、 to() に実際に利用する具象クラス名を指定します。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(LoggerInterface::class)->to(NullLogger::class);
    }
}

コンストラクタ引数がない具象クラスの設定 bind()

bind() にクラス名を指定します。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(Clock::class);
    }
}

コンストラクタで他のクラスのインスタンスだけを注入される具象クラスの設定 bind()

依存クラスをすべて設定した上で、 bind() にクラス名を指定します。
設定を記述する順序は自由なので、前後しても大丈夫です。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(LoggerInterface::class)->to(NullLogger::class);
+        $this->bind(GreetingCommand::class); // GreeterとLoggerInterfaceに依存
+        $this->bind(Greeter::class); // 依存対象を後に書いても良い
    }
}

独自のファクトリが存在するクラス toInstance()

toInstance() を使います。
bind() にクラス名を指定し、 toInstance() にファクトリで作成した実際のクラスのインスタンスを指定します。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(TimeDetector::class)->toInstance(TimeDetector::createFromConfig([
+            'morning' => range(4, 10),
+            'day' => range(11, 15),
+            'night' => array_merge(range(0, 3), range(16, 23)),
+        ]));
    }
}

コンストラクタでパラメータ(クラスでなく単なる値)を注入されるクラスの設定 toConstructor()

bind()toConstructor() の両方にクラス名を指定し、 toConstructor() の第2引数でコンストラクタ引数の変数名と仮想クラス名(パラメータ名)の対応を指定します。
実際に渡したい値は、 annotatedWith() に仮想クラス名を指定し、さきほどの toInstance() を使って設定します。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(GreetingFormatter::class)->toConstructor(GreetingFormatter::class, [
+            'format' => 'greeting_formatter_format',
+        ]);
+        $this->bind()->annotatedWith('greeting_formatter_format')->toInstance('%greeting%, %name%.');
    }
}

使うインスタンスを切り替える

toProvider() を使います。 まず、Ray\Di\ProviderInterface を実装した GreetingRegistryProvider クラスを追加します。
Providerは、引数なしの get() メソッドから目的のクラスのインスタンスを返すように実装します。

<?php

namespace Quartetcom\RayDiSample\DependencyInjection;

use Quartetcom\RayDiSample\GreetingRegistry;
use Ray\Di\Di\Named;
use Ray\Di\ProviderInterface;
use Ray\Di\SetContextInterface;

class GreetingRegistryProvider implements ProviderInterface, SetContextInterface
{
    /**
     * @var array
     */
    private $greetings;

    /**
     * @var string
     */
    private $context;

    /**
     * Providerクラスに何かの値をDIしたい場合は@Namedアノテーションが必要
     *
     * @Named("greetings=greeting_registry_greetings")
     * @param array $greetings
     */
    public function __construct(array $greetings)
    {
        $this->greetings = $greetings;
    }

    /**
     * @param string $context
     */
    public function setContext($context)
    {
        $this->context = $context;
    }

    /**
     * @return GreetingRegistry
     */
    public function get()
    {
        return new GreetingRegistry($this->greetings[$this->context]);
    }
}

注意すべきなのはProviderクラスに何かの値をDIしたい場合は @Named アノテーションを使う必要があるということです。 @Named アノテーションは通常、同じクラスのインスタンスを複数使用する場合などにDI対象を特定するために使うものです。通常のクラスなら annotatedWith() を用いてModule側で指定可能ですが、Providerには @Named を使って指定しないといけないようです。
ソースコードにDIのための概念が入ってしまいますが、ProviderはModuleと同様にRay.Diを使うのをやめた場合は使わないクラスなので問題ないでしょう。

Providerクラスができたら、GreeterModuleでは bind() にクラス名を指定し、 プロバイダークラス名を toProvider() に指定します。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
+        $this->bind(GreetingRegistry::class)->toProvider(GreetingRegistryProvider::class, getenv('lang')); // getenv('lang')はGreetingRegistryProvider::$contextになる
+        $this->bind()->annotatedWith('greeting_registry_greetings')->toInstance(Yaml::parseFile(__DIR__.'/../Resources/config/greetings.yml')['greetings']);
    }
}

注入されたクラスを実際に使う

最終的な GreeterModule::configure() は下記のようになりました。

<?php
// ...

class GreeterModule extends AbstractModule
{
    public function configure()
    {
        $this->bind(TimeDetector::class)->toInstance(TimeDetector::createFromConfig([
            'morning' => range(4, 10),
            'day' => range(11, 15),
            'night' => array_merge(range(0, 3), range(16, 23)),
        ]));
        $this->bind(Clock::class);
        $this->bind(Greeter::class);
        $this->bind(GreetingFormatter::class)->toConstructor(GreetingFormatter::class, [
            'format' => 'greeting_formatter_format',
        ]);
        $this->bind()->annotatedWith('greeting_formatter_format')->toInstance('%greeting%, %name%.');
        $this->bind(GreetingRegistry::class)->toProvider(GreetingRegistryProvider::class, getenv('lang'));
        $this->bind()->annotatedWith('greeting_registry_greetings')->toInstance(Yaml::parseFile(__DIR__.'/../Resources/config/greetings.yml')['greetings']);
        $this->bind(LoggerInterface::class)->to(NullLogger::class);
        $this->bind(GreetingCommand::class);
    }
}

では、 scripts/run.php で、DI設定に従って GreetingCommand のインスタンスを取得してみましょう。

<?php

// ...

-$timeDetector = TimeDetector::createFromConfig([
-    'morning' => range(4, 10),
-    'day' => range(11, 15),
-    'night' => array_merge(range(0, 3), range(16, 23)),
-]);
-$greetings = Yaml::parseFile(__DIR__.'/../src/Resources/config/greetings.yml')['greetings'];
-$lang = getenv('lang');
-$greetRegistry = new GreetingRegistry($greetings[$lang]);
-$greeter = new Greeter($greetRegistry, $timeDetector, new GreetingFormatter('%greeting%, %name%.'), new Clock());
-$command = new GreetingCommand($greeter, new NullLogger());

+/** @var GreeterCommand $command */
+$command = new Injector(new GreeterModule())->getInstance(GreetCommand::class);

// ...

DIなし に比べて Ray.Diを使った版 はスッキリしましたね!
編集したのは 3ファイルだけ なので簡単に導入できました。

まとめ

Ray.Diを使うことで scripts/run.php での処理の流れは圧倒的に追いやすくなりました。
ライブラリとして実装したクラスに全くDI専用のアノテーションを書かなくても使えるので、Ray.Di用のModule(とProvider)を追加するだけで気軽に試すことができますし、他のDIフレームワークへの切り替えも楽にできそうです。