suguru.dev

バンクーバーで働くエンジニアの備忘録

tsyringeによる依存性の注入と循環参照との戦い

概要

tsyringeを弊社のプロジェクトに導入して2年くらい経ちました。このDIライブラリの導入により、

  • インターフェースによる依存性の注入
  • 循環参照

の問題が発生し、多くのことを学ぶことができたのでいくつか紹介します。

今回はライブラリの基本的な使い方には触れません。

インターフェースによる依存性の注入

まずはじめにtsyringeではインターフェースによる依存性の注入を簡単に書くことができません。 こちらの例に習って書くと以下のようになります。

// useCases/UserItem.ts
export interface UserItem {
  give(itemId: string, quantity: number): Promise<void>;
}

export namespace UserItem {
  export interface Repository {
    increment(itemId: string, quantity: number): Promise<void>;
  }
}

class UserItemUseCase implements UserItem {
  constructor(@inject('UserItem.Repository') private readonly repository: UserItem.Repository) {}

  async give(itemId: string, quantity: number): Promise<void> {
    await this.repository.increment(itemId, quantity);
  }
}
container.register('UserItem', { useClass: UserItemUseCase });

// repositories/UserItem.ts
class UserItemRepository implements UserItem.Repository {
  async increment(itemId: string, quantity: number): Promise<void> {}
}
container.register('UserItem.Repository', { useClass: UserItemRepository });

インターフェースとクラスを割り当てたいだけなのにも関わらず、手動でstringやsymbolを使用して登録する必要があります。 これは、JavaScript上でインターフェースが存在できないため、インターフェースと実際のクラスをつなぐトークンが必要になるためです。

そこでJavaScript上で存在できて、かつTypeScript上でインターフェースとして扱うことができる抽象クラスを採用することにしました。

// useCases/UserItem.ts
export abstract class UserItem {
  abstract give(itemId: entities.Item.Id, quantity: number): Promise<void>;
}

export namespace UserItem {
  export abstract class Repository {
    abstract increment(itemId: entities.Item.Id, quantity: number): Promise<void>;
  }
}

@injectable(UserItem)
class UserItemUseCase implements UserItem {
  constructor(private readonly repository: UserItem.Repository) {}

  async give(itemId: entities.Item.Id, quantity: number): Promise<void> {
    await this.repository.increment(itemId, quantity);
  }
}

// repositories/UserItem.ts
@injectable(UserItem.Repository)
class UserItemRepository implements UserItem.Repository {
  async increment(itemId: entities.Item.Id, quantity: number): Promise<void> {}
}

// container.ts
import * as tsyringe from 'tsyringe';

export type Constructor<T = any> = new (...args: any[]) => T;
export type AbstractConstructor<T = any> = abstract new (...args: any[]) => T;
export type Token<T = any> = tsyringe.InjectionToken<T> | AbstractConstructor<T>;

export function injectable(...tokens: Token[]) {
  return (Class: Constructor) => {
    tsyringe.injectable()(Class);
    registerInterfaces(Class, tokens);
  };
}

function registerInterfaces(Class: Constructor, tokens: Token[]) {
  for (const token of tokens) {
    tsyringe.container.register(token as tsyringe.InjectionToken, { useToken: Class });
  }
}

これにより"インターフェース"による依存性の注入が簡単に行えるようになりました。

余談ですが、インターフェース名にI接頭辞を仕様することはTypeScriptでは非推奨です。これは依存先がインターフェースかクラスかを知るべきではないからです。 この原則からもインターフェースのときのみ@inject を使用をするということは避けたいものがあります。

他のデコレータもこちらに載せてあるのでぜひ参考にしてみてください。

循環参照との戦い

Node.jsの背景

Node.jsは循環参照を言語仕様でサポートしています。実際tsryingeを使うまで、循環参照の問題に直面することは今まで一度もありませんでした。

// UserItem.ts
import * as UserReward from './UserReward';
export async function give(id: entities.Item.Id, quantity: number): Promise<void> {
  const item = await repository.get(id);
  if (item.type === entities.Item.Type.Reward) {
    await UserReward.give(item.rewardId, quantity);
  }
}

// UserReward.ts
import * as UserItem from './UserItem';
export async function give(id: entities.Item.Id, quantity: number): Promise<void> {
  const reward = await repository.get(id);
  await UserItem.give(itemId, quantity);
}

このコードはNode.js上では何も問題なく動きます。今回はあまり触れませんが、requireが循環参照を解決してくれるためです。 しかしtsryingeではうまく動きませんでいた。

// UserItem.ts
import { UserReward } from './UserReward';

@injectable(UserItem)
class UserItemUseCase implements UserItem {
  constructor(private readonly userRewardUseCase: UserReward, private readonly repository: UserItem.Repository) {}

  async give(itemId: entities.Item.Id, quantity: number): Promise<void> {
    const item = await this.repository.get(itemId);
    if (item.type !== entities.Item.Type.Reward) {
      return this.repository.increment(itemId, quantity);
    }
  }
}

// UserReward.ts
import { UserItem } from './UserItem';

@injectable(UserReward)
class UserRewardUseCase implements UserReward {
  constructor(private readonly userItemUseCase: UserItem, private readonly repository: UserReward.Repository) {}

  async give(id: entities.Reward.Id, quantity: number): Promise<void> {
    const reward = await this.repository.get(id);
    await this.userItemUseCase.give(reward.itemId, reward.quantity * quantity);
  }
}

これはTypeScriptのデコレータが同期的に依存関係を解決し、requireの恩恵が得られないためでした。 TypeScriptの言語仕様の欠陥とも見ることもできますが、私はこのことがきっかけとなりClean Architectureを勉強し・導入することになりました。

解決策1: delay

tsryingeにはdelayという関数があり、これにより循環参照を利用することが可能になります。

// UserReward.ts
@injectable(UserReward)
class UserRewardUseCase implements UserReward {
  constructor(
    @inject(delay(() => UserItem as any))
    private readonly userItemUseCase: UserItem,
    private readonly repository: UserReward.Repository,
  ) {}

  async give(id: entities.Reward.Id, quantity: number): Promise<void> {
    const reward = await this.repository.get(id);
    await this.userItemUseCase.give(reward.itemId, reward.quantity * quantity);
  }
}

言語仕様的に循環参照が認められてるので必ずしも悪とは言いませんが、これはアーキテクチャの本質的な問題から目を逸らしているだけのようにも感じ、弊社では使用を禁止しています。

解決策2: 依存性逆転

循環参照は依存の方向を考えずに書くときに起こります。そのためコードを書き始める前に予め依存の方向を決める必要があります。このケースでは依存の方向を UserReward -> UserItem に固定し、 依存性の逆転を適用します。 f:id:suguru03:20210927122809p:plain

// UserItem.ts
export namespace UserItem {
  export abstract class UserRewardUseCase {
    abstract give(rewardId: entities.Item.RewardId, quantity: number): Promise<void>;
  }
}

@injectable(UserItem)
class UserItemUseCase implements UserItem {
  private userRewardUseCase: UserItem.UserRewardUseCase;
  constructor(private readonly container: Container, private readonly repository: UserItem.Repository) {}

  async give(itemId: entities.Item.Id, quantity: number): Promise<void> {
    const item = await this.repository.get(itemId);
    if (item.type !== entities.Item.Type.Reward) {
      return this.repository.increment(itemId, quantity);
    }
    await this.userRewardUseCase.give(item.rewardId, quantity);
  }

  register(userRewardUseCase: UserItem.UserRewardUseCase) {
    this.userRewardUseCase = userRewardUseCase;
  }
}

// UserReward.ts
import { UserItem } from './UserItem';

@injectable(UserReward)
class UserRewardUseCase implements UserReward, UserItem.UserRewardUseCase {
  constructor(private readonly userItemUseCase: UserItem, private readonly repository: UserReward.Repository) {
    userItemUseCase.register(this);
  }

  async give(id: entities.Reward.Id, quantity: number): Promise<void> {
    const reward = await this.repository.get(id);
    await this.userItemUseCase.give(reward.itemId, reward.quantity * quantity);
  }
}

このようにUserReward側から依存性を注入することで循環参照を避けることができます。

解決策3:デコレータを使った依存性逆転

デコレータを使っても依存性を逆転させることができます。

// UserItem.ts
export namespace UserItem {
  export function RegisterReward() {
    return (Class: Constructor<UserItem.UserRewardUseCase>) => {
      UserItemUseCase.register(Class);
    };
  }
}

@injectable(UserItem)
class UserItemUseCase implements UserItem {
  private static UserReward: Constructor<UserItem.UserRewardUseCase>;
  constructor(private readonly container: Container, private readonly repository: UserItem.Repository) {}

  async give(itemId: entities.Item.Id, quantity: number): Promise<void> {
    const item = await this.repository.get(itemId);
    if (item.type !== entities.Item.Type.Reward) {
      return this.repository.increment(itemId, quantity);
    }
    await this.container.resolve(UserItemUseCase.UserReward).give(item.rewardId, quantity);
  }

  static register(Class: Constructor<UserItem.UserRewardUseCase>) {
    this.UserReward = Class;
  }
}

// UserReward.ts
@injectable(UserReward)
@UserItem.RegisterReward()
class UserRewardUseCase implements UserReward, UserItem.UserRewardUseCase {
  constructor(private readonly userItemUseCase: UserItem, private readonly repository: UserReward.Repository) {}

  async give(id: entities.Reward.Id, quantity: number): Promise<void> {
    const reward = await this.repository.get(id);
    await this.userItemUseCase.give(reward.itemId, reward.quantity * quantity);
  }
}

個人的にはデコレータで書くことが好きですが、どちらの方法でも良いかと思います。

まとめ

tsyringe・TypeScriptのちょっとしたテクニックと、私の循環参照との戦いについて紹介しました。 あまり触れませんでしたが、Clean Architectureは多くの依存問題を解決し、テスタブルなコードを書くことを手助けしてくれます。

こちらにコードをあげてありますのでぜひ参考にしてみてください。

おまけ

ちなみにTypeScriptのTemplate Literalのサポートにより、より厳格な型定義がしやすくなりました。

// index.d.ts
type Key<Name extends string> = string & Record<`__${Name}`, never>;


// entities/Item.ts
export namespace Item {
  export type Id = Key<'item'>;
}

リンク

Assert.Equal どっち派?

概要

Expectedは左なのか右なのか。 言語によって使い分けたいのでザックリ調べてみました。

Assert.Equal とは

言語によって細かい仕様は異なると思いますが、 引数に2つの値を取り、その値がマッチしているかをチェックする関数です。 その2つの値をよく、ExpectedActualと表記しますが、言語によって左右が異なるので調べてみました。

どちらかというと右

Document には明確に記述がないですが、Example が暗に意味しているものです。

そんなものはない

あったらすみません。

まとめ

左の派閥の強さを感じました。右がいいけど右とは強く言えない子たちが多いようです。 Node.js は独自の道を歩んだのか、なぜ Java に合わせなかったのか、疑問は残りますが、個人的には右が好きです。

NPMのパッケージの所有権を乗っ取られそうになった話

概要

ある日突然「あなたの所有するパッケージの所有権を取得するためにDispute Resolutionに従って申請を始めている」というメールをNPM経由で頂きました。 ビビりました。それをお断りするまでの軌跡です。

TL;DR

  • メールきたら無視しちゃダメ
  • はっきりと断ろう
  • チケットのクローズを見届けよう

Dispute Resolutionとは

ザックリ説明すると、NPMはパッケージ名がユニークである必要があります。原則的に、他者の所有するパッケージ名を取得することは不可能ですが、特定の条件下のみこのプロセスを通じて取得申請することができます。

  1. Get the author email with npm owner ls
  2. Open a support ticket at https://npmjs.com/support
  3. Reply to the support ticket email, adding the author on the To line so that both npm support and the author are now on the thread.
  4. Politely ask if the author will transfer the package.
  5. After 4 weeks, if there's no resolution, we'll address it.

最初のメール

彼は私の所有するrecomposer というパッケージの所有権を、長いこと更新がないこと・機能を追加したいことを理由に取得しようと申請してました。

recompose とはReact Hooksが出る以前によく使われていたHOCベースのReact用のライブラリです。recomposerとは、それをTypeScriptでラップし、メソッドチェインで使用できるようにしたライブラリです。

良くなかったのがこのときの返信でした。

私はめんどくさいことを避けるべく 「recomposerrecomposeに依存しているため、 先にrecomposeに聞いてくれ。今は譲渡するつもりはない」と返信してしまいました。recomposeはどうせ相手にしないだろうと目論んで。

第2のメール

彼は実はrecomposeの所有権の申請も同時にしており「4週間以内に有益な更新をするように」と言われました。 この4週間とは、Dispute Resolutionの最後の項にある「4週間後、決議が得られない場合、所有権を譲渡する」という内容を踏まえてだと思われました。

We are not currently accepting dispute requests to "adopt an abandoned package" as we re-evaluate and update the overall dispute process.

一応、上のポリシーにはabandoned packageは対象外と書いてあるのですが、そもそも"abandoned"という定義は曖昧で明確ではなかったのと、 私はNPMパッケージの扱いにかなりセンシティブなので、一般的にどのように対応するべきかどうかをNPMに問い合わせすることにしました。

NPMパッケージが他者に渡る危険性

recomposeは今でも127万/weekダウンロードがあるパッケージです。そのパッケージが他者に渡るとコードが改変され悪質なコードを追加されてしまう危険性があります。なのでNPMのアカウント・パッケージはGithub以上に厳重に管理する必要があります。

過去にこんな事件がありました。ご存知でない方はこちらをどうぞ。

2018/11/27に判明したnpmパッケージ乗っ取りについて - Qiita

NPMとのメール

今回のようなメールを受け取った場合、どのように対応するべきかについてNPMに問い合わせしました。 これに対する返答は「メールに対して返信すること」と「はっきりと断ること」でした、申し訳ない。

最後のメール

最後にはっきりと丁寧にお断りして、彼に感謝の意を表明して、NPMの方にチケットをクローズしていただきました。

まとめ

NPMパッケージは誰が使ってるかわかりません、責任を持って守りましょう。

世の中には色んな人がいますが、きっと彼はOSSを改善しようと考えていたのだと思います。ただ、recomposeというのを選んでしまっただけで。。。

Node.js + Github Private Registry

概要

Github Private Registryにより、プライベートなモジュールを簡単に作成することができるようになりました。 今回はGithub Actionsを用いてNPMモジュールをGithub Private registryに登録・利用する方法を紹介します。

モジュール化したいリポジトリの設定

Github Actionの設定

まず、ドキュメントに沿って以下のようなYamlファイルを作成します。

# .github/workflows/release.yml
name: Release
on:
  release:
    types: [created]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    # Setup .npmrc file to publish to npm
    - uses: actions/setup-node@v1
      with:
        node-version: '14.x'
        registry-url: 'https://npm.pkg.github.com'
    - run: yarn
    - run: yarn publish
      env:
        NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Github Actionsではリポジトリに紐付いたGITHUB_TOKENが自動的に挿入されます。このトークンはこのGithub Private Registryに対する書き込み権限を持っているので、そのまま使用することができます。

package.jsonの設定

Github Registryを使用する際に、パッケージ名に組織名を入れる必要があります。

{
  "name": "@<org name>/<package name>"
}

後はNPMのお作法に従って、

npm version patch

でタグを作成し、Github上でリリースを作成すると自動的にPrivate RegistryにPublishしてくれます。リリースはこちらからです。f:id:suguru03:20201017090921p:plain

モジュールを使用したいリポジトリの設定

次にモジュールを使用したいリポジトリ側でインストールできるように設定する必要があります。

.yarnrcの作成

使用したいリポジトリのルート配下に以下のような.yarnrcを作成します。以下の設定で特定のモジュールのみをGithub Registryから取得することができます。

registry "https://registry.npmjs.org"
"@<org name>:registry" "https://npm.pkg.github.com"

ローカルの設定

ローカル開発で使用する場合、Github Registryにアクセスできるようにする必要があります。

Personal access tokensのページからアクセストークンを作成します。権限は read:packagesだけで十分です。 f:id:suguru03:20201017092134p:plain

作成したトークンを利用してnpm loginします。

npm login --registry=https://npm.pkg.github.com
Username: suguru03
Password: <access token>

ログインが完了すると~/.npmrcが更新されており、先程のトークンがあることが確認できます。(確認できてしまっていいのかどうか…)

//registry.npmjs.org/:_authToken=<access token>

ここのトークンを書き換える必要があるだけで、npm loginは必須ではないです。

以上の設定で、リポジトリ配下でyarn installすることができるようになります。

CIの設定

Github ActionsでもCirlceCIでも、ローカル開発と同様にアクセストークンを作成する必要があります。

Github Actionsの場合

Github Actionsで自動的に挿入されるGITHUB_TOKENは他のGithub Private Registryにアクセスする権限がないため、別途作成する必要があります。 またGTIHUB_TOKENは上書きできないため、作成したトークンはNPM_TOKENとしました。NPM_TOKENはOrganization配下のsecretsに入れると、再利用ができて便利かと思います。

以下はstepsの一部抜粋です。

steps:
  - uses: actions/checkout@v2
  - uses: actions/setup-node@v1
    with:
      node-version: '14.x'
      registry-url: 'https://npm.pkg.github.com'
  - name: yarn install
    run: yarn
    env:
      NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

CircleCIの場合

CircleCIでは以下のように書きました。

steps:
  - run:
      name: npm login
      command: echo "//npm.pkg.github.com/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
  - run:
      name: yarn install 
      command: yarn

workflows:
  deploy-workflow:
    jobs:
      - build:
          context: node

こちらもGithub Actionsと同様にcontextを使用して他のリポジトリでも使用できるようにしておくと便利かと思います。

また、公式npm loginを叩いていますが、実際は叩かずともインストールが可能です。

dependencies:
  pre:
    - echo -e "$NPM_USER\n$NPM_PASS\n$NPM_EMAIL" | npm login

まとめ

Node.jsでのGithub Private Registryの使い方について紹介しました。 NPM Private Registryは少しハードルがありましたが、Github Private Registryは無料枠もあり気軽に試せるのでとてもオススメです、是非試してみてください。

リンク

AST + Prettierで快適にコードを一括変換しよう

概要

この記事はQiita Node.js Advent Calendar 2019 5日の記事です。

「Node.jsのプロジェクトにTypeScriptを導入したい」

ちょうど去年の12月に面接で聞かれたことでした。

あなたならどう答えますか?

今回は面接のネタとしても使える、ASTとPrettierを利用してコードを一括変換する方法を紹介します。

ASTとは

AST(Abstract Syntax Tree)とは抽象構文木のことで、コードを抽象化しツリー構造にパースしたものです。 身近なところではTypeScript、Eslint、Prettierなどで使用されています。

コードの変換方法

コードを変換するアプローチは色々ありますが、ASTに出会う前はよく正規表現を利用してコードの変換をしていました。

正規表現による変換

例えば varconst に変換するくらいなら正規表現のほうが早いかもしれません。

例1

以下の例を見てみましょう。

// test.js
var a = 'a';
var b = 'b'; var c = 'c';

これくらいは余裕ですね。

// 回答例
const str = fs.readFileSync(path.resolve(__dirname, './test.js'), 'utf8');
str.replace(/var/g, 'const')

例2

次の例はだいぶ意地悪なコードですが、まだいけそうな気がします。

var d = 'd'; var variable = 'var var var';
// 回答例
str.replace(/var\s(.+?=)/g, 'const $1')

例3

次の例はいかがでしょう。

/**
 Goの型宣言
 var e =1;
 C#の型宣言
 var e = 1;
**/
var e = 1;

まだなんとかなりそうですが…

正規表現でコードを変換する場合、試行錯誤して例外を見つけ、最終的に呪文のような正規表現を書くことになるかと思います。

ASTによる変換

そこで登場するのがASTです。

今回はbabelのパーサを使いコードをASTに変換します。 以下のように実行することでASTを出力することができます。ちなみに util.inspectを使うことでネスト構造のオブジェクトを展開することができます。

const parser = require('@babel/parser');
console.log(util.inspect(parser.parse(str), false, null));

以下は実行結果よりvar宣言の部分とコメントの部分の抜粋です。

// var宣言部分
Node {
  type: 'VariableDeclaration',
  kind: 'var',
  ...
}

// コメント部分
trailingComments: [
  {
    type: 'CommentBlock',
    value: '*\n Goの型宣言\n var e =1;\n C#の型宣言\n var e = 1;\n*',
    ...
  }
]

AST上では var の宣言とコメントは全く別物として扱われます。また const 定義の部分は以下のような定義になります。

// const宣言部分
Node {
  type: 'VariableDeclaration',
  kind: 'const',
  ...
}

varconstを比較したとき、大きな違いは kind の部分だけになります。

どうですか、AST簡単ですね!

しかし大きな問題があります。 パーサはパースしかしてくれずコードの再形成はしてくれないのです…困った。

Prettierによるコードの再形成

そこでPrettierの出番です。

PrettierJavaScript界隈では最も有名なコードの整形ツールの一つです。具体的にはコードを特定のパーサでASTに変換し、そのASTを元にコードを整えつつ再形成します。JavaScriptの場合、先程登場した @babel/parser が使用されています。

今回やりたいことはシンプルで、パース後のASTの var の部分を const に変換し、Prettierにコードの再形成をしてもらいたいのです。

そのために作ったツールがprettier-hookです。このツールは上記の機能をサポートしています。

コードは以下のように書きます。

// hook.js
const { Ast, hooks } = require('prettier-hook');

function parse(ast) {
  // ASTの変換処理
  return ast;
}
hooks.babylon.addHook(parse);

余談ですが、babylon とはbabel がモノレポ化前の名残で、現在は@babel/parserとして開発されています。

このparse関数の中に任意のトラバーサを挟むことができます。 今回はvarconstに変換したいだけなので、コードは以下のように書けます。

function parse(ast) {
  new Ast()
    // 変換したいTypeを指定
    .set('VariableDeclaration', (parent, key) => {
      const node = parent[key];
      node.kind = 'const'; // varをconstに変換する
    })
    .resolveAst(ast);
  return ast;
}

このAstのクラスがprettier-hookのトラバーサになります。 変換したいNodetypeを指定すると、そのtypeが現れたときに関数が呼び出され、ASTの変換を可能にしています。

以下のように実行することで、ASTを使用して変換することができます。

npx prettier-hook --require hook.js test.js
// or
npx prettier-hook --require hook.js --write test.js
// 実行結果
const a = "a";
const b = "b";
const c = "c";
const d = "d";
const variable = "var var var";
/**
 Goの型宣言
 var e =1;
 C#の型宣言
 var e = 1;
**/
const e = 1;

ツールの紹介

prettier-hookを利用して作成したツールを軽く紹介します。

JavaScrip2TypeScript

JavaScriptファイルをTypeScriptファイルに変換するツールです。

主な機能は、

などがあります。

JSDocの例

/**
 * @param {number} a
 * @param {boolean} [b]
 * @param {string} [c]
 **/
function test(a, b, c = 1) {
  return 1;
}
/**
 * @param {number} a
 * @param {boolean} [b]
 * @param {string} [c]
 **/
function test(a: number, b?: boolean, c: string | number = 1) {
  return 1;
}

Classの例

class Test {
  constructor() {
    this.num = 1;
    this.str = 'str';
  }
  getNum() {
    return this.num;
  }
  getStr() {
    return this.str;
  }
}

Test.num = 1;
class Test {
  static num: number;
  num: number;
  str: string;
  constructor() {
    this.num = 1;
    this.str = "str";
  }
  getNum() {
    return this.num;
  }
  getStr() {
    return this.str;
  }
}

Test.num = 1;

ツール作成も含め、1週間弱で400ファイル以上あるプロジェクトをTypeScriptに変換できました。

実際、ツールも完璧ではないのと、アンチパターンのコードが多かったので、変換後に手直しが必要でした。

まとめ

コードを変換する際にASTを利用するアプローチについて紹介しました。 正規表現でコードを変換するコードを書くより、ASTで書くことにより保守性も上がるケースも多いのでは無いかと思います。 ぜひコードを変換する際にはASTを使ってみて、面接でネタにしてくださいね!

リンク

Unity Editor上でのVersion Controlの使い方 〜Perforce編〜

概要

UnityにはBuilt-inのVersionControlという関数があり、これを利用することでシェルコマンドを叩くこと無くGitやPerforceなどにCommit・Submitすることができます。

Unity Editor上からファイルを自動生成・削除、及びPerfoceに反映させるツールを作ったのでその一部を紹介します。

Version Controlの設定

[Project Setitngs] > [Editor]上にVersion Controlという項目を設定します。今回はPerfoceを使用するためPerfoceの設定をしました。

f:id:suguru03:20190623052840p:plain

Connectのボタンを押してConnectedになっていることを確認してください。

VersionControlクラスの使い方

まずUnityEditor.VersionControlをインポートします、C#ではusingを使用します。

using UnityEditor.VersionControl;

また各関数でassetsのパスが必要となるため、assetsを用意しておきます。

var assetPath = Application.dataPath;
var assets = new AssetList
{
    new Asset(assetPath)
};

Task

今回使用する関数はどれもUnityEditor.VersionControl.Taskを返します。必要に応じでWaitを呼んでください。 このTaskはawaitableではないため同期処理になりプロセスを専有します。他の作業がブロックされるのでなるべく呼ぶ回数を減らすことをおすすめします。

Checkout

Perfoceではすべてのファイルがreadonlyなので、ファイルを変更する前に必ずcheckoutする必要があります。 そのため必ずWaitを呼ぶ必要があります。

Provider.Checkout(assets, CheckoutMode.Asset).Wait();

PerfoceにはChaneglistという概念があります。 Gitのbranchに似てますが、同時に複数のChangelistを使用することができます。

例えば作業AとBを同時にする必要があり、それぞれを別のChangelistで管理することでSubmitするタイミングを調整することができます。

ここでCheckoutされたファイルは自動的にdefaultのChangelistに入ります。

Add

新しいファイルを追加します。ディレクトリパスを指定することでディレクトリ配下の新規ファイルを追加することができます。

Provider.Add(assets, true).Wait();

これらのファイルはAddコマンドを呼ばない限りPerfoce上で確認することができません。

これがとても厄介で、追加漏れしたファイルは存在しているにも関わらず、Perfoce上で確認ができないため困ることが多々あります。 この追加漏れをチェックするreconcileというコマンドがありますが、かなり不便です。

Delete

既存のファイルを削除します。

Provider.Delete(assets).Wait();

Revert

変更を破棄します。 Perforceの場合、チェックアウトされたすべてのファイルがSubmitの対象になります。そのため変更されてないファイルはUnchangedで破棄する必要があります。

Provider.Revert(assets, RevertMode.Normal).Wait();
Provider.Revert(assets, RevertMode.Unchanged).Wait(); // 未変更のファイルのみを破棄

Submit

変更を反映します。 changeSetのdescriptionはChangelistのdescriptionにリンクします。第四引数はSaveOnlyです。

var changeSet = GetChangeSet();
Provider.Submit(changeSet, assets, changeSet.description, true).Wait();

Submitを2回呼ぶとコネクションが切れることがあるので、2回呼ぶことは避けたほうが良さそうです。 コネクションが切れてしまった場合はProject Settingsからまた有効にする必要があります。

Example

実際のコードではasset配下は範囲が広すぎてパフォーマンス上の懸念があるため、作業範囲を限定します。 この例は、Zipファイルを展開し、Zip内に存在するファイルの追加・変更と存在しないファイルの削除をResources配下に反映します。

// Zipファイルの展開
using (var archive = new ZipArchive(stream, ZipArchiveMode.Read, true))
{
  var assetPath = Application.dataPath;
  var resourcePath = Path.Combine(assetPath, "Resources");

  var assets = new AssetList
  {
      new Asset(resourcePath)
  };

  // Resource配下をCheckout
  Provider.Checkout(assets, CheckoutMode.Asset).Wait();

  // Zipファイル内に存在するパスのキャッシュ
  var assetPathSet = new HashSet<string>();
  foreach (var entry in archive.Entries)
  {
      var destinationPath = Path.Combine(resourcePath, entry.FullName);
      var dirName = Path.GetDirectoryName(destinationPath);
      if (dirName != null)
      {
          // ディレクトリが存在しない場合エラーになるので作成する
          Directory.CreateDirectory(dirName);
      }
      entry.ExtractToFile(destinationPath, true);
      // フルパスではなくasset配下のパスが必要なためassetのパスを取得する。これはmetaファイルにも使用される。
      assetPathSet.Add(new Asset(destinationPath).assetPath);
  }

  // ディレクトリのmeta情報のために追加する
  assetPathSet.Add(new Asset(resourcePath).assetPath);

  // ディレクトリ配下の新規ファイルを追加する
  var addTask = Provider.Add(assets, true);
  addTask.Wait();

  // Zipファイルに存在しないファイルの削除
  var deleteAssets = new AssetList();
  // addTask.assetListはチェックアウトされているすべてのファイル名を取得できる
  deleteAssets.AddRange(addTask.assetList.Where(asset => !assetPathSet.Contains(asset.assetPath)));
  Provider.Delete(deleteAssets).Wait();

  // これらのファイルをデータ配下のChangelistに移動させる
  var changeSet = GetChangeSet("データ");
  Provider.Submit(changeSet, addTask.assetList, changeSet.description, true).Wait();

  // 未変更のファイルを取り消す
  Provider.Revert(assets, RevertMode.Unchanged).Wait();
}

まとめ

Unity EditorのVersionControlの使い方について紹介しました。 この順番で呼ばないとうまく動かなかったりハマりどころが多かったりしますが、自動化すると便利なことは多いのでぜひトライしてみてください。

Gitを使いたい。

リンク

FlatBuffers導入

概要

FlatBuffersを導入手順についての記事です。

FlatBufferとは

FlatBuffersとはGoogle社が開発しているクロスプラットフォーム対応のシリアライゼーションライブラリです。主にゲームで使われることを目的として開発されています。Protocol Bufferに似ていますが、大きな違いはバイナリデータをパースせずにシリアライズされたデータを使用するため、高速かつメモリを効率的に使うことができます。

Flatbuffersのインストール

はじめにflatbuffersbrewでインストールします。 brewが使えない場合はBuildingのページを参考にインストールすることができます。

brew install flatbuffers

flatcというコマンドが使えるようになれば準備完了です。

FBSファイルの作成

次にFBSファイルを作成します。FBSファイルとはFlatbuffers用のスキーマ定義ファイルです。これによりバイナリファイルの作成および各言語のparserを生成することができます。

Tutorialを参考にFBSファイルを作成します。

// monster.fbs

// Example IDL file for our monster's schema.
namespace MyGame.Sample;
enum Color:byte { Red = 0, Green, Blue = 2 }
union Equipment { Weapon } // Optionally add more tables.
struct Vec3 {
  x:float;
  y:float;
  z:float;
}
table Monster {
  pos:Vec3; // Struct.
  mana:short = 150;
  hp:short = 100;
  name:string;
  friendly:bool = false (deprecated);
  inventory:[ubyte];  // Vector of scalars.
  color:Color = Blue; // Enum.
  weapons:[Weapon];   // Vector of tables.
  equipped:Equipment; // Union.
  path:[Vec3];        // Vector of structs.
}
table Weapon {
  name:string;
  damage:short;
}
root_type Monster;

バイナリファイルの作成

バイナリファイルはJSONまたはJSON5のフォーマットを使用します。今回はJSON5を使用してバイナリファイルを生成します。

// monsterdata.json5

{
  pos: {
    x: 1,
    y: 2,
    z: 3
  },
  hp: 300,
  name: 'Orc'
}

先程生成したFBSファイルを使用してJSONデータをバイナリファイルに変換します。

flatc -b monster.fbs monsterdata.json5

monsterdata.binが生成されればOKです。

JavaScriptで読み込み

今回はJavaScriptファイルをFBSファイルより自動生成し、実際にNode.jsでバイナリファイルを読み込みます。

flatc --js monster.fbs

monster_generated.jsファイルが生成され、これを使用して実際にバイナリファイルを読み込んでみます。

Nodeの実行環境をTutorialを参考に作成し実行し、ログが出れば成功です。

yarn init -y
yarn add flatbuffers
// example.js

const fs = require('fs');
const path = require('path');
const { flatbuffers } = require('flatbuffers');

const { MyGame } = require('./monster_generated');

const filepath = path.resolve(__dirname, 'monsterdata.bin');
const data = new Uint8Array(fs.readFileSync(filepath));

const buf = new flatbuffers.ByteBuffer(data);
const monster = MyGame.Sample.Monster.getRootAsMonster(buf);
console.log(monster.hp()); // 300
console.log(monster.name()); // Orc
node example.js

まとめ

FlabBuffersの導入をしました。まだベンチマークを計測していないですが、C#クライアントではうまく動いてより良い感じです。またdefault valueを使用することでデータ量を減らすこともできるようなので、ぜひ使っていきたいと思います。

Link