ぺのめも

Web開発を勉強中。学んだことや思ったことなど

Ruby のメソッドのソースコード(C言語)を初めて読んだ

この記事は、フィヨルドブートキャンプ Part 1 Advent Calendar 2023 の 18 日目の記事です。

はじめに

自分はここ 2 年ほど Ruby on Rails を学習しており、個人開発で作った Web アプリ(これ)も、Rails で開発しています。
先日、Ruby の基礎を改めて復習しようと Ruby Silver の試験を受けたのですが、試験に向けた学習で色々なメソッドを触っているうちに、「これらの実装をのぞいてみたいな……?」と思い立ちました。
初めて Ruby 本体のリポジトリを clone してきて、ふんわりだけど読めた!というのはひとつの嬉しい体験だったので、残しておこうと思います。

こちらの記事のおかげ

こちらの Qiita 記事を見つけたおかげで、ひとまず読んでみちゃおう〜という気持ちになれました。ありがたいです。

qiita.com

String#succ を読んでみる🙋‍♀️

String#succ の実装を読んでみることにしました。
Ruby マニュアルの説明:

self の「次の」文字列を返します。
「次の」文字列は、対象の文字列の右端からアルファベットなら アルファベット順(aの次はb, zの次はa, 大文字も同様)に、数字なら 10 進数(9 の次は 0)とみなして計算されます。

なんかおもしろいな〜と思ったのと、「次の」ってどういう定義なんだろう? と思ったのでチョイス。

# 基本的な例 
p "aa".succ        # => "ab"
p "111".succ        # => "112"

# 文字、数字ともに繰り上がりが起きる
p "9".succ    # => "10"
p "zz".succ   # => "aaa"

# 数字と文字が混在していても繰り上がる
p "1z".succ    # => "2a"
p "a9".succ   # => "b0"
p "AZ".succ   # => "BA"

# 文字が続けば記号をまたいで繰り上がる
p "a?Z".succ # => # "b?A"
p "a?9".succ # => # "a?10"

# 文字と数字と記号が混在していれば、文字同士または数字同士続くところまで繰り上がる
p "a.9.9".succ # => # "a.10.0"

# 記号だけの文字列のときは、次の記号になる
p "..".succ     # => "./"

# 符号は無視される
p "-9".succ   # => "-10" 

# 空文字の時は空文字を返す
p "".succ     # => ""

実装コードを確認する

Ruby のリポジトリからソースコードを clone し、読んでいきます。2023/12/18 時点の安定版である 3.2.2 の Git タグを参照しています。

先述の Qiita によると

Rubyソースコードとクラスは大体、対応しています。 Array クラスなら array.c ファイル、 String クラスなら string.c ファイルで実装されています。

とのことでした。
string.c の中で検索してみると、succ メソッドの定義が見つかりました。(該当箇所
rb_str_succという関数に実体が定義されているみたい。

rb_define_method(rb_cString, "succ", rb_str_succ, 0);
// 略
rb_define_method(rb_cString, "next", rb_str_succ, 0);

同じく string.c 内に、rb_str_succ関数の定義がありました。 (該当箇所
「『次の文字』とは何か、が分かる」を目指して、この関数を読み進めていきます。
(ちなみに自分の場合、C言語は学生の時にちょっぴり触れたくらいです。)

VALUE
rb_str_succ(VALUE orig)
{
    VALUE str;
    str = rb_str_new(RSTRING_PTR(orig), RSTRING_LEN(orig));
    rb_enc_cr_str_copy_for_substr(str, orig);
    return str_succ(str);
}

static VALUE
str_succ(VALUE str)
{
    rb_encoding *enc;
    char *sbeg, *s, *e, *last_alnum = 0;
    int found_alnum = 0;
    long l, slen;
    char carry[ONIGENC_CODE_TO_MBC_MAXLEN] = "\1";
    long carry_pos = 0, carry_len = 1;
    enum neighbor_char neighbor = NEIGHBOR_FOUND;

    slen = RSTRING_LEN(str);
    if (slen == 0) return str;

    enc = STR_ENC_GET(str);
    sbeg = RSTRING_PTR(str);
    s = e = sbeg + slen;

    while ((s = rb_enc_prev_char(sbeg, s, e, enc)) != 0) {
        if (neighbor == NEIGHBOR_NOT_CHAR && last_alnum) {
            if (ISALPHA(*last_alnum) ? ISDIGIT(*s) :
                ISDIGIT(*last_alnum) ? ISALPHA(*s) : 0) {
                break;
            }
        }
        l = rb_enc_precise_mbclen(s, e, enc);
        if (!ONIGENC_MBCLEN_CHARFOUND_P(l)) continue;
        l = ONIGENC_MBCLEN_CHARFOUND_LEN(l);
        neighbor = enc_succ_alnum_char(s, l, enc, carry);
        switch (neighbor) {
          case NEIGHBOR_NOT_CHAR:
            continue;
          case NEIGHBOR_FOUND:
            return str;
          case NEIGHBOR_WRAPPED:
            last_alnum = s;
            break;
        }
        found_alnum = 1;
        carry_pos = s - sbeg;
        carry_len = l;
    }
    if (!found_alnum) {       /* str contains no alnum */
        s = e;
        while ((s = rb_enc_prev_char(sbeg, s, e, enc)) != 0) {
            enum neighbor_char neighbor;
            char tmp[ONIGENC_CODE_TO_MBC_MAXLEN];
            l = rb_enc_precise_mbclen(s, e, enc);
            if (!ONIGENC_MBCLEN_CHARFOUND_P(l)) continue;
            l = ONIGENC_MBCLEN_CHARFOUND_LEN(l);
            MEMCPY(tmp, s, char, l);
            neighbor = enc_succ_char(tmp, l, enc);
            switch (neighbor) {
              case NEIGHBOR_FOUND:
                MEMCPY(s, tmp, char, l);
                return str;
                break;
              case NEIGHBOR_WRAPPED:
                MEMCPY(s, tmp, char, l);
                break;
              case NEIGHBOR_NOT_CHAR:
                break;
            }
            if (rb_enc_precise_mbclen(s, s+l, enc) != l) {
                /* wrapped to \0...\0.  search next valid char. */
                enc_succ_char(s, l, enc);
            }
            if (!rb_enc_asciicompat(enc)) {
                MEMCPY(carry, s, char, l);
                carry_len = l;
            }
            carry_pos = s - sbeg;
        }
        ENC_CODERANGE_SET(str, ENC_CODERANGE_UNKNOWN);
    }
    RESIZE_CAPA(str, slen + carry_len);
    sbeg = RSTRING_PTR(str);
    s = sbeg + carry_pos;
    memmove(s + carry_len, s, slen - carry_pos);
    memmove(s, carry, carry_len);
    slen += carry_len;
    STR_SET_LEN(str, slen);
    TERM_FILL(&sbeg[slen], rb_enc_mbminlen(enc));
    rb_enc_str_coderange(str);
    return str;
}

① rb_str_succ 関数

頭から読んでいきます。

VALUE
rb_str_succ(VALUE orig)
{
    VALUE str;
    str = rb_str_new(RSTRING_PTR(orig), RSTRING_LEN(orig));
    rb_enc_cr_str_copy_for_substr(str, orig);
    return str_succ(str);
}

VALUE 型がいきなり分からないな〜と思ったけれど、README.EXT.ja - Documentation for Ruby 2.0.0 などを見ると

Cの変数には型があり,データには型がありません.ですから,たとえばポインタをintの変数に代入すると,その値は整数として取り扱われます.逆にRubyの変数には型がなく,データに型があります.
この違いのため,CとRubyは相互に変換しなければ,お互いのデータをアクセスできません.

RubyのデータはVALUEというCの型で表現されます.VALUE型のデータはそのデータタイプを自分で知っています.このデータタイプというのはデータ(オブジェクト)の実際の構造を意味していて,Rubyのクラスとはまた違ったものです.

とのことなので、Ruby のデータを扱うための汎用的な型なのだろうな〜くらいの理解で先へ進む。

引数で受け取った元の文字列のコピーを作成して、コピーのほうをstr_succ関数に渡しています。
RSTRING_LENは文字列の長さを、RSTRING_PTRは文字列先頭のポインタを取得するマクロとのこと。
str_succ関数で実際に次の文字列を算出して return しているみたい。

② str_succ 関数の前編(処理の準備)

受け取った文字列の情報(サイズやエンコーディング)の取得や、各種変数の初期化。
carry は、繰り上がりに関する何か。
空文字の場合はそこで return 。 sbeg + slen で文字列末尾のポインタを取得して、この後末尾から走査をしていきます。
"aa".succ => "ab"のように、右端の桁から文字を進めていくために、末尾(=右端)からスタートしているようです。

static VALUE
str_succ(VALUE str)
{
    rb_encoding *enc;
    char *sbeg, *s, *e, *last_alnum = 0;
    int found_alnum = 0;
    long l, slen;
    char carry[ONIGENC_CODE_TO_MBC_MAXLEN] = "\1";
    long carry_pos = 0, carry_len = 1;
    enum neighbor_char neighbor = NEIGHBOR_FOUND;

    slen = RSTRING_LEN(str);
    if (slen == 0) return str;

    enc = STR_ENC_GET(str);
    sbeg = RSTRING_PTR(str);
    s = e = sbeg + slen;

  // 略
}

ONIGENC_CODE_TO_MBC_MAXLEN のマクロは 7 が定義されていました。 (該当箇所
マルチバイト文字の最大長らしい。

enum neighbor_char はこのように定義。(該当箇所

enum neighbor_char {
    NEIGHBOR_NOT_CHAR,
    NEIGHBOR_FOUND,
    NEIGHBOR_WRAPPED
};

それぞれこんな意味っぽい。

  • NEIGHBOR_NOT_CHAR:非アルファベットまたは非数字だった
  • NEIGHBOR_FOUND:次の文字が見つかった
  • NEIGHBOR_WRAPPED:繰り上がった

③ str_succ 関数の後編

文字列の走査開始。
rb_enc_prev_charは、1 つ左の文字を返すメソッドです。 (該当箇所
先述のように右端の文字からスタートし、1 文字チェックするごとに s が指す現在地の文字は左にずれていって、それより左の文字がなく NULL を返すと、s != 0 が FALSE になってループを抜ける形。
enc_succ_alnum_char関数で文字列を1 つ進め、返り値に応じて分岐しています。

  • NEIGHBOR_FOUND(次の文字が見つかった)であればその状態の文字列を返して終了する。
  • NEIGHBOR_WRAPPED(繰り上がった)であればfound_alnum(記号でない文字列が見つかった)フラグと、 carry(繰り上がり)の情報を更新してから次のイテレーションに進む。
  • NEIGHBOR_NOT_CHAR(非アルファベットまたは非数字だった)であれば、何もせず次のイテレーションへ進む。

それぞれに対応する例を考えると、下記のようになりそうです。

  • 文字列が"dog"で現在の文字が"g"だったら、1 ループ目で次の文字"h"が見つかってNEIGHBOR_FOUNDとなり、"doh"を return して終了する。
  • 文字列が"129"で現在の文字が"9"だったら、"9""0"になり、NEIGHBOR_WRAPPEDとなって次のイテレーション"2"のチェックに進む。
  • 文字列がa."で現在の文字が"."だったら、NEIGHBOR_NOT_CHARとなって次のイテレーション"a"のチェックに進む。
while ((s = rb_enc_prev_char(sbeg, s, e, enc)) != 0) {
        if (neighbor == NEIGHBOR_NOT_CHAR && last_alnum) {
            if (ISALPHA(*last_alnum) ? ISDIGIT(*s) :
                ISDIGIT(*last_alnum) ? ISALPHA(*s) : 0) {
                break;
            }
        }
        l = rb_enc_precise_mbclen(s, e, enc);
        if (!ONIGENC_MBCLEN_CHARFOUND_P(l)) continue;
        l = ONIGENC_MBCLEN_CHARFOUND_LEN(l);
        neighbor = enc_succ_alnum_char(s, l, enc, carry);
        switch (neighbor) {
          case NEIGHBOR_NOT_CHAR:
            continue;
          case NEIGHBOR_FOUND:
            return str;
          case NEIGHBOR_WRAPPED:
            last_alnum = s;
            break;
        }
        found_alnum = 1;
        carry_pos = s - sbeg;
        carry_len = l;
    }

なおif(neighbor == NEIGHBOR_NOT_CHAR && last_alnum) .....の ブロックでは、 last_alnum(記号の右側にあった文字)と s(記号の左側にある現在の文字) が異なる種類(一方がアルファベットで他方が数字、またはその逆)であればループを抜けることで、下記のような「文字と数字と記号が混在していれば、文字同士または数字同士続くところまで繰り上がる」という仕様を実現していそうです。

# 数字同士が続いているところまで繰り上がり、文字には影響しない
p "a.9.9".succ # => # "a.10.0"


全ての文字でNEIGHBOR_NOT_CHARだった場合は、次の if 文内のループに入り、enc_succ_char関数で記号を1つ次の文字に更新していきます。
前半と似た形で、

  • enc_succ_char関数で文字列を1つ進め、返り値がNEIGHBOR_FOUNDであればその状態の文字列を返して終了。
  • enc_succ_char関数の返り値がNEIGHBOR_WRAPPEDまたはNEIGHBOR_NOT_CHARであれば、繰り上がりの処理をして次のイテレーションに進む

という感じみたいです。

rb_enc_precise_mbclenは指定したポインタにある文字のバイト数を取得する関数(参照箇所12)、 rb_enc_asciicompatエンコーディングが何らかの意味で ASCII と互換性があるかどうかを取得する関数(参照箇所)のよう。

    if (!found_alnum) {        /* str contains no alnum */
        s = e;
        while ((s = rb_enc_prev_char(sbeg, s, e, enc)) != 0) {
            enum neighbor_char neighbor;
            char tmp[ONIGENC_CODE_TO_MBC_MAXLEN];
            l = rb_enc_precise_mbclen(s, e, enc);
            if (!ONIGENC_MBCLEN_CHARFOUND_P(l)) continue;
            l = ONIGENC_MBCLEN_CHARFOUND_LEN(l);
            MEMCPY(tmp, s, char, l);
            neighbor = enc_succ_char(tmp, l, enc);
            switch (neighbor) {
              case NEIGHBOR_FOUND:
                MEMCPY(s, tmp, char, l);
                return str;
                break;
              case NEIGHBOR_WRAPPED:
                MEMCPY(s, tmp, char, l);
                break;
              case NEIGHBOR_NOT_CHAR:
                break;
            }
            if (rb_enc_precise_mbclen(s, s+l, enc) != l) {
                /* wrapped to \0...\0.  search next valid char. */
                enc_succ_char(s, l, enc);
            }
            if (!rb_enc_asciicompat(enc)) {
                MEMCPY(carry, s, char, l);
                carry_len = l;
            }
            carry_pos = s - sbeg;
        }
        ENC_CODERANGE_SET(str, ENC_CODERANGE_UNKNOWN);
    }

最後のところでは、最後の桁の繰り上がりを反映して、最終的な文字列を返してそう。

RESIZE_CAPA(str, slen + carry_len);
sbeg = RSTRING_PTR(str);
s = sbeg + carry_pos;
memmove(s + carry_len, s, slen - carry_pos);
memmove(s, carry, carry_len);
slen += carry_len;
STR_SET_LEN(str, slen);
TERM_FILL(&sbeg[slen], rb_enc_mbminlen(enc));
rb_enc_str_coderange(str);
return str;

ここまで読んでみると、「次の文字」を探す実質のロジックはenc_succ_alnum_charenc_succ_charに書いてあるみたいです。

④ enc_succ_alnum_char 関数

文字か数字かを判定し、それに応じて ctype を設定しています。 どちらにも当てはまらない(=記号など)場合は早々にNEIGHBOR_NOT_CHARを return して終了。

for 文内でenc_succ_char関数を実行し、次の文字への更新と、enum neighbor_char(NEIGHBOR_NOT_CHARNEIGHBOR_FOUNDNEIGHBOR_WRAPPED)の結果を受け取っています。
次の文字が見つかり、かつ元の文字と同じタイプであればNEIGHBOR_FOUNDを返し、次の文字が同じタイプでない(=ラップが生じた)であればNEIGHBOR_WRAPPEDを、無効な文字であればNEIGHBOR_NOT_CHARを返しているみたいです。
「次の文字」の決め方はenc_succ_char関数を見ないと分からなかった。

/*
  overwrite +p+ by succeeding letter in +enc+ and returns
  NEIGHBOR_FOUND or NEIGHBOR_WRAPPED.
  When NEIGHBOR_WRAPPED, carried-out letter is stored into carry.
  assuming each ranges are successive, and mbclen
  never change in each ranges.
  NEIGHBOR_NOT_CHAR is returned if invalid character or the range has only one
  character.
 */
static enum neighbor_char
enc_succ_alnum_char(char *p, long len, rb_encoding *enc, char *carry)
{
    enum neighbor_char ret;
    unsigned int c;
    int ctype;
    int range;
    char save[ONIGENC_CODE_TO_MBC_MAXLEN];

    /* skip 03A2, invalid char between GREEK CAPITAL LETTERS */
    int try;
    const int max_gaps = 1;

    c = rb_enc_mbc_to_codepoint(p, p+len, enc);
    if (rb_enc_isctype(c, ONIGENC_CTYPE_DIGIT, enc))
        ctype = ONIGENC_CTYPE_DIGIT;
    else if (rb_enc_isctype(c, ONIGENC_CTYPE_ALPHA, enc))
        ctype = ONIGENC_CTYPE_ALPHA;
    else
        return NEIGHBOR_NOT_CHAR;

    MEMCPY(save, p, char, len);
    for (try = 0; try <= max_gaps; ++try) {
        ret = enc_succ_char(p, len, enc);
        if (ret == NEIGHBOR_FOUND) {
            c = rb_enc_mbc_to_codepoint(p, p+len, enc);
            if (rb_enc_isctype(c, ctype, enc))
                return NEIGHBOR_FOUND;
        }
    }
    MEMCPY(p, save, char, len);
    range = 1;
    while (1) {
        MEMCPY(save, p, char, len);
        ret = enc_pred_char(p, len, enc);
        if (ret == NEIGHBOR_FOUND) {
            c = rb_enc_mbc_to_codepoint(p, p+len, enc);
            if (!rb_enc_isctype(c, ctype, enc)) {
                MEMCPY(p, save, char, len);
                break;
            }
        }
        else {
            MEMCPY(p, save, char, len);
            break;
        }
        range++;
    }
    if (range == 1) {
        return NEIGHBOR_NOT_CHAR;
    }

    if (ctype != ONIGENC_CTYPE_DIGIT) {
        MEMCPY(carry, p, char, len);
        return NEIGHBOR_WRAPPED;
    }

    MEMCPY(carry, p, char, len);
    enc_succ_char(carry, len, enc);
    return NEIGHBOR_WRAPPED;
}

⑤ enc_succ_char 関数

現在の文字を指すポインタと、文字列の長さ、エンコーディングを入力として、neighbor_charenumNEIGHBOR_NOT_CHARNEIGHBOR_FOUNDNEIGHBOR_WRAPPED)を返しています。

マルチバイト文字を含むエンコーディングrb_enc_mbminlen(enc) > 1が true )のときは、有効な文字列かどうかをチェックのうえ、

 c = rb_enc_mbc_to_codepoint(p, p + len, enc) + 1;

によって「コードポイントに 1 を加算する」ことで次の文字を求めていました。
コードポイントは、文字ごとに割り当てられた番号のこと。(Code point (コードポイント) | MDN

シングルバイト文字のみのエンコーディングのときは、

++((unsigned char*)p)[i];

の箇所でインクリメントを実行しています。

static enum neighbor_char
enc_succ_char(char *p, long len, rb_encoding *enc)
{
    long i;
    int l;

    if (rb_enc_mbminlen(enc) > 1) {
        /* wchar, trivial case */
        int r = rb_enc_precise_mbclen(p, p + len, enc), c;
        if (!MBCLEN_CHARFOUND_P(r)) {
            return NEIGHBOR_NOT_CHAR;
        }
        c = rb_enc_mbc_to_codepoint(p, p + len, enc) + 1;
        l = rb_enc_code_to_mbclen(c, enc);
        if (!l) return NEIGHBOR_NOT_CHAR;
        if (l != len) return NEIGHBOR_WRAPPED;
        rb_enc_mbcput(c, p, enc);
        r = rb_enc_precise_mbclen(p, p + len, enc);
        if (!MBCLEN_CHARFOUND_P(r)) {
            return NEIGHBOR_NOT_CHAR;
        }
        return NEIGHBOR_FOUND;
    }
    while (1) {
        for (i = len-1; 0 <= i && (unsigned char)p[i] == 0xff; i--)
            p[i] = '\0';
        if (i < 0)
            return NEIGHBOR_WRAPPED;
        ++((unsigned char*)p)[i];
        l = rb_enc_precise_mbclen(p, p+len, enc);
        if (MBCLEN_CHARFOUND_P(l)) {
            l = MBCLEN_CHARFOUND_LEN(l);
            if (l == len) {
                return NEIGHBOR_FOUND;
            }
            else {
                memset(p+l, 0xff, len-l);
            }
        }
        if (MBCLEN_INVALID_P(l) && i < len-1) {
            long len2;
            int l2;
            for (len2 = len-1; 0 < len2; len2--) {
                l2 = rb_enc_precise_mbclen(p, p+len2, enc);
                if (!MBCLEN_INVALID_P(l2))
                    break;
            }
            memset(p+len2+1, 0xff, len-(len2+1));
        }
    }
}

実装を読んでのまとめ

知りたいこととして最初に掲げた「『次の文字』とは何か」は、「文字コードの数字を 1 進めた」文字のことでした。
とはいえ単純に +1 すれば終わるという話ではなく、

  • 文字、数字、記号での処理の分岐
  • 文字同士または数字同士が連続する範囲の判定
  • 繰り上がりの考慮、桁数が増える場合のメモリの確保
  • マルチバイトエンコーディング・シングルバイトエンコーディングの違いの吸収

といった考慮のうえで実現されていることも分かりました。

irbで確認

String#succ が、文字コードの数字を 1 進めることによって次の文字を取得している様子を、irb で確認してみました。
エンコーディングUTF-8 です。

漢字

漢字でも、ちゃんと 171581 → 171582 にインクリメントされています。

> "𩸽".codepoints
=> [171581]
> "𩸽".succ
=> "𩸾"
> "𩸾".codepoints
=> [171582]
絵文字

家族の絵文字って、複数の文字の組み合わせとして実現されているんですね。
この絵文字も、succ を実行すると一番右の文字コードが 1 進んでいます。
(向かって右下の子供が変わっています)

> "👨‍👩‍👧‍👦".succ
=> "👨‍👩‍👧‍👧"
> "👨‍👩‍👧‍👦".codepoints
=> [128104, 8205, 128105, 8205, 128103, 8205, 128102]
> "👨‍👩‍👧‍👧".codepoints
=> [128104, 8205, 128105, 8205, 128103, 8205, 128103]

おわりに

Ruby Silver 試験の役立ったかは分かりませんが、面白かったです。
正直ちゃんと隅々理解しようと思うとハードルが高くて難しかったのですが、ざっくり処理を見てみるくらいなら意外と気軽にできるんだな〜と思ってもらえれば幸いです。

【Ruby on Rails 7 + React・個人開発】 KPI ツリー図の作成・数値シミュレーションをサクッとできる Web サービス「KPI ツリーメーカー」をリリースしました

はじめに

Ruby on Rails 7 + React で 個人開発した Web サービス、「KPI ツリーメーカー」をこのたびリリースしました。
KPI ツリー図の作成・数値シミュレーションをサクッと行うことができるサービスです。

KPI ツリーメーカーのサービス画面
サービス画面

Google アカウントがあれば無料ですぐに使えるので、ぜひお気軽に触ってみてもらえたら嬉しいです。

kpi-tree.com

github.com

簡単な自己紹介

新卒でシステム開発会社に入り、プロジェクトマネージャーを経て、現在はモバイルアプリや Web サービスのプロダクトマネージャーとして働いています。
仕事ではマネジメントが主になっていて自分で開発・実装する機会が少ないので、エンジニアリング経験をもっと積みたいなあと思い、FjordBootCamp という Rails の学習コミュニティに1年半ほど参加しています。

KPI ツリーメーカーの紹介

KPI ツリーメーカーとは

KPI ツリー図の作成・数値シミュレーションをサクッと行うことができる、無料の Web サービスです。
下記のような KPI ツリーを、ポチポチッとすぐに作ることができます。

KPI ツリーメーカーで作成した KPI ツリーの画像
KPI ツリー

  • 目標設定等で数値シミュレーションが必要なとき
  • 事業やキャンペーンの企画案をたくさん出して、それぞれの KPI を簡単に整理したいとき

など、手軽に KPI ツリーがほしい場面で便利です。

使い方

  1. Google アカウントでサインアップする

    KPI TREE MAKER にサインアップ

  2. ツリーを新規作成する

    ツリーを新規作成する画面

  3. ツリー要素の追加や編集を行う

    KPI ツリーを作成、編集する画面

  4. 作成したツリー画像はダウンロードできます

    KPI ツリーをダウンロードできる画面

作ろうと思った背景

サービス開発の理解を深めるために、個人でイチから実装したものを作りたい。でも何を作ろうか……🤔 となったときに、
あんまり壮大にならない「ちょっとしたツール系」のサービスがいいなあ、と考えました。
そこから、

  • KPI ツリーを作る機会が仕事でちょくちょくあること
  • 数値計算スプレッドシートでしつつ、スライドに図を描く&数値更新があれば図を修正する、というのが面倒だなと感じていたこと

に思い当たり、簡単に KPI ツリーを作成できて、数値をいじれて、画像化できるサービスがあれば嬉しいな! ということで、KPI ツリーメーカーを作ることに決めました。

※ ボツになった案もいくつかあり、例えばそのうちの1つは「コンビニ新商品ウォッチャー」です。
コンビニスイーツ大好きマンの自分としては、各社コンビニチェーンをひとまとめにして、しかも商品カテゴリを絞って新商品をチェックできたら嬉しいな〜と考えたんですが、各コンビニの公式サイトを勝手にスクレイピングするのはマズそう……というわけでボツに。

利用技術

言語

  • Ruby 3.1
  • TypeScript 4.9

フレームワーク

その他主なライブラリ

データベース

ホスティング

テスト

CI/CD

開発はコンテナ (Docker Compose) 上で行い、リリースは Fly.io でビルドおよびデプロイしています。

技術スタック
技術スタック
システム構成図
システム構成図

取り組んでの感想

描画が大変

KPI ツリーの描画を自力で実装するのは大変そうだったので、React の描画系のライブラリを探しました。
その中から、

  • ツリー形式のノードの描画ができる
  • 継続的にメンテナンスされていそう

なものをピックアップし、試しにツリーを作成してみて一番イメージに近い描画ができた react-d3-tree を選びました。
props で各種オプション値を指定するだけで要素の間隔や方向をいい感じに設定できたり、ズームイン/ズームアウトも最初からできたりと、機能が充実していました。
指定の型でデータを渡すだけで、下記のような描画ができます。

react-d3-treeを説明する図。要素の位置や線のスタイルを簡単に設定できる。
react-d3-tree で作成したツリー図。要素の位置や線のスタイルを簡単に設定できる

ここから、ノードのサイズや色を変えたり、クリック時のアクションをつけたり、プロパティに応じてアイコンを表示するようにしたりとカスタマイズしていきました。

react-d3-treeを使ったことを説明する図。
カスタマイズの結果、こうなる

react-d3-tree はデフォルト機能が充実している一方で、ライブラリ側が用意している基本オプションから逸れたカスタマイズをしようと思うと、React も D3 も使うのが初めての私はなかなか苦戦しました。。

ツリーを表現するためのデータ変換と検索にひと苦労

例えば、下記のような親子関係のツリーを描画したいとします。

親子ノードの図

DB で下記のようにフラットな形で保存している状態から、

Node テーブル:

id parent_id
1
2 1
3 1
4 2
5 3
6 3
7 3
8 7
9 7

下記のような入れ子構造に変換して react-d3-tree に渡すことで、ツリー図を描画できます。

{
  id: 1,
  children: [
    {
      id: 2,
      children: [{id: 4}]
    },
    {
      id: 3,
      children: [
        {id: 5},
        {id: 6},
        {
          id: 7,
          children: [
            {id: 8},
            {id: 9}
          ]
        }
      ]
    }
  ]
}

この変換には、 list-to-tree というライブラリを使いました。

また、ツリー図に対するユーザー操作が発生した場合は、この入れ子構造のデータに対して検索処理をしていきます。
例えば、ユーザーがツリー図のノードをクリックしたら、クリックしたノードとその兄弟ノードの色を変えるという機能があります。
その時、裏側では「クリックされた描画要素に対応するノードオブジェクトの id を取得し、その兄弟ノードを検索して isSelected プロパティを true にする」といったことをしています。これを自分で実装するには思いの外面倒で、tree-model というライブラリを利用することで楽に実装ができました。

リリースまで到達できてよかった

2、3ヶ月でリリースできるといいかな〜なんて当初は思っていたものの、結果的にはなんだかんだ半年ほどかかりました。
自身のモチベーションのみが原動力となる個人開発においては、結局のところ「諦めずにリリースまで到達すること」が一番大変なことなんじゃないかなと思います。
自分がぶつかったつらみと、その乗り越え方は以下のような感じでした。

  • 疲れてたり、予定が入ったり、ついダラダラしたりで作業が進まなくてつらい。
    • 生活リズムに作業時間を組み込む💪
      • 平日1時間・休日4時間というノルマを決めました。
      • 朝型の生活に切り替えた上で、平日出勤前の1時間・休日午前中4時間、は基本マストで作業する時間としていました。
  • 仕事の繁忙期や、家族のライフイベントなどが重なり、作業時間の確保がどう頑張っても無理でつらい。
    • 潔くあきらめて、開発のことはいったん忘れる💪
  • エラーにハマったりして、時間をかけているのに開発進捗が出なくてつらい。
    • 結果的に開発の進捗は全くなくても、机に向かっただけで今日は OK・進捗だった、と自分の気持ちを上げていく💪

半年かかったとはいえ、働きながらでもサービス1つリリースできるんだ、という成功体験になったなあと感じています。
机に向かう時間が生活リズムに組み込まれたのも、良い効果となりました。
リリースまで、と言いましたが、サービスはこれからも継続・改善していく所存です。

その他もろもろ

  • GitHub Projects のカンバンが便利だった
  • CSS の苦手意識がだいぶなくなった
    • 実現したいデザインを模索しながら複数のパターンを試す過程を通して、ある程度サクッとページにデザインを当てられるようになれた
  • テストコードを書くのが大好きになった
    • 利用ライブラリのバージョンを安心して上げられたり、ある修正が予期せぬところに影響していることに気付けたり、テストがある安心感が本当に良い

おわりに

「KPI ツリーメーカー」は、皆さまの KPI ツリーの作成・更新のちょっとした手間を削減できるサービスにできたと思っています。

kpi-tree.com

お気軽に使ってみていただけますと嬉しいです!

フィヨルドブートキャンプのチーム開発

はじめに

peno022 です。普段はプロダクトマネージャーとして働いています。
現業では自分でコーディングする機会はほぼ無いのですが、プロダクトの技術的な理解を深めたいと思い、フィヨルドブートキャンプという Ruby on Rails をメインとしたコミュニティ型のプログラミングスクールに通いました。

その中で、普段フィヨルドブートキャンプで使っているEラーニングシステムをスクラムチームで開発するというカリキュラムがあり、学びが多かったので記録しておきます。

フィヨルドブートキャンプのチーム開発について

チーム開発では、フィヨルドブートキャンプのEラーニングシステムの開発チームに1メンバーとして参加し、開発を行っていきます。

フィヨルドブートキャンプのアプリ画面
フィヨルドブートキャンプ画面

フレームワークRuby on Rails、一部の View では React、Vue.js を利用しています。
Public リポジトリになっているので、内容はどなたでも参照可能です。

github.com

スクラムチームの構成
  • フィヨルドブートキャンプの運営者でプログラマーの komagata さんがスクラムマスターを担当
  • 同じく運営者でデザイナーの machida さんがプロダクトオーナーを担当
  • 複数人の受講生(時期によりますが10人前後くらい)が開発を担当
開発の進め方
  • 週に一度、その週の振り返りと翌週の計画ミーティングを実施。各人の進捗共有と Issue の割り振りを行う。
  • 開発者は担当 Issue に対応し、PR を作成(仕様のすり合わせなど必要な時は都度 Discord で相談)
  • 開発者(受講生)の誰かに PR レビューを依頼。
  • 受講生のレビューが完了したら、komagata さんに PR レビューを依頼。
  • レビューが完了したら、マージ&ステージング環境にデプロイ。
  • ステージング環境での動作確認後、本番にリリースし、Issue をクローズ(リリースは週次で実施)。
  • 20ポイント分の Issue を担当して全てをクローズできたらチーム開発のカリキュラムとしては終了し、その人は開発チームから抜ける。

フィヨルドブートキャンプ開発のかんばん

実業務と違うのは、事情があって急ぎたいもの以外は基本的にスケジュールが本人に任されるという点です。(チームに都度状況共有をすることは大前提として)
仕事や育児などと並行して限られた時間で参加している方も多いですし、締め切りにあわせて無理して進めなければいけないということは起きません。

担当した Issue

Issue PR
トップページのお二人の横断幕を外す · Issue #5796 #5812
app/views/home/index.html.slim.page-header-actions のリンクにul,liタグを付与したい · Issue #5783 #5819
ダッシュボード > ブックマークが 0 のときの表示を変えたい · Issue #5679 #5838
booksのページでエラーが起きているのを修正する · Issue #5788 #5921
ログイン必須の指定が多すぎる · Issue #5984 #5921
ブログ一覧で、記事の表示数を3の倍数(24個)に設定する · Issue #5900 #5922
確認通知をactive_delivery化したい · Issue #5874 #5924
[newspaper] 提出物のステータス変更処理をnewspaperに置き換えたい · Issue #6018 #6034
個人の日報一覧もプラクティスで絞り込みたい。 · Issue #6037 #6044

レビューを担当したもの:

Issue PR
ブックマークがアクティブのときの色を変えたい。 · Issue #5820 #5842
footer に Fjord Choice のリンクを置きたい · Issue #5797 #5847
スマホ時の検索の表示・非表示をJSで行いたい。 · Issue #5805 #5845
コメント通知をactive_delivery化したい · Issue #5873 #5919
ユーザー登録から数日後に相談部屋にメッセージを送りたい · Issue #5746 #5959
Q&A個別ページの右カラムに、そのカテゴリーの一覧を表示したい。 · Issue #5999 #6135

感想

自分たちがユーザーであり開発者なので楽しい

カリキュラムの順番上、半年〜1年ほど受講生としてフィヨルドブートキャンプのサービスを使ってきた上で、開発チームに参加することになります。
そのため、ユーザー視点でのサービス理解が十分に深まった状態で開発に臨むことができ、とても良かったです。今対応している Issue によってどんなユーザー課題が解決できるのか想像しやすく、開発を楽しめました。
また、ユーザー課題について考えることは普段のプロダクトマネージャー業務でもやっている一方で、開発観点でより理解しやすい・メンテナンスしやすい実装に落とし込むにはどうすればよいか? を考える機会をこのチーム開発では得ることができました。

開発作法を理解していく経験を積める

このEラーニングシステムは、活発に開発を続けられながら4年ほど本番運用されています。
個人で開発した小規模なシステムとは違って、どこに何のロジックがあるか、ぱっと見では分からないことも多々あります。
そのため最初は小さな修正の Issue を担当をしながら、どこに何があるのかを把握していきました。
また、このシステムに合わせた開発環境の構築方法や開発ルールも、チームに参加してからキャッチアップしていくことになります。
こういったプロセスは個人開発では通ることができないので、良い経験になったと思います。

最後に

一番最初の Issue にアサインされてから、最後に担当した Issue をクローズするまで、ちょうど3ヶ月ほどの活動でした。一人で学習しているだけだとチーム開発はなかなか出来ないので、学習コミュニティに参加してよかったです。
チーム開発をしたいけどどうやって始めたらいいか悩んでいる方がいれば、フィヨルドブートキャンプはおすすめです!

Docker + Rails 7 + RSpec で System Spec を実行するための設定

はじめに

Docker環境でRailsのWebアプリケーションを開発し始めた際、RSpecのSystem Specを実行するために必要な設定の全貌がなかなか分からず。
大ハマリしたので、手順を残しておきます。

Gemfile.lockのバージョン情報:

開発には VSCode Remote Container を利用しています。

デフォルトで入っている 'webdrivers' gem は削除する

webrdrivers を利用すると、Docker環境ではなくローカル(=ホスト)側にブラウザを探しにいってしまうようで、Webdrivers::BrowserNotFoundのエラーが発生します。

Gemfile:

# test以前は省略
group :test do
  gem 'capybara'
  gem 'launchy'
  gem 'selenium-webdriver'
  # gem 'webdrivers' ← これは削除する
end

webrdriversのgemが読み込まれると発生してしまうエラー:

# gem 'webdrivers' を削除しないで bundle exec rspec を実行

Failure/Error:
       driven_by :selenium, using: :headless_chrome, options: {
         browser: :remote,
         url: ENV.fetch('SELENIUM_DRIVER_URL'),
       }
Webdrivers::BrowserNotFound:
Failed to determine Chrome binary location.

参考:

github.com

補足

参考リンク先ではgem 'webdrivers', require: !ENV['SELENIUM_REMOTE_URL']という修正方法になっています。
Docker環境とローカル環境の両方で動かせるようにしたいという事情がある場合、gem 'webdrivers', require: !ENV['SELENIUM_DRIVER_URL']にしておくことで、「環境変数を設定しているコンテナで利用する場合はwebdriversを読み込まない」という形にできるからのようです。
自分の場合はDocker環境で使えればOKなので、gem 'webdrivers'の行ごと削除しています。

ちなみに、そもそもGemfileのrequire ~~~が何をしているのかについては、こちらの記事が分かりやすかったです。

masuyama13.hatenablog.com

Chromeのコンテナと、Railsのコンテナをネットワークで接続する

docker-compose.ymlを修正する必要があります。 下記のように、appのコンテナとselenium_chromeのコンテナを、同じネットワークで指定します。

version: '3'

services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile

    volumes:
      - ../..:/workspaces:cached

    ### 追記 ###
    networks:
      ktg-net:

  selenium_chrome:
    image: selenium/standalone-chrome-debug
   
    ### 追記 ###
    networks:
      ktg-net:
  
  db:
   image: postgres:15.1
    # 中略

### 追記 ###
networks:
  ktg-net:

volumes:
  postgres-data:

ネットワーク接続ができていない状態だと、下記のエラーが発生しました。

Failure/Error: visit root_path
          
          SocketError:
            Failed to open TCP connection to selenium_chrome:4444 (getaddrinfo: Name or service not known)

利用するドライバーを設定する

RSpecの設定ファイル(/spec/rails_helper.rbなど)に設定を追加する必要があります。
自分の場合は設定を下記のようにspec/support/配下に切り出しているため、spec/support/capybara.rbのファイルにドライバーの設定を書きました。

# /spec/rails_helper.rb
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }

spec/support/capybara.rb:

# frozen_string_literal: true

RSpec.configure do |config|
  config.before(:each, type: :system) do
    driven_by :selenium, using: :headless_chrome, options: {
      browser: :remote,
      url: ENV.fetch('SELENIUM_DRIVER_URL')
    }
    Capybara.server_host = 'app'
  end
end

以下、設定内容について解説します。

driven_by :selenium, using: :headless_chrome

ドライバの種類として:seleniumを指定し、利用するブラウザに:headless_chromeを指定しています。
(利用するコンテナイメージも合わせて変更すれば、ブラウザには:headless_firefoxなどを指定することも可能です。)

driven_byのドキュメント:

api.rubyonrails.org

なお、driven_byでドライバとして設定できるのは、現時点で:selenium, :poltergeist, :webkit, :cuprite, :rack_testの5種類のようです。 github.com

options の設定

options: {
      browser: :remote,
      url: ENV.fetch('SELENIUM_DRIVER_URL')
    }

ローカルではなくリモート環境のブラウザを利用するため、browser: :remoteを指定しています。
また、リモートに存在するドライバーのURLを指定する必要があるため、ここではdocker-compose.yml環境変数として設定したものを参照しています。
環境変数に設定せずurl: 'http://selenium_chrome:4444/wd/hub'と直接書いても問題なく動きます。)

github.com

docer-compose.ymlを修正し、appコンテナに環境変数を追加:

version: '3'

services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile

    # 中略

    ### 追記 ###
    environment:
      - SELENIUM_DRIVER_URL=http://selenium_chrome:4444/wd/hub 

# 以下略

Capybara.server_host

テストアプリケーションが外部ホストからアクセスされる場合に設定が必要とのこと。

server_host (String = "127.0.0.1") - The IP address Capybara will bind the application server to. If the test application is to be accessed from an external host, you will want to change this to "0.0.0.0" or to a more specific IP address that your test client can reach.

Module: Capybara — Documentation for capybara (3.38.0)

デフォルト値が127.0.0.1のため、何も設定しないとドライバがアプリケーションを見つけられず、エラーが発生します。

# Capybara.server_host の設定をせずに bundle exec rspec を実行

Failure/Error: visit root_path
     
     Selenium::WebDriver::Error::UnknownError:
       unknown error: net::ERR_CONNECTION_REFUSED
         (Session info: headless chrome=94.0.4606.61)

その他参考

qiita.com

qiita.com

Docker + Rails 7 + RSpec で Webdrivers::BrowserNotFound のエラー

TL;DR

  • Railsアプリケーション作成時にデフォルトで入っている gem 'webdrivers'をGemfile から削除することで解決した
  • webrdrivers が読み込まれると、Docker環境ではなくローカル(=ホスト)側にブラウザを探しにいってしまうらしい

関連記事

peno022.hatenablog.com

状況

VSCode Remote Containerを利用し、Docker環境でRailsのWebアプリケーションを開発しています。
RSpecのSystem Specをchromeのヘッドレスブラウザで実行しようとした際に、下記のエラーが発生しました。

spec/sample_spec.rb:

# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Welcome pages' do
  it 'LPのリンクから利用規約へ遷移する' do
    visit root_path
    click_link '利用規約'
    expect(page).to have_selector 'h1', text: '利用規約'
  end
end

spec/support/capybara.rb:

# frozen_string_literal: true

RSpec.configure do |config|
  config.before(:each, type: :system) do
    driven_by :selenium, using: :headless_chrome, options: {
      browser: :remote,
      url: ENV.fetch('SELENIUM_DRIVER_URL')
    }
    Capybara.server_host = 'app'
  end
end

bundle exec rspecの実行結果:

Failure/Error:
       driven_by :selenium, using: :headless_chrome, options: {
         browser: :remote,
         url: ENV.fetch('SELENIUM_DRIVER_URL'),
       }
Webdrivers::BrowserNotFound:
Failed to determine Chrome binary location.

Gemfile.lockのバージョン情報:

docker-compose.yml:

version: '3'

services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile

    volumes:
      - ../..:/workspaces:cached

    networks:
      ktg-net:

    environment:
      - SELENIUM_DRIVER_URL=http://selenium_chrome:4444/wd/hub

  selenium_chrome:
    image: selenium/standalone-chrome-debug
    networks:
      ktg-net:
  
  db:
    image: postgres:15.1
    # restart: unless-stopped
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./create-db-user.sql:/docker-entrypoint-initdb.d/create-db-user.sql
    environment:
      POSTGRES_USER: postgres
      POSTGRES_DB: postgres
      POSTGRES_PASSWORD: postgres

    networks:
      ktg-net:

networks:
  ktg-net:

volumes:
  postgres-data:

解決方法

rails new でアプリを作成した段階のデフォルトで入っている gem 'webdrivers'をGemfile から削除することで解決しました。
webrdrivers が読み込まれると、Docker環境ではなくローカル(=ホスト)側にドライバーを探しにいってしまうらしく、ローカル側ではドライバーが見つからないため、Webdrivers::BrowserNotFoundが発生していたようです。

Gemfile:

# test以前は省略
group :test do
  gem 'capybara'
  gem 'launchy'
  gem 'selenium-webdriver'
  # gem 'webdrivers' ← これを削除する
end

参考

github.com

GitHub で issue を作成したら自動で project に追加する方法

はじめに

GitHub Actions を使って、リポジトリで issue を作成したら自動で project に追加する workflow を作成しました。

背景

自分が今開発しているリポジトリでは、GitHub の project (classic じゃないほう)を使って、issue をカンバン形式で管理しています。
しかし、GitHub リポジトリの現時点の仕様では、issue を作成した時に自動でプロジェクトに追加するような設定ができません。

そのため、issue を作成するたびに手動で追加先 project を指定する必要があり、指定し忘れるとカンバンから漏れてしまうという課題がありました。
そこで、GitHub Actions で自動化しました。

GitHubのフォーラムでも、追加先 project を issue template で指定できるようにしてほしいという要望が上がっていますが、現時点ではまだ対応されていないようです。

workflow の作成

こちらの workflow ファイルを作成・配置します。
利用している add-to-project アクションは、GitHub認証済みアクションなので安心です。

# .github/workflows/add_issues_to_project.yml

name: Add issues to project

on:
  issues:
    types: [opened]

jobs:
  add-to-project:
    name: Add issue to project
    runs-on: ubuntu-latest
    steps:
      - uses: actions/add-to-project@v0.4.0
        with:
          project-url: https://github.com/users/[ユーザー名]/projects/[プロジェクトID]
          github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}


personal access token の作成・設定

トークンの作成

1. Tokens(classic) の作成画面を開く

personal access tokenは、GitHub アカウントの Settingsリポジトリの Settings ではなく)を開き、
Developper settings > Personal access tokens > Tokens(classic)
のメニューから、作成画面を開くことができます。

今回利用しているアクションは、現時点ではFine-grained tokensのほうには対応していないので注意してください。
Fine-grained tokensを使うと、アクションの実行時にPersonal access tokens with fine grained access do not support the GraphQL APIというエラーが出ます。

2. 権限設定・トークン作成

下記の権限を設定し、「Generate token」をクリックするとトークンが作成されます。

  • repo(配下すべて)
  • project(配下すべて)

注意:
作成したトークンの文字列は、このあと利用するのでメモしておいてください。
一度画面を閉じると再表示されません。

リポジトリへの登録

トークンを作成できたら、リポジトリの Settings を開き、
Security > Secrets and variables > Actions
のメニューから、作成したトークンを登録します。

項目名に任意の名前(今回はADD_TO_PROJECT_PAT)をつけ、Valueには生成したトークンの文字列をペーストして、登録完了です。

確認方法

ここまでの手順を完了すると、このリポジトリで issue を作成した時に workflow が動き、project が自動で指定されるようになります。
issue を作成したら、リポジトリの Actions タブで、Add issues to projectのワークフローが動いているかを確認してみてください。

ワークフローの実行確認
ワークフローの実行確認

参考

github.com

個人開発プロジェクトの開始からCI構築までにやったこと( GitHub Actions )

はじめに

個人でRuby on RailsのWEBアプリ開発に着手しています。
初めてのCI環境構築で色々調べながら取り組んだので、次回はスッとできるよう、記録を残しておきます。

やったこと

ざっくり以下のようなステップを踏みました。

  • ブランチ運用の設計
  • CIの設計
  • CIの実装
    • テストを動かす
    • lintを動かす
  • CodeQLの設定
  • 必要なチェックをパスしないとPRをマージできないようにする
  • tagprを導入する

ブランチ運用の設計

そもそもCIの要件を整理するにあたり、ブランチの運用方法を決めました。
今回は、

  • 開発は基本的に一人で実施するので、できるだけシンプルにしたい
  • ローカル環境で確認後に即本番リリースはちょっと不安なので、ステージング環境で確認などしてからリリースできるようにしておきたい

という背景により、GitHub flowをベースに、下記のような運用にすることにします。

  • mainブランチと、各featureブランチのみで運用
    • featureブランチはmainへのマージ後に削除
  • mainブランチにfeatureブランチがマージされたら、ステージング環境へデプロイ
  • mainブランチにタグを打ったら、本番環境へデプロイ
    • どのコミットまでをリリースに含めるかを決めたら、タグを打つまでは追加でマージしないようにする
---
title: Git branch 運用イメージ
---
gitGraph
   commit
   branch feature1
   branch feature2
   checkout feature1
   commit
   commit
   checkout main
   merge feature1
   checkout feature2
   commit
   checkout main
   merge feature2
   branch feature3
   checkout feature3
   commit
   checkout main
   merge feature3
   commit tag: "v1.0.0"

CIの設計

上記の前提のうえで、CIをどのようにしたいかを(CDも含め)検討しました。

CIのチェックに含めるもの

  • lintの実行
  • テストの実行
  • CodeQLの実行(※GitHubが提供している、コードのセキュリティチェック)

やりたいこと

  • featureブランチからmainへのPRを作成した時
    • CIが動き、パスしなければPRはマージできない
  • featureブランチにコミットをpushした時
    • CIが動き、パスしなければPRはマージできない
  • mainブランチにfeatureブランチをマージした時
    • リリース用のブランチ・PRを作成(main←リリースブランチ)
      • リリースブランチは、機能の追加はせずにリリース用の変更(CHANGELOGの追記など)だけを行うブランチ
    • リリースブランチに対してCIが動き、パスしなければPRはマージできない
  • mainブランチにリリースブランチをマージした時
    • リリースバージョンのtagを打つ
    • tagが打たれたバージョンのmainを本番にデプロイする

ツール

Publicリポジトリであれば上限なく無料で利用でき、公式ドキュメントも豊富だったため、GitHub Actionsを利用することにしました。

docs.github.com

詳しくは上記の公式ドキュメントを読む必要がありますが、GitHub Actionsの基本的な概念は以下のとおりです。

  • ワークフロー
    • 1 つ以上のジョブを実行する自動化プロセス。リポジトリ内の .github/workflows 配下にYAML ファイルを作成して定義する。
  • イベント
    • ワークフローの実行をトリガーするアクティビティ。リモートブランチへのpush、PRの作成など。
  • ジョブ
  • アクション
    • GitHub Actions用に定義された、再利用可能なジョブ。GitHub Marketplaceにアクションが色々公開されている。独自で作ることもできる。
  • ランナー
    • ワークフローを実行するサーバー。

CIの設定ファイル(完成版)

CIのワークフローを定義していきます。こちらの記事を参考にしました。

zenn.dev

最終的なコードは下記になります。(クリックすると開きます)

========

github/workflows/ci.yml

name: ci

on:
  pull_request:
    branches: [ main ]

jobs:
  ci:
    runs-on: ubuntu-latest
    container:
      image: ruby:3.1.3
      env:
        RAILS_ENV: test
    steps:
      - name: Check out source code
         uses: actions/checkout@v3
      - name: Cache node modules
         uses: actions/cache@v3
         env:
           cache-name: cache-node-modules
         with:
           path: ~/.npm
           key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
           restore-keys: |
             ${{ runner.os }}-build-${{ env.cache-name }}-
             ${{ runner.os }}-build-
             ${{ runner.os }}-

      - name: Cache bundle gems
         uses: actions/cache@v3
         with:
           path: vendor/bundle
           key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
           restore-keys: |
             ${{ runner.os }}-gem-
             ${{ runner.os }}-

      - name: Install Node.js
         uses: actions/setup-node@v3
         with:
           node-version: '19.4'

      - name: Npm install
         run: npm install

      - name: Install Bundler
         run: gem install bundler --no-document -v $(grep "BUNDLED WITH" -1 Gemfile.lock | tail -n 1)

      - name: Bundle install
         run: |
           bundle config set --local path 'vendor/bundle'
           bundle install --jobs 4 --retry 3

      - name: Run rubocop
         run: bundle exec rubocop

      - name: Run lint
         run: npm run lint

      - name: Run tests
         run: bundle exec rspec

========

ワークフローの各部分について

上記ファイルの各部分の内容について、順に見ていきます。

実行のトリガー

実行のトリガーとなるイベントは、mainブランチへのPull requestが作成あるいは更新された時としています。
なおイベントの詳細については、下記のドキュメントに一覧が記載されているので参考になりました。

docs.github.com

name: ci

on:
  pull_request:
    branches: [ main ]

依存ライブラリのキャッシュ

node-modulesとRuby gemをキャッシュしておくことで、ワークフローの実行を速くすることができます。

      - name: Cache node modules
         uses: actions/cache@v3
         env:
           cache-name: cache-node-modules
         with:
           path: ~/.npm
           key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
           restore-keys: |
             ${{ runner.os }}-build-${{ env.cache-name }}-
             ${{ runner.os }}-build-
             ${{ runner.os }}-

      - name: Cache bundle gems
         uses: actions/cache@v3
         with:
           path: vendor/bundle
           key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
           restore-keys: |
             ${{ runner.os }}-gem-
             ${{ runner.os }}-
  • key
    • ハッシュ保存時に作成されるキー。検索時にも使われる。
  • path
    • キャッシュを保存・復元するときのパス。
  • restore-keys
    • key一致するキャッシュがない場合に探しにいくキーです。optionalですが、できるだけ多くのキャッシュヒットを得るためには設定しておくとよいようです。

docs.github.com

実際に試してみると、2回目の実行時は確かに速くなっていました。
←1回目 2回目→

Nodeのインストール・npm installの実行

Nodeのインストールには、actions/setup-nodeを使っています。

      - name: Install Node.js
         uses: actions/setup-node@v3
         with:
           node-version: '19.4'

      - name: Npm install
         run: npm install

このアクションを使うことで、Node.jsがキャッシュされると思ったのですが、現時点ではバージョン19.xの場合はキャッシュできないようです。

peno022.hatenablog.com

下記のようにNodeのインストールコマンドを直接実行した場合と比べると、10秒→5秒とactions/setup-nodeを使ったほうが速くなったので、19.4が対応されるのを待ちつつ、このまま使うことにしました。

      # 直接実行した版
      - name: Install Node.js
         run: |
           curl -sL https://deb.nodesource.com/setup_19.x | bash -
           apt-get install -y nodejs

Bundlerのインストール・bundle installの実行

      - name: Install Bundler
         run: gem install bundler --no-document -v $(grep "BUNDLED WITH" -1 Gemfile.lock | tail -n 1)

      - name: Bundle install
         run: |
           bundle config set --local path 'vendor/bundle'
           bundle install --jobs 4 --retry 3

gem installのオプション補足:

  • --no-document
    • gemに関するドキュメントの生成をスキップ。ライブラリ本体のダウンロードにとどめたほうが実行が速くなるため。
  • -v
    • bundlerのバージョンを指定。
    • grep "BUNDLED WITH" -1 Gemfile.lock | tail -n 1で、Gemfile.lockで指定しているBundlerバージョンを取得。

Command Reference - RubyGems Guides

bundle installのオプション補足:

  • --jobs
    • ダウンロード・インストールを並行して行うジョブ数を指定できる。

lintの実行

今回のプロジェクトでは、Rubocopとeslint、prettierを利用しています。

      - name: Run rubocop
         run: bundle exec rubocop

      - name: Run lint
         run: npm run lint

npm run lintの実行内容については、プロジェクトのpackage.jsonで指定しています。

// package.json

 "scripts": {
    // 中略
    "lint": "run-p lint:*",
    "lint:eslint": "eslint 'app/javascript/**/*.js' --max-warnings=0",
    "lint:prettier": "prettier app/javascript/**/*.js --check"
  },

テストの実行

今回のプロジェクトでは、RSpecを利用しています。
現段階では、ユニットテストの実行のみを確認しています。
システムテストの実行にはブラウザのインストールが必要など、実行するテストによって追加の設定が必要になります。

      - name: Run tests
         run: bundle exec rspec

CodeQLの設定

CodeQLのワークフローは、リポジトリ設定の「CodeQL analysis」をAdvancedで有効化することで、GitHubが作成してくれます。
今回はワークフローの内容は変更していません。

リポジトリ設定については、こちらの記事に詳細を記載しています。
peno022.hatenablog.com

ワークフローのymlファイルは下記になります。(クリックすると開きます)
========

.github/workflows/codeql.yml

# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"

on:
  push:
    branches: [ "main" ]
  pull_request:
    # The branches below must be a subset of the branches above
    branches: [ "main" ]
  schedule:
    - cron: '24 13 * * 6'

jobs:
  analyze:
    name: Analyze
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: read
      security-events: write

    strategy:
      fail-fast: false
      matrix:
        language: [ 'javascript', 'ruby' ]
        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
        # Use only 'java' to analyze code written in Java, Kotlin or both
        # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support

    steps:
    - name: Checkout repository
      uses: actions/checkout@v3

    # Initializes the CodeQL tools for scanning.
    - name: Initialize CodeQL
      uses: github/codeql-action/init@v2
      with:
        languages: ${{ matrix.language }}
        # If you wish to specify custom queries, you can do so here or in a config file.
        # By default, queries listed here will override any specified in a config file.
        # Prefix the list here with "+" to use these queries and those in the config file.

        # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
        # queries: security-extended,security-and-quality


    # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).
    # If this step fails, then you should remove it and run the build manually (see below)
    - name: Autobuild
      uses: github/codeql-action/autobuild@v2

    # ℹ️ Command-line programs to run using the OS shell.
    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun

    #   If the Autobuild fails above, remove it and uncomment the following three lines.
    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.

    # - run: |
    #   echo "Run, Build Application using script"
    #   ./location_of_script_within_repo/buildscript.sh

    - name: Perform CodeQL Analysis
      uses: github/codeql-action/analyze@v2
      with:
        category: "/language:${{matrix.language}}"

========

指定したチェックをパスしないとPRをマージできないようにする

Repository Settings の Code and automation > Branches の画面を開きます。
今回はmainブランチへのマージを制限したいので、Branch protection rulesのmainの設定を編集します。

Require status checks to pass before merging を有効にし、マージ時に必須にしたいジョブを検索・選択します。

Require status checks to pass before merging を有効にする
「Require status checks to pass before merging」を有効にする

この設定により、CIがパスしていない状態だと、下記のようにPRのマージができないよう警告表示になります。

リリース用PRの自動作成をする

mainブランチ上のタグでリリースバージョンを管理したいため、tagprというアクションを導入しました。
導入方法については、こちらの記事をご参照ください。
(長くなったので別記事に切り出しました。。)

peno022.hatenablog.com

完了!

これで、やりたいこととして最初に設定したことは実現できました。

再掲:

  • featureブランチからmainへのPRを作成した時
    • CIが動き、パスしなければPRはマージできない
  • featureブランチにpushしてコミットを追加した時
    • CIが動き、パスしなければPRはマージできない
  • featureブランチをmainへマージした時
    • リリース用のPRを作成(リリースブランチからmain)
    • リリースブランチに対してCIが動き、パスしなければPRはマージできない
  • リリース用のPRをmainへマージした時
    • リリースバージョンのtagを打つ
    • tagが打たれたmainを本番にデプロイする

GitHub Actionsって何?という状態からだったのでけっこう苦労しましたが、次回からはスッとできそうです。