thasmto's blog

フロントエンドエンジニアやってます。プログラマーとして学んだことや考えていることを投稿するブログです。

npm init や yarn create でジェネレータを利用できる理由

Next.jsSlidev などのフロントエンドのOSSnpm init slidevnpx create-next-app のようなテンプレートからプロジェクト作成するためのジェネレータが用意されており、なぜ npm initnpx など複数の方法でジェネレータを利用できるのか気になったので調べました。

パッケージ名にルールがある

NPMのドキュメントnpm init がジェネレータを認識するためのルールが記載されており、 どうやら create-<name> のような特定のルールでパッケージ名を指定するとジェネレータとしてNPMが認識してくれるようです。

具体的なルールは下記になります。

npm init <@scope> (same as `npm exec <@scope>/create`)
npm init [<@scope>/]<name> (same as `npm exec [<@scope>/]create-<name>`)

パッケージを scoped-module として作成する場合は、@thasmto/create のようにスコープ名の後ろに create をつけたパッケージ名にすることで npm init @thasmtoで実行できるようになります。
また、 scoped-module ではない場合でも create-thasmto のように create- から始まるパッケージ名にすることで npm init thasmto と実行できます。

本来、npm init は新しい package.json を作成するために利用されますが、上記のルールに当てはまるパッケージが存在する場合は、内部で npx exec というコマンドが動きます。 そして npm exec はパッケージのコマンドを実行するためのコマンドであり、エイリアスとして npx が用意されています。

このことから以下の三つコマンドは同じパッケージを実行することがわかりました。

npm init next-app
npm exec create-next-app
npx create-next-app

また、yarn createnpm init と同様に create-* と名前付けされたパッケージを実行できます。

おわり

普段あまり意識することはないですがちょっとした疑問を解消をできました。 実際にジェネレータを作るには、テンプレートからプロジェクトディレクトリを生成するためのNode.jsのスクリプトを書く必要がありますが、その場合は下記の記事が参考になりそうでした。

medium.com

Fish shellを導入した時の手順メモ

Fish インストール

brew install fish

iterm2起動時のシェルをfishに変更

  1. ターミナルでwhich fishを実行して、fish実行ファイルの場所を確認
  2. iterm2 → preferences → profiles → general → command
  3. Custom Shellを選択
  4. 1で確認したfish実行ファイルのパスを設定

powerlineインストール

brew install python
pip3 install powerline-status

fontをインストール

git clone https://github.com/powerline/fonts.git --depth=1
cd fonts
./install.sh
cd ..
rm -rf fonts

iterm2でフォントを設定

iTerm2 → Preferences → Profiles → Text → Font → Change Font → Meslo LG M Regular for Powerline
iterm2を再起動する

fisher インストール

curl https://git.io/fisher --create-dirs -sLo ~/.config/fish/functions/fisher.fish    

テーマインストール

fisher install oh-my-fish/theme-agonister

config.fishにテーマの設定を記述

~/.config/fish/config.fishファイルに以下を追記

set fish_theme agnoster

参考

Emotion コーディングルール案

はじめに

社内のプロジェクトでCSSinJSライブラ入りとしてemotionを導入したのでコーディングルールを考えてみました。

プロジェクトで使用する技術

package version
next v11.1.2
typescript v4.4.3
@emotion/react v11.4.1

css関数を使ってスタイルを定義する

emotionではstyle-componentと同様にスタイルを含むコンポーネントを作成するstyled関数と、コンポーネントと独立してスタイルを定義できるcss関数が用意されています。

css関数を使うことで従来のマークアップのようにHTMLとCSSを分別することができるので、css関数を使うことにします。 emotionを導入した理由もcss関数が使えることが大きいです。

import { css, styled } from '@emtion/react';

// styledを使った書き方
const StyleButton = styled.button`
  width: 100px;
`;
const Button = ({ children }) => {
  return <StyledButton>{ children }</styledButton>
}

// cssを使った書き方
const buttonStyle = css`
  width: 100px;
`;
const Button = ({ children }) => {
  return <button css={buttonCss}>{children}</button>
}

cssの記述はファイルの下段に定義する

一つのjsxファイルでコンポーネントのロジック、HTML、CSSを記述するので、それぞれの記述が混在すると可読性が落ちてしまいます。

VueのSFCの記述のように一番下にスタイルを記述することでどこに何が記載されているか予測しやくなります。

参考:レシピサービスのフロントエンドに CSS in JS を採用した話

css関数で作ったスタイルの変数名にプレフィックスとして「css」を付ける

変数のプレフィックスを統一することで、一目でスタイルを表す変数であると認識しやすくなります。 プレフィックスにはstylecssなどいくつか候補あると思いますが、文字数が短くcssプロパティともイメージづけしやすいcssプレフィックスとしてつけると良いと考えます。 また、エディタの補完機能が働き、cssと入力した時点で作成したスタイルの一覧が入力候補として表示されるようになります。 f:id:thasmto:20210925172742p:plain

const Button = ({children}) => {
  return <button css={cssButton}>{ children }</button>
}

const cssButton = css`
  background: #ddd;
  border-radius: 30px;
`;

Highlightの設定

その他にVSCode拡張機能Highlight」を使って、cssから始まる文字の色を変更することで、変数がcss関数で作られたスタイルを意味するのかそれ以外なのか区別しやすくできます。

拡張機能をインストールして、VSCodeの設定ファイルに以下の設定を追加する。 colorは自由に変更してください。

    "highlight.regexes": {
        "(css[a-zA-Z0-9_]+)": {
            "filterFileRegex": ".*(jsx|tsx)$",
            "decorations": [
                {
                    "color": "OldLace",
                }
            ]
        },
    }

f:id:thasmto:20210925172835p:plain

コンポーネントからcssを渡したいときは、子コンポーネント側はclassNameで受け取り、cssプロパティの後ろにclassNameを設定する

emotionを使用すると、cssプロパティで渡したスタイルは内部でclassNameというpropsに変換されて子コンポーネントに渡されます。(参考記事:Emotionを使いこなす) そのため、親から子へスタイルを渡した場合は、子コンポーネントcssプロパティとclassNameプロパティの両方を扱う必要があります。

この二つを同時に指定した場合、classNameプロパティで渡したスタイルの優先度が高くなるのですが、どちらのプロパティを先に記述した方が可読性が高いか考えるときにcssプロパティ内でのマージ方法を参考にします。

cssプロパティ上でスタイルをマージする方法として、配列でcss={[style1, style2]}と記述する方法があります。 この場合、配列の後ろに格納したstyle2の優先順位が高くなります。

このルールに基づき、cssプロパティより優先度が高いclassNameプロパティを後ろに書くことで優先順位をイメージしやすくなります。

const Parent = () => {
  return <div>
    <Child css={cssParentToChild} />
  </div>
}

const Child: React.FC<{ className?: string}> = ({ className }) => {
  return <div css={[cssChild1, cssChild2]} className={className}>child</div>
}
const cssParentToChild = css`
  top: 3px;
`;
const cssChild1 = css`
  top: 1px;
`;
const cssChild2 = css`
  top: 2px;
`;

このように指定すると、以下のようにスタイルが定義されます。 f:id:thasmto:20210925173113p:plain

コンポーネント設計の基礎

ソフトウェアコンポーネントについて、Wikipediaでは以下のように記載されています。

Wikipedia:ソフトウェアコンポーネント

事前に製作されたコンポーネント群を組み合わせて、電気製品や機械製品を作るようにソフトウェアを作れることを強調する。

このようにコンポーネント指向の開発ではいくつかのコンポーネントを組み合わせてサービスを開発します。
そのため、ひとつ一つのコンポーネントは様々な組み合わせに耐えうる設計をしなければいけません。
そのようなコンポーネントの設計を行う上での基礎的な考え方を紹介します。

コンポーネントにマージンやポジションなどの外側のレイアウトを設定しない

コンポーネントが持つ情報はコンポーネントの内側の情報だけにとどめることで、外側がどんな状況でも組み合わせやすくなります。

隣のコンポーネントと何pxスペースをあけるか、親コンポーネントの左端から何pxの位置にコンポーネントを置くかという、マージンやポジションはコンポーネント の外側の情報になるのでコンポーネントに持たせるべきではありません。

コンポーネントに情報を持たせるか迷った時は、情報がコンポーネントの内側に関することなのかという点は一つの指標になると思います。

コンポーネントに親コンポーネントの知識を持たせない

先ほども述べたとおり、コンポーネントが外側を知りすぎると良くありません。
コンポーネント→子コンポーネントの順番でコンポーネント を作成していると、
子が親の知識(コンポーネントの外の知識)を持っていることを前提にした「親<子」の関係になってしまいがちです。
この間違いをすると、特定の親コンポーネント でしか使えないコンポーネントになってしまいます。
コンポーネントを設計する際は、親コンポーネント が子コンポーネントの知識を包括する「親>子」の関係を意識したほうがいいです。

// Bad

const Parent = () => {
  const list = getList();
  const index = getIndex();
  
  return <div><Child list={list} index={index}  /></div>
}

// ✖︎ 子が親のstateを知りすぎている
const Child = ({list, index}) => {
  const item = list[index]
  return <div>{item}</div>
}
// Good

const Parent = () => {
  const list = getList();
  const index = getIndex();

  return <div><Child item={list[index]}  /></div>
}
  
// ○ 子は自分自身の必要最低限の情報のみ知っている
// itemさえあればどこでもコンポーネントを呼び出せる
const Child = ({item}) => {
  return <div>{item}</div>
}

インターフェースを統一する

コンポーネントを利用する人がコンポーネントについて学ばなければいけないことがあまりに多いと、それは利便性に欠けた設計と言えます。
利用者がコンポーネント名や利用シーンからある程度の使い方をイメージできなければ、間違った使われ方をしたり、呼び出されたコードを見ても何を意味しているかわかりづらく可読性の低いコードになってしまうので注意が必要です。

特に、同じような意味合いのデータやプロップスでもコンポーネントによって名前が違ったり、データの型が異なっていると利用者は混乱します。最低限、コンポーネント のインターフェースを統一することを意識しましょう。

簡単に始められることとして、既存のHTMLの要素のインターフェースを真似することをおすすめします。 例えば、ボタンコンポーネントを作成する場合は、HTMLのbutton要素のようにdisabled属性を受け取ってボタンを非活性に出来たり、タグでテキストを囲むとボタン内のテキストとして表示するといった具合です。

// buttonタグのインターフェースを利用できていない例
<Button isDisable text="進む" />
// buttonタグのインターフェースを利用している例
<Button disabled>進む</Button>

また、HTML要素も再利用可能なコンポーネントの一つとして捉えることができ、利用者がそれらを呼び出す際にHTML要素なのか、それとも作成されたコンポーネントなのか意識しなくてもいいような設計が望ましいと考えます。

そういった意味でもHTMLの要素がどういったインターフェースになっているか観察し、真似できるところがないか考えてみることは重要です。

粒度を意識する

コンポーネントはどれだけUIの情報を詰め込むかで粒度が変わってきます。 一つのコンポーネントで細かいUIの情報を多く含めすぎると、違いがほとんどないUIを作りたいとなったときでも、それらを構成するUIを再利用できず、ほとんど内容が変わらないコンポーネントを増やすか、無理矢理拡張性を持たせることになってしまいます。

再利用可能なコンポーネントにするためには、切り分けられる最小の単位までコンポーネント分割することです。小さく分割したコンポーネント群は、そうでないものに比べて多様な組み合わせが可能になります。

f:id:thasmto:20210822234601p:plain
コンポーネントの粒度を意識する

UIを分割したときコンポーネントの最小単位は機能ごとに変わってきます。 例えば、ボタンコンポーネントはbuttonタグとスタイルだけで構築可能ですが、 ヘッダーコンポーネントは各ページへのナビゲーションやロゴ、ハンバーガーメニューなどの構成要素を必要とします。 このコンポーネントごとの粒度の違いを意識し、整理整頓することで再利用可能なコンポーネントを開発しやすくなります。

また、メリットはそれだけではありません。 コンポーネントを適切な粒度に分割すると、それぞれのコンポーネントを修正した際の影響範囲の予測も容易になります。

粒度の小さいコンポーネントは、粒度が大きいコンポーネントの構成要素として再利用される場面が多くなります。それらに比べて粒度が大きいコンポーネントは再利用される場面は少なくなってきます。

ボタンなどの粒度の小さいコンポーネントを修正する場合は、その他のコンポーネントへの影響が出ないように意識する必要がありますが、粒度が大きいコンポーネントは使用される場面を特定しやすく、影響内容を正確に把握できるので、UIに影響がある修正も許容しやすくなります。

このようにコンポーネントを適切な粒度に切り離し、それぞれの影響範囲を予測しやすくなると、コンポーネントの修正や拡張をする際の判断を正確にできるようになります。

アトミックデザイン

https://atomicdesign.bradfrost.com/ コンポーネントの粒度の目安としてAtomic Designという方法論があります。

物質が原子や分子から構成されるように、コンポーネントを以下の5つの粒度に分類します。 Atoms(原子) Molecules(分子) Organisms(有機体) Templates(テンプレート) Pages(ページ)

詳細は割愛しますが、AtomicDesignは広く受け入れられている考え方なのでコンポーネントの粒度を考える際の目安として参考になると思います。

粒度を分類する際の注意点

粒度を分類する際に陥りやすい間違いとして、粒度を正しく分類できたとしても、そのコンポーネントの中で粒度の小さい部分まで定義してしまうことがあります。
例えば、ヘッダーコンポーネントをOrganismsに分類して作ったとしても、そのヘッダーの中でMoleculesやAtomsなどの小さい粒度のUIまで定義してしまうといったことです。
なるべくコンポーネント内の小さい粒度の実装は別のコンポーネントとして切り出すべきで、小さい粒度の実装を含めるとしても一つか二つ下の粒度(TemplatesならOrganismsとMoleculesまで)にとどめるべきと考えます。

また、階層の順番が逆になってしまっているコンポーネントもたまに見かけます。
Templatesのコンポーネントの中でPagesのコンポーネントを呼び出していたり、
Moleculesの中コンポーネントの中でOrganismsのコンポーネントを呼び出すといったことです。
実装しているコンポーネントの粒度だけでなく、その中で呼び出すコンポーネントの粒度も意識しなければいけません。

コードリーディング reg-suit/reg-keygen-git-hash-plugin

業務でビジュアルリグレッションテストの導入を進めており、画像の比較と差分出力のツールとしてNode.jsで動くreg-suitを使用することになりました。

reg-suitではGitのコミットを辿り、どのコミットを比較対象にするか選択することができる「reg-keygen-git-hash-plugin」というプラグインがあり、これを使用することでブランチをマージする前などに作業ブランチとその親ブランチの変更点をスクリーンショットで比較をすることができます。

このプラグインでどのようにNode.jsでGitのブランチやコミットの情報を取得し、利用しているのか気になったのでプラグインのコードを見てみました。

reg-keygen-git-hash-plugin

比較するスクリーンショットを識別するキーとしてGitのコミットハッシュを識別するために使われるプラグイン。 どのように比較するかは公式のREADME内で提示されているグラフを見るとわかりやすいです。

f:id:thasmto:20210711211428p:plain
引用:https://github.com/reg-viz/reg-suit/blob/master/packages/reg-keygen-git-hash-plugin/README.md
分岐元のコミットと現在のブランチのコミットを比較対象としています。

コードを見ていく

srcの中のファイルは以下の3つ。

index.ts

まずはindex.tsから見ていきます。

// index.ts
import { KeyGeneratorPlugin, PluginCreateOptions, KeyGeneratorPluginFactory } from "reg-suit-interface";
import { fsUtil } from "reg-suit-util";

import { CommitExplorer } from "./commit-explorer";

class GitHashKeyGenPlugin implements KeyGeneratorPlugin {
  // ...省略

  init(config: PluginCreateOptions): void {
    this._conf = config;
  }

  // 比較元のコミットハッシュを取得
  getExpectedKey(): Promise<string> {
    // ...省略
  }
  
 // 比較先のコミットハッシュを取得
  getActualKey(): Promise<string> {
    // ...省略
  }
  // ...省略
}

const pluginFactory: KeyGeneratorPluginFactory = () => {
  return {
    keyGenerator: new GitHashKeyGenPlugin(),
  };
};

export = pluginFactory;

index.tsにはGitHashKeyGenPluginクラスが定義されています。
getExpectedKeygetActuralKeyが重要そうで、メソッドの名前から比較元と比較先のキーとしてGitのコミットハッシュを取得をするメソッドだと予測できます。
getExpectedKeyの中は以下のようになっています。

class GitHashKeyGenPlugin implements KeyGeneratorPlugin {
  // ...
  getExpectedKey(): Promise<string> {
    if (!this._checkAndMessage()) {
      return Promise.reject<string>(null);
    }
    try {
      const result = this._explorer.getBaseCommitHash();
      if (result) {
        return Promise.resolve(result);
      } else {
        return Promise.reject<string>(null);
      }
    } catch (e) {
      this._conf.logger.error(this._conf.logger.colors.red(e.message));
      return Promise.reject<string>(null);
    }
  }
 ...
}

_checkAndMessage()はgitの情報を取得できるか判断するために.gitディレクトリが作業ディレクトリ内に存在するかをチェックするメソッド。 Git操作可能であれば別ファイルから読み込んだCommitExplorerクラスのインスタンスからgetBaseCommitHashメソッドを呼び出して、比較元のコミットハッシュを取得しています。

getActualKeyメソッドの処理も同じようにCommitExplorerのインスタンスからgetCurrentCommitHashメソッドを呼び出して比較先のコミットハッシュを取得しています。

commit-explorer.ts

次にCommitExplorerの詳細を見ていきます。 GitCmdClientクラスで取得したコミット情報を利用して比較対象のコミットを探すための処理がまとめられています。 見ていくコード量が多かったので、コード内に直接メモを書いています。 また、複雑で理解しきれていないくてメモできていない部分や間違ったことを書いている可能性がありますのでご了承ください。

import { GitCmdClient } from "./git-cmd-client";

export type CommitNode = string[];

export class CommitExplorer {
  private _gitCmdClient = new GitCmdClient();
  private _commitNodes!: CommitNode[];
  private _branchName!: string;
  private _branchNameCache: { [hash: string]: string[] } = {};

  /*
   * e.g. return `[["a38df15", "8e1ac3a"], ["8e1ac3a", "7ba8507"]]`.
   *      The first element of node means commit hash, rest elements means parent commit hashes.
   */
  // カレントブランチ内にあるコミットハッシュと親コミットハッシュのペアを格納した配列を取得する
  getCommitNodes(): CommitNode[] {
    return this._gitCmdClient
      .logGraph()
      .split("\n")
      .map((hashes: string) =>
        hashes
          .replace(/\*|\/|\||\\|_|-+\.|/g, "")
          .split(" ")
          .filter(hash => !!hash),
      )
      .filter((hashes: CommitNode) => hashes.length);
  }

  /*
   * e.g. return `master`.
   */
  // 作業ブランチのブランチ名を取得する
  getCurrentBranchName(): string {
    const currentName = this._gitCmdClient.currentName().replace("\n", "");
    if (
      currentName.startsWith("(HEAD detached") ||
      currentName.startsWith("(no branch") ||
      currentName.startsWith("(detached from") ||
      (currentName.startsWith("[") && currentName.indexOf("detached") !== -1)
    ) {
      throw new Error("Can't detect branch name because HEAD is on detached commit node.");
    }
    return currentName;
  }

  /*
   * e.g. return `ede92258d154f1ba1e88dc109a83b9ba143d561e`.
   */
  // 作業ブランチの現在のコミットのハッシュを取得する
  getCurrentCommitHash(): string {
    const currentName = this._branchName;
    if (!currentName || !currentName.length) {
      throw new Error("Fail to detect the current branch.");
    }
    return this._gitCmdClient.revParse(currentName).replace("\n", "");
  }

  /*
   * Return branch name including target hash.
   * e.g. `["master", "feat-x"]`.
   */
  // 引数に渡したコミットハッシュのコミットを含むブランチの名前を配列で取得する
  getBranchNames(hash: string): string[] {
    if (this._branchNameCache[hash]) return this._branchNameCache[hash];
    const names = this._gitCmdClient
      .containedBranches(hash)
      .split("\n")
      .filter(h => !!h)
      .map(branch => branch.replace("*", "").trim());
    this._branchNameCache[hash] = names;
    return names;
  }
  
  // 全てのブランチ名を配列で取得する
  getAllBranchNames(): string[] {
    return this._gitCmdClient
      .branches()
      .split("\n")
      .map(b => b.replace(/^\*/, "").trim().split(" ")[0])
      .filter(b => !!b || b === this._branchName)
      .filter((x, i, self) => self.indexOf(x) === i);
  }
  
  // 作業ブランチと引数に渡したハッシュのブランチとの分岐元のハッシュを取得する
  getIntersection(hash: string): string | undefined {
    try {
      return this._gitCmdClient.mergeBase(hash, this._branchName).slice(0, 8);
    } catch (e) {}
  }


  getBranchHash(): string | undefined {
    const branches = this.getAllBranchNames();
    return branches
      .map(b => {
        
        const hash = this.getIntersection(b);
        // 作業ブランチと引数に渡したハッシュのブランチとの分岐元のハッシュを取得する

        const time = hash ? new Date(this._gitCmdClient.logTime(hash).trim()).getTime() : Number.MAX_SAFE_INTEGER;
        // hashが作られた時間を取得する

        return { hash, time };
      })
      .filter(a => !!a.hash)
      .sort((a, b) => a.time - b.time) // ハッシュが古い順にソート
      .map(b => b.hash)[0]; // 一番最初に作られた分岐元のブランチを返す
  }

  getCandidateHashes(): string[] {
    const mergedBranches = this.getBranchNames(this._commitNodes[0][0]).filter(
      b => !b.endsWith("/" + this._branchName) && b !== this._branchName,
    );
    // this.getBranchNames(this._commitNodes[0][0])は作業ブランチの
    // 最新コミットを含んでいるブランチ名一覧(作業ブランチと、作業ブランチからさらに分岐しているブランチ、
    // 作業ブランチをすでにマージしているブランチの一覧)
    
    // branch:master
    // *
    // |\
    // | * currentコミット(branch:a)
    // |/ \
    // *   * other(branch:b)
    //
    // この場合、this._commitNodes[0][0]はcurrentコミットが入るので、
    // getBrancheNamesで取得できるブランチ名はmaster, a, b の3つになる
    // this._branchNameであるaブランチと**/aとなるブランチが除かれ、
    // mergedBranchesには['master', 'b']が返る

    return this._commitNodes
      .map(c => c[0])
      .filter(c => {
        const branches = this.getBranchNames(c);
      
        
        const hasCurrent = !!branches.find(b => this._branchName === b);
        // this._commitNodesはcurrentブランチ(this._branchName)上のログに含まれるコミットの一覧なのでhasCurrentは必ずtrueになる?
        
        const others = branches.filter(b => {
          return !(b.endsWith(this._branchName) || (mergedBranches.length && mergedBranches.some(c => b === c)));
        });
        // currentブランチ以外にもコミットを含んでいるブランチ
        
        return hasCurrent && !!others.length;
        // currentブランチに存在して、他のブランチにも存在しているコミットを返す
      });
  }
 
  isReachable(a: string, b: string) {
    const between = this._gitCmdClient.logBetween(a, b).trim();
    // aからは到達できないがbからは到達できるコミットを返す。
    // つまりaのあとbまでの間に追加されたコミットを返す。

    return !between;
    // aのあとにbまでの間に追加されたコミットがなければtrueを返す
  }

  findBaseCommitHash(candidateHashes: string[], branchHash: string): string | undefined {
    const traverseLog = (candidateHash: string): boolean | undefined => {
      if (candidateHash === branchHash) return true;
      return this.isReachable(candidateHash, branchHash);
      // currentブランチに存在して他のブランチにも存在しているコミットと、分岐元のコミットのハッシュの間にコミットがなければtrueを返す
    };
    const target = candidateHashes.find(hash => !!traverseLog(hash));
    return target;
  }

  getBaseCommitHash(): string | null {
    this._branchName = this.getCurrentBranchName();
    this._commitNodes = this.getCommitNodes();
    
    const candidateHashes = this.getCandidateHashes();
    // currentブランチに存在して、他のブランチにも存在しているコミットのハッシュ
    const branchHash = this.getBranchHash();
    if (!branchHash) return null;
    const baseHash = this.findBaseCommitHash(candidateHashes, branchHash);
    if (!baseHash) return null;
    const result = this._gitCmdClient.revParse(baseHash).replace("\n", "");
    return result ? result : null;
  }
}

git-cmd-client.ts

最後にGitCmdClientクラスのコードを見ていきます。 Gitのコマンドを直接呼び出す処理がまとめられています。

コード概要

import { execSync } from "child_process";
import shellEscape from "shell-escape";

export class GitCmdClient {
  private _revParseHash: { [key: string]: string } = {};
  
  // 現在選択しているブランチ名を出力
  currentName() {
    return execSync('git branch | grep "^\\*" | cut -b 3-', { encoding: "utf8" });
  }
  
  // 現在のブランチの最新のコミットを出力
  revParse(currentName: string) {
    if (!this._revParseHash[currentName]) {
      this._revParseHash[currentName] = execSync(`git rev-parse "${currentName}"`, { encoding: "utf8" });
    }
    return this._revParseHash[currentName];
  }

  // ブランチの一覧を出力
  branches() {
    return execSync("git branch -a", { encoding: "utf8" });
  }

  // 引数に渡したハッシュのコミットを含んだブランチの一覧を出力
  containedBranches(hash: string): string {
    return execSync(shellEscape(["git", "branch", "-a", "--contains", hash]), { encoding: "utf8" });
  }
  
  // 引数に渡したハッシュのコミットのコミットされた日時を出力
  logTime(hash: string) {
    return execSync(shellEscape(["git", "log", "--pretty=%ci", "-n", "1", hash]), { encoding: "utf8" });
  }
  
  // 引数に渡した期間のコミットを出力
  logBetween(a: string, b: string) {
    return execSync(shellEscape(["git", "log", "--oneline", `${a}..${b}`]), { encoding: "utf8" });
  }
  
  // コミットハッシュと親のコミットハッシュのペアのリストを出力
  logGraph() {
    return execSync('git log -n 300 --graph --pretty=format:"%h %p"', { encoding: "utf8" });
  }
  
  // 引数に渡した二つのブランチの分岐元となるコミットを出力
  mergeBase(a: string, b: string) {
    return execSync(shellEscape(["git", "merge-base", "-a", a, b]), { encoding: "utf8" });
  }
}

使用しているモジュール 

Node.js child_process

https://nodejs.org/api/child_process.html#child_process_child_process

The child_process module provides the ability to spawn subprocesses in a manner that is similar, but not identical, to popen(3). This capability is primarily provided by the child_process.spawn() function:

サブプロセスを生成する機能を提供するモジュール。

child_process.execSync()

The child_process.execSync() method is generally identical to child_process.exec() with the exception that the method will not return until the child process has fully closed. When a timeout has been encountered and killSignal is sent, the method won't return until the process has completely exited. If the child process intercepts and handles the SIGTERM signal and doesn't exit, the parent process will wait until the child process has exited.

execメソッドを非同期ではなく同期処理で呼び出すメソッド。 Node.jsのメソッドは基本的に非同期処理されるので、同期処理をしたい場合はメソッド名の後ろにSyncとついたメソッドが用意されていることが多い。

child_process.exec()

https://nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback

Spawns a shell then executes the command within that shell, buffering any generated output. The command string passed to the exec function is processed directly by the shell and special characters (vary based on shell) need to be dealt with accordingly

シェルを生成し、そのシェル内でコマンドを実行して、生成された出力をバッファリングする。

shell-escape

https://github.com/xxorax/node-shell-escape
コマンドを構成する文字列を配列で渡すことで、コマンド実行に適した文字列にエスケープする

// 実行例
var shellescape = require('shell-escape');

var args = ['curl', '-v', '-H', 'Location;', '-H', 'User-Agent: dave#10', 'http://www.daveeddy.com/?name=dave&age=24'];

var escaped = shellescape(args);
console.log(escaped);
//出力結果
curl -v -H 'Location;' -H 'User-Agent: dave#10' 'http://www.daveeddy.com/?name=dave&age=24'

まとめ

コードを見てなんとなく全体像を掴めました。 Nodejsでのコマンドの実行にchild_processモジュールとshell-escapeもモジュールが使われていることがわかったので、今後コマンドラインツールを作るときに活用できそうです。 また、Gitコマンドを直接呼び出すクラスとコミット情報を加工するクラスを分けるといったクラス設計の考え方も具体的な実装から学ぶことができました。

テスト駆動開発 第一部

テスト駆動開発の第一部を読んだのでそのメモ。

TDDのゴール

「動作するきれいなコード」

(以下、本書の引用)

「動作するきれいなコード」。RonJeffriesのこの簡潔な言葉が、テスト駆動開発(TDD)のゴールだ。動作するきれいなコードはあらゆる意味で価値がある。

開発が予測可能になる。 完成したかどうかがわかり、バグが残っているかを心配する必要もない。 コードが伝えようとしていることを余すところなく受け取れる。 最初に思いついたコードを書き殴っただけで終わりなら、再考してより良いコードを書くチャンスは永遠に来ない。 あなたが作るソフトウェアのユーザを快適にする。 チームメイトはあなたを信頼し、あなたもまたチームメイトを信頼する。 書いていて気持ちがいい。

TDDのルール

  • 自動化されたテストが失敗したときのみ、新しいコードを書く。
  • 重複を除去する。

TDDの流れ

  1. まずはテストを1つ書く
  2. すべてのテストを走らせ、新しいテストの失敗を確認する
  3. 小さな変更を行うすべてのテストを走らせ、すべて成功することを確認する
  4. リファクタリングを行って重複を除去する

まずはテストを1つ書く

TDDはテストを書くところから始まる。 テストを書くときに用途にあったインターフェースを考える。そのコンポーネントや関数がどのように呼び出されるのか、どのようなプロパティを受け取りどのような値を返すのか理想的なAPIから考える。

すべてのテストを走らせ、新しいテストの失敗を確認する

テストの内容をプロダクトコードに反映していないので、テストは当然失敗する。

小さな変更を行うすべてのテストを走らせ、すべて成功することを確認する

コードのクオリティは気にせずテストを通すための実装をする。 すばやくテストを通すために以下の2つの書き方を意識する。 仮実装:コードでまずベタ書きの値を使い、実装を進めるに従って、徐々に変数に置き換えていく。 明白な実装:すぐに頭の中の実装をコードに落とす。

リファクタリングを行って重複を除去する

べた書きで値を返してしまっている箇所や重複したコードなどリファクタリングしていく。 すでに自動テストがあるので、安心してコードを修正できる。

はじめは小さいステップで

この流れを小さいステップで繰り返していく。大きな処理は最初から正しいアーキテクチャで書くことは難しく、書き終わったあとに根本から修正することが難しくなる。

ただし、ステップは可能な限り小さくしなくてもいい。これまでの実装から正しい実装を書けると判断できる場合には小さいステップは省いてしまってもいいし、正しい設計が思いつかないのであればまずは小さいステップからはじめてもいい。

(以下、本書の引用)

TDDを行う際には、常にこのような微調整を行う。非常に細かなステップを窮屈に感じるならば、歩幅を大きくする。不安を感じるならば、歩幅を小さくする。TDDとは、あちらへ少し、こちらへ少しといった感じで舵を取るプロセスだ。正しい歩幅などというものは、未来永劫に存在しない。

そのほかTDD中に考えること

多少の回り道もOK

かならずしもTDDの流れに従わなくてもいい。 必要に応じて実装を先に書いたり、言語仕様を確認するためのテストを書いたりしてもいい。

(以下、本書の引用)

この修正をいま行うべきだろうか。それとも待つべきだろうか。教科書的な答えは「いま行っている作業を止めないために、修正は待つべき」となる。ただ、個人的な回答をするなら「ちょっとした割り込みを楽しもう。ただし、ちょっとしたものに限る」と言いたい。そこに「割り込みにさらに割り込むことはしない」というルールも加えておこう(JimCoplienがこのルールを教えてくれた)。

別のコードを修正したくなったとき

プロダクトコードを追記した結果、テストのエラーが出ているが、追記した場所とは異なる箇所でプロダクトコードを変更しなければいけないことがわかったとき、 一度テストのエラーが出ない状態まで変更を巻き戻し、新しくプロダクトコードを変更する箇所のテストを先に書いてから実装の修正を行い、テストが通れば再び取り組んでいた問題に戻る。

(以下、本書の引用)

レッドバーが出ているときに新しくテストを加えるのは避けたいのだが、プロダクトコードを変更しなければならないときが来てしまった。テスト無しでコードを変更するわけにはいかない。手堅い方法は、レッドバーの原因になった変更を巻き戻し、グリーンバーに復帰するというものだ。グリーンバーに復帰したらequalsのテストを書いてから実装の修正を行い、再び取り組んでいた問題に戻る。

実装内部で使うヘルパークラスのテストは書かない

実装のテストが通れば内部のヘルパークラスの動作が正しいことが保証されるのでヘルパークラスのテストを書かなくてもいい場合がある。

(以下、本書の引用)

今回はPairクラスのテストを書かない。なぜかというと、いまはリファクタリングの過程としてPairクラスを抽出している途中だからだ。リファクタリングが完了してすべてのテストが通るようになったら、それでPairクラスも同時にテストされていると見なしてよい。

TDDで作成されたテストはステートメントカバレッジが高くなるがパフォーマンステストなどの代替にはならない

TDDを行うとプロダクトコードの命令文全体のうち、テストを実施した割合は100%に近くなり、システム開発を続けるためには有用なものになる。 ただし、パフォーマンステスト、負荷テスト、ユーザービリティテストの代替になるわけではない。

TypeScriptでUnion型の判別

実際に案件でTypeScriptのリファクタリングを行ったのでざっくりメモ。

もともとのコードは以下のような内容。

type Form = CheckForm | FreeTextForm;

interface BaseForm {
  id: number;
  type: 'checkbox' | 'freeText';
  description: string;
}

interface CheckForm extends BaseForm {
  options: Options[];
}

interface FreeTextForm extends BaseForm {
  maxLength: number;
}

const form: Form = {
  id: 1,
  type: 'checkbox',
  description: 'select only one',
  options: [{key: 1,value: '1'},{key: 2,value: '2'}]
}


function findOptionIndex(form, key) {
  // CheckFormのときだけ処理をしたい
  if (form.type === 'checkbox') {
    const f = form as CheckForm; // form.optionsを参照できるように型を指定している
    return form.options.findIndex((opt) => opt.key === key);
  } else {
    return -1;
  }
}

Formは複数の型を結合したUnion型のため、form.optionsを参照しようとするとoptionsをメンバとして持っていない可能性があると怒られてしまう。 そのためas を使ってformの型をCheckFormとして扱うように指定されていた。

しかしこの記述をするにはいちいち変数にオブジェクトを入れ直さないといけないのであまりよろしくない。 なので、まず特定の条件をクリアした場合にどういう型として扱うか指定することができるユーザー定義の型ガードというので書き直してみた。

const isCheckForm = (form: Form): form is CheckForm => {
  return form.type === 'checkbox';
}

function findOptionIndex(form: Form, key: number) {
  if (isCheckForm(form)) {
    return form.options.findIndex((opt) => opt.key === key);
  } else {
    return -1;
  }
}

上記のように関数の返り値をarg is TypeNameとすることで、関数の返り値がtrueだった場合に引数にいれたargTypeNameで指定した型のオブジェクトとして扱うことができる。 これで可読性もだいぶ良くなり型の予測もしやすくなった。

ただ一つ問題があり、この方法だとisの後ろにどんな型でも指定できるので、開発者が間違った型を指定するとTypeScriptがエラーを検知できなくなるリスクがある。

Union型はメンバが持つリテラル型のプロパティを元にUnion型のメンバを判別できるらしく、最終的に先ほどのリファクタリングは諦めて、CheckFormFreeTextFormtypeプロパティに文字列を指定することで解決できた。 内容は以下のようになった。

type Form = CheckForm | FreeTextForm;

interface BaseForm {
  id: number;
  type: 'checkbox' | 'freeText';
  description: string;
}

interface CheckForm extends BaseForm {
  type: 'checkbox'; // 判別のためにリテラル型のプロパティを含める
  options: Options[];
}

interface FreeTextForm extends BaseForm {
  type: 'freeText'; // 判別のためにリテラル型のプロパティを含める
  maxLength: number;
}

const form: Form = {
  id: 1,
  type: 'checkbox',
  description: 'select only one',
  options: [{key: 1,value: '1'},{key: 2,value: '2'}]
}


function findOptionIndex(form: Form, key: number) {
  // CheckFormのときだけ処理をしたい
  if (form.type === 'checkbox') { // CheckFormのtypeプロパティと一致しているため、CheckFormと認識される
    return form.options.findIndex((opt) => opt.key === key);
  } else {
    return -1;
  }
}

参考