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

みなさんこんにちは。カルテットコミュニケーションズでフロントエンド開発をしています、松岡です。

松岡やよい | 社員インタビュー記事

弊社の開発環境では、ステージングサーバー・本番サーバーとデプロイを2段階に分けています。ステージングサーバーは本番に近い設定で、バックエンド・フロントエンドのつなぎこみの結合テストなどを行う場です。ステージングサーバーでの検証が終わると同じ内容が本番サーバーにデプロイされる訳ですが、リリース日の都合などで本番サーバーデプロイ待ちのタイムラグが発生し、その間次のリリースに向けて開発している機能は検証を終えたばかりのステージングサーバーにはデプロイできなくなってしまいます。

ユーザーに全く見えない新規画面の開発であればデプロイに問題はないのですが「既存画面にボタンを追加する」「ルーティングを追加するとサイトマップに勝手に出てきてしまう」というような、画面と深く関わるフロントエンドならではの困りごとが出てきます。

ローカル開発環境・ステージングサーバー・本番サーバーと環境ごとに追加機能の有効無効を切り替えられないか、その方法を考えてみたので紹介したいと思います。

環境

  • @angular/cli v10.1
  • node v14.8

追記

【2020/10/07 記事修正のお知らせ】2020/10/05 に記事を公開しましたが「ステージング/本番サーバーでコンポーネントを隠蔽」について @NgModule でのコンポーネント入れ替えは子コンポーネントまで隠蔽できず NO_ERRORS_SCHEMA などを駆使しないといけない事が分かりました。また fileReplacements を使うともっと簡単に隠蔽できる事が分かったので、この方法を使うように加筆修正させていただきました。

サンプルアプリ

簡単なサンプルアプリを例として説明していきます。

このアプリにはナビゲーションバーに Staging Production というリンクボタンがあります。 クリックすると /staging /production に遷移し <router-outlet>(濃い青のボックス部分) にコンテンツを展開します。

リンクは「デプロイしたくない」先のサーバーの名前を示しています。

  • /staging ステージングサーバーにデプロイしたくない(ローカル開発中)
  • /production 本番サーバーにデプロイしたくない(ステージング検証中)

急いで本番サーバーにリリースしたいホットフィックスが発生したら困ってしまいますね。リンクボタンを CSS で隠しておく手もありますが、コンポーネントやサービスのロジックは実行されてしまいます。まだ不完全な HTTP リクエストを実行してしまったり、開発中のサービスクラス内部でエラーが発生するかもしれません。

このサンプルを下記のように環境ごとに機能を制限するアプリに変更していきたいと思います。

環境 /staging リンク /production リンク
ローカル 利用できる 利用できる
ステージング - 利用できる
本番 - -

environment ファイル

Angular には環境ごとに変数を切り替えるための environment.ts というファイルがあります。これを使って、ローカル開発環境・ステージングサーバー・本番サーバーの3つを判別できるように設定を書き足します。

// src/environments/environment.ts

export const environment = {
  local: true,
  staging: false,
  production: false,
};
// src/environments/environment.staging.ts
// ※このファイルは存在しないので新規作成する

export const environment = {
  local: false,
  staging: true,
  production: false,
};
// src/environments/environment.prod.ts

export const environment = {
  local: false,
  staging: false,
  production: true,
};

angular.json の fileReplacements

Angular の CLI コマンドには ng serve --prod のようにプロダクションビルドを示すオプションがあります。 --prod が渡されると fileReplacements によって environment.ts の中身が environment.prod.ts からコピーされ置き換わるようなイメージです。(ビルド中の処理イメージでファイルシステムへの影響はありません)

公式ドキュメント
Angularアプリのビルドとサーブ > ターゲット固有ファイルの置換の設定

ng serve --staging は存在しません。そこで先ほど用意した environment.staging.ts ファイルの設定を angular.json に書き加える必要があります。

// angular.json

"architect": {
  "build": {
    // architect > build > configurations
    "configurations": {
      "production": {
        "fileReplacements": [
          {
            "replace": "src/environments/environment.ts",
            "with": "src/environments/environment.prod.ts"
          }
          ...
      },
      // ① staging を書き足す
      "staging": {
        "fileReplacements": [
          {
            "replace": "src/environments/environment.ts",
            "with": "src/environments/environment.staging.ts"
          }
        ]
      }
    }
  },
  ...
  // architect > serve
  "serve": {
    "builder": ...,
    "configurations": {
      "production": {
        "browserTarget": "app:build:production"
      },
      // ② staging を書き足す
      "staging": {
        "browserTarget": "app:build:staging"
      }
    }
  },
}

書き足した staging という環境は下記のコマンドで呼び出す事ができます。

# ビルトインサーバーを staging 環境で起動
$ ng serve -c staging

# staging 環境のビルド
$ ng build -c staging

angular.json の設定次第で「本番サーバーではコードを難読化する」「ステージング環境ではソースマップを出力する」などの環境ごとのビルドが調整できます。またコンポーネントから environment ファイルを参照すると、それぞれの環境ごとに違った変数の値を得る事ができます。

ステージング/本番サーバーでコンポーネントを隠蔽

サンプルアプリの Staging リンクボタンは「ステージング環境では見せたくない」機能です。 environment で実行環境が判別できるようになったので、ステージング環境でこのリンクを隠蔽しましょう。

リンクボタンは Anchor を表示するだけの下記のようなコンポーネントとします。

// src/app/links/staging-link.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'app-staging-link',
  template: `<a routerLink="/staging">Staging</a>`,
})
export class StagingLinkComponent {}

このコンポーネントとインターフェースが全く同じである、影武者コンポーネントを作ります。

// src/app/fakes/fake-staging-link.component.ts
// ① ファイルを新規作成する

import { Component } from '@angular/core';

@Component({
  // ② 本物と同じセレクタ
  selector: 'app-staging-link',
  template: ``,
})
export class FakeStagingLinkComponent {
  // ③ 本物に @Input() があれば同じように記述
}

Production リンクボタンも同じように影武者コンポーネントを作ります。

// src/app/fakes/fake-prod-link.component.ts
// ① ファイルを新規作成する

import { Component } from '@angular/core';

@Component({
  // ② 本物と同じセレクタ
  selector: 'app-prod-link',
  template: ``,
})
export class FakeProdLinkComponent {
  // ③ 本物に @Input() があれば同じように記述
}

Angular は selector が同一の複数コンポーネントをひとつのモジュールに登録する事はできません。 仮に StagingLinkComponent(本物)FakeStagingLinkComponent(影武者) を両方とも AppModule に登録すると怒られてしまいます。

そこで fileReplacements を使うと、環境によって本物と影武者を入れ替える事ができるようになります。

// angular.json

"architect": {
  "build": {
    "configurations": {
      // architect > build > configurations > production
      "production": {
        "fileReplacements": [
          ...
          // ① 設定を書き足す
          {
            "replace": "src/app/links/prod-link.component.ts",
            "with": "src/app/fakes/fake-prod-link.component.ts"
          },
          {
            "replace": "src/app/links/staging-link.component.ts",
            "with": "src/app/fakes/fake-staging-link.component.ts"
          }
      },
      // architect > build > configurations > staging
      "staging": {
        "fileReplacements": [
          ...
          // ② 設定を書き足す
          {
            "replace": "src/app/links/staging-link.component.ts",
            "with": "src/app/fakes/fake-staging-link.component.ts"
          }
        ]
      }
    }
  },

これで @NgModule から辿るコンポーネントは環境に応じて影武者に入れ替わるようになります。

// src/app/app.module.ts

import { StagingLinkComponent } from "src/app/links/staging-link.component";
import { ProdLinkComponent } from "src/app/links/prod-link.component";

@NgModule({
  declarations: [
    ...
    StagingLinkComponent,
    ProdLinkComponent,
  ],
  imports: [ ... ],
})
export class AppModule { }

staging 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c staging

ローカル PC ですがビルトインサーバーは「ステージング」環境として起動しています。

  • ステージングサーバーで見せたくない /staging へのリンクは存在しない
  • 本番サーバーで見せたくない /production へのリンクは存在している

次に production 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c production
# ng serve --prod でも同じ

今度はビルトインサーバーは「本番」環境として起動しています。

  • ステージングサーバーで見せたくない /staging へのリンクは存在しない
  • 本番サーバーで見せたくない /production へのリンクは存在しない

開発中の機能がきれいさっぱり隠蔽できましたね!よしよしこれでデプロイのタイミングを気にしなくても大丈夫!

…ではありません。ブラウザのブックマークなどから URL をダイレクトに指定されると /staging/production のページが開いてしまいます。

CanActivate guard

Angular には guard という機能があります。いくつか種類があるのですが、その中の CanActivate を利用するとページ遷移をブロックする事ができます。

公式ドキュメント(未翻訳・英語)
https://angular.io/api/router/CanActivate

実装はとてもシンプルです。ステージングサーバーで見せたくない機能をガードするため、ローカル環境のみ true を返すメソッドを実装しています。

// src/app/guards/staging-guard.ts
// 新規に追加するファイル

import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { environment } from 'src/environments/environment';

@Injectable()
export class StagingGuard implements CanActivate {
  canActivate(): boolean {
    return environment.local;
  }
}

本番サーバー用のガードも同じように実装しておきましょう。

// src/app/guards/prod-guard.ts
// 新規に追加するファイル

import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { environment } from 'src/environments/environment';

@Injectable()
export class ProdGuard implements CanActivate {
  canActivate(): boolean {
    return environment.local || environment.staging;
  }
}

実装したガードをルーティングに設定します。

// src/app/app-routing.module.ts

import { ProdGuard } from 'src/app/guards/prod-guard';
import { StagingGuard } from 'src/app/guards/staging-guard';

const routes: Routes = [
  {
    path: '',
    children: [
      {
        path: '',
        pathMatch: 'full',
        component: HomePageComponent,
      },
      {
        path: 'staging',
        component: StagingPageComponent,
        // ① ガードを追加
        canActivate: [StagingGuard],
      },
      {
        path: 'production',
        component: ProductionPageComponent,
        // ② ガードを追加
        canActivate: [ProdGuard]
      },
    ],
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

staging 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c staging

これで URL をダイレクトに入力しても /staging に遷移せずトップページにリダイレクトするようになりました。

ステージング/本番サーバーでサービスを隠蔽

隠蔽したいのはコンポーネントだけではありません。例えばユーザープロファイルを取得するような HTTP クライアントを利用したサービスがあるとします。

// src/app/services/profile-repository.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import * as Rx from 'rxjs';

@Injectable()
export class ProfileRepository {
  constructor(private http: HttpClient) {}

  get(): Rx.Observable<Profile> {
    return this.http.get<Profile>(`/api/profile`);
  }
}

export interface Profile {
  name: string;
  email: string;
}

ローカル開発環境では angular-in-memory-web-api などを利用してフェイクデータで開発していて、ステージングサーバーや本番サーバーでは RestAPI を呼び出すものとします。そして RestAPI の実装は未完了でレスポンスは常に HTTP 404: Not Found になる、という想定です。

公式ドキュメント
angular-in-memory-web-api サーバーからデータの取得 > データサーバーをシミュレートする

ユーザープロファイルサービスは AppComponent で利用し画面上部にユーザー名を表示します。

// src/app/app.component.ts

import { Profile, ProfileRepository } from 'src/app/services/profile-repository';

@Component({
  selector: 'app-root',
  // プロファイルを表示している
  template: `
    <p>ようこそ {{profile.name}} さん</p>
    ...
  `,
})
export class AppComponent implements OnInit {
  profile: Profile;

  // サービスに依存している
  constructor(
    private repository: ProfileRepository
  ) {}

  ngOnInit(): void {
    // サービスを利用してプロファイルを取得している
    this.repository.get().subscribe(profile =>
      this.profile = profile
    );
  }
}

ここでも影武者を登場させます。

// src/app/fakes/fake-profile-repository.ts
// 新規に追加するファイル

import { Injectable } from '@angular/core';
import { Profile } from 'src/app/services/profile-repository';
import * as Rx from 'rxjs';

@Injectable()
export class FakeProfileRepository {
  get(): Rx.Observable<Profile> {
    return Rx.of({
      name: '影武者',
      email: 'dummy@example.com',
    });
  }
}

一部のエンドポイントだけ実装が完了していないのであれば、処理を部分的に委譲する事もできます。

// src/app/fakes/fake-profile-repository.ts

@Injectable()
export class FakeProfileRepository {
  // ① 本物をインジェクト
  constructor(
    private delegate: ProfileRepository
  ) {}

  // ② 実装完了のエンドポイントは本物に委譲
  delete = () => this.delegate.delete();
  update = (profile: Profile) => this.delegate.update(profile);

  // ③ 未実装のエンドポイントはダミーデータを返す
  get(): Rx.Observable<string> {
    return Rx.of('おいら影武者ッス');
  }
}

サービスは Angular の DI を使って影武者に置き換えます。モジュールの providers で DI コンテナを操作すれば、あちこちのコンポーネントからサービスが利用されていても、その全てが影武者に置き換わります。

// src/app/app.module.ts

@NgModule({
  declarations: [...],
  imports: [...],
  providers: [
    // ① 設定を追加する
    {
      provide: ProfileRepository,
      useFactory: (http) => {
        // ローカル開発環境は本物のサービス
        if (environment.local) {
          return new ProfileRepository(http);
        }

        // ステージング・本番サーバーは影武者サービス
        return new FakeProfileRepository();
      },
      deps: [HttpClient],
    },
  ],
  ...
})
export class AppModule { }

補足 Angular の DI はとても柔軟で、コンポーネント単位での providers 指定も可能です。 その場合は「全てが影武者に置き換わる」が当てはまらないので providers ごとに設定を追加する必要があります。

staging 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c staging

HTTP リクエストを実行しない影武者が登場しましたね。これでステージングサーバーにデプロイしても HTTP 404: Not Found で処理がストップする事はなくなりました。

※ このまま本番サーバーにデプロイすると ようこそ影武者さん が見えてしまうので、先程の「ステージング/本番サーバーでコンポーネントを隠蔽」の手順で隠してください

package.json にスクリプトを追加

$ ng serve -c staging

ステージング環境としてビルトインサーバーを立ち上げるために何度か登場したこのコマンドですが、明日になったら忘れるので package.json に追加しておきましょう。

// package.json
"scripts": {
  // ① ローカル PC でそれぞれの環境をデバッグするためのコマンド
  "start": "ng serve",
  "start:staging": "ng serve -c staging",
  "start:prod": "ng serve -c production",

  // ② それぞれの環境ごとにビルドファイルを出力するためのコマンド
  "build": "ng build",
  "build:staging": "ng build -c staging",
  "build:prod": "ng build -c prod",
  ...
}

ステージングサーバーには build:staging で出力したファイル群を、本番サーバーには build:prod で出力したファイルをデプロイします。

環境判定のバッドパターン

コンポーネントやサービスクラスの中で if (window.env === "staging") したり HTML で *ngIf="env === 'local'" のように記述するのは、できる限り避けたいところです。安易に分岐させたコードは除去する時にコードを探すのも大変ですし、誤って必要なコードを一緒に削除するなど事故を起こしがちです。

@Component({
  selector: 'app-root',
  template: `
    <p *ngIf="env.isLocal">ようこそ {{profile.fullName}} さん</p>
    ...
    <span class="..." [ngClass]="alertClass">
  `,
})
export class AppComponent implements OnInit {
  profile: Profile;
  isLocal: boolean;
  alertClass: string;

  // 【怖い】 依存のどれを削除していいのか
  constructor(
    private repository: ProfileRepository,
    private envProvider: EnvProvider,
  ) {
    this.isLocal = this.envProvider.isLocal;

    // 【怖い】 インターフェースが本物とは変わってる...
    if (this.isLocal) {
      this.profile = { fullName: "偽武者" } as Profile;
    }
  }

  ngOnInit(): void {
    // 【怖い】 alertClass って変数は残したほうがいいのか?
    this.alertClass = this.isLocal ? 'local' : 'prod';

    if (!this.isLocal) {
      return;
    }
    this.repository.get().subscribe(...);
  }
}

実は私はこの方法をよく使っていたのですが、事故が怖くて if 文だけ削除し変数や依存を残したままにした事がありました。そんな過去の反省から「もっとスマートにできないか」と模索したのが今回の記事で紹介した方法です。

environmentproviders など Angular の機能を利用すれば、コンポーネントやサービスは「どの環境で実行されているのか」を知る必要はなく、除去する時の作業も楽になります。

@NgModule({
  declarations: [...],
  imports: [...],
  providers: [
// 【怖くない】 行単位で削除すれば良い
//   {
//     provide: ProfileRepository,
//     useFactory: (http) => {
//       if (environment.local) {
//         return new ProfileRepository(http);
//       }
//
//       return new FakeProfileRepository();
//     },
//     deps: [HttpClient],
//   },
  ],
  ...
})
// angular.json

"architect": {
  "build": {
    "configurations": {
      "production": {
        "fileReplacements": [
//       【怖くない】 行単位で削除すれば良い
//        ...
//        {
//          "replace": "src/app/links/prod-link.component.ts",
//          "with": "src/app/fakes/fake-prod-link.component.ts"
//        },
//        {
//          "replace": "src/app/links/staging-link.component.ts",
//          "with": "src/app/fakes/fake-staging-link.component.ts"
//        }
      },
      ...

なるべく楽に安全に手を入れられるように、実装するその時から将来の作業を考えつつコードを書いていきたいですね。 最後まで読んでいただき、ありがとうございました。

お詫び

以前 Angular 超入門 というシリーズ記事の第4回を書いたのですが、前回の投稿から半年近く経過してしまいその間にシリーズ第1回の投稿者の金本が Angular実践入門チュートリアル という完全版チュートリアルを別のサイトのエントリとして書いてくれていました。(弊社を退職し現在はフリーで活動しています、Twitterアカウントは ttskch(たつきち) さんです)

チュートリアルを途中で断念していた方には大変申し訳ないのですが、たつきちさんの記事を参考にしていただければと思います….。最初の Angular アプリから Firebase のデプロイ方法までサポートしている、とてもおすすめなコンテンツです!

Angular実践入門チュートリアル @ttskh


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

ここ数年の間,瞬く間に多くのプログラミング言語が登場し,話題を呼んでいます. その中でも特に人々の目を引いたものに, Rust があります. この記事では,そんな Rust を少しだけ紹介します. ここで紹介する Rust の機能は ほんの一部 であり,まだまだ優れた機能を数多く持っています!

今までのプログラミング言語との決定的な違い

多くの人々の目を引くほど話題になるからには,なにか決定的な違いがあるはずです. その一つが, 革新的で安全なメモリ管理システム です.

プログラミング言語とプログラマは,長い間メモリ管理に悩まされてきました. 例えば C では, malloc 関数や calloc 関数を用いることで, OS の許す範囲で自由にメモリ空間を確保できます. こうして確保された空間は非常に柔軟ですが, メモリリークやバグの温床 となります. コンパイラはその空間を使う時点でアクセスが有効かどうかをチェックしないからです.

プログラムは,プログラマの記述通りに動作します. 自分で考えることも,動作を変えることもしません. プログラマがミスをすれば,それが確実にプログラムへ反映されます. 先述したようなメモリリークやバグも,もちろんこれに含まれるでしょう.

Rust では,このような問題を防ぐための様々な言語機能が提供されています. それらを使うことで,柔軟な変数や参照を,コンパイラによるチェックを受けたまま安全に保持できるのです.

普段高レイヤを扱う人々にとって,ここまで述べてきたような話題はあまり興味がないかもしれません, 私もその一人でした. しかし, Rust は低レイヤに限られない言語です. すでに, Web をはじめとした様々な実行環境用のフレームワークがリリースされています. Rust によるメモリ管理は高レイヤでも力を発揮します. 結果として, 高機能で速く,安全なプログラム を作り上げられるのです!

環境構築

では,さっそく Rust を書くための環境を整えていきましょう. Rust は,環境の構築も非常に簡単です. まずは, Rust のツールチェイン (コンパイラや標準ライブラリなどのセット) を管理するためのツールである rustup をインストールします.

macOS や Linux などの Unix オペレーティングシステムでは以下のコマンドでインストールできます:

curl https://sh.rustup.rs -sSf | sh -s

Windows では以下を使います:

$uri = "https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe"
$destination = "$env:TEMP\rustup-init.exe"
$client = New-Object System.Net.WebClient
$client.DownloadFile($uri, $destination)
. $destination

あとはインストーラ CLI の指示に従うだけです. 詳細なカスタマイズもできますが,とりあえず今はデフォルトでいいでしょう. インストール先に拘りのある方は,適宜調整してください.

rustc --version コマンドが正常に実行できれば,インストールは成功です!

$ rustc --version
rustc 1.46.0-nightly (8aa18cbdc 2020-07-08)

実際にプログラムを記述するためのワークスペースも作成しておきましょう. 以下のコマンドを実行するだけです:

$ cargo new <ワークスペース名>

現在のディレクトリに指定した名前のフォルダが作成され,そこがワークスペースとなります.

$ cd <ワークスペース名>
$ tree .
.
├── Cargo.toml
└── src
    └── main.rs

最初の一歩: 所有権

一般的な言語チュートリアルでは,ここで Hello, world! を標準出力に書き込むことでしょう. その言語の標準ライブラリに定義された関数を呼べばその文字列が出力されるということは誰でもわかるので,ここでは Rust の特徴的な一面から始めます.

Rust では,メモリ空間が必要でなくなった瞬間に開放されます. いつ開放されるかというタイミングが重要です. 以下のコードを考えてみます:

fn main() {
    {
        let s = String::from("Hello");

        println!("{}", s)
    }

    println!("{}", s)
}

お察しの通り,このコードは動作しません. もっと言うと,コンパイルエラーを起こすため実行すらできません. Rust では, スコープを抜けた瞬間 ,そのスコープで定義された変数により束縛されたメモリ空間が開放されます. ここまでは,他の言語と大差はありません.

次に,以下のコードを見てみましょう:

fn main() {
    let s = String::from("Hello");
    
    foo(s);
    println!("main says: {}", s)
}

fn foo(bar: String) {
    println!("foo says: {}", bar)
}

実際にコンパイルしてみると分かりますが,このコードも,実は動作しません. ここで,他のプログラミング言語との違いが見えてきます. s という変数は, main 関数のスコープで定義されたため,この関数が終了するまでは有効なように思えます. しかし, main 関数最後の println! は動作しません. cargo runcargo check コマンドを実行すると,以下のようなエラーが発生します:

error[E0382]: borrow of moved value: `s`
 --> src\main.rs:5:31
  |
2 |     let s = String::from("Hello");
  |         - move occurs because `s` has type `std::string::String`, which does not implement the `Copy` trait
3 | 
4 |     foo(s);
  |         - value moved here
5 |     println!("main says: {}", s)
  |                               ^ value borrowed here after move

foo 関数を呼び出し, s 変数を渡すと,この変数は main 関数のスコープから foo 関数のスコープへと所有権が移動します. Rust では,一度他のスコープへ所有権を渡した変数は,もう自身の所有権がないのでアクセスできません. また,エラーメッセージにもありますが, Copy トレイトを実装した型 (数値型など) ではこの問題が発生しません. 所有権を渡す前に変数に格納された値をコピーするからです.

では,同じ変数を複数回使いたいときはどのようにするのでしょうか. それを解決するための言語機能が存在します. 借用 (borrowing) です. 借用を行うことで,所有権を完全に渡さずに変数 (に束縛されたメモリ空間) へのアクセスを認可できます.

先ほどの例で,借用を使ってみます:

fn main() {
    let s = String::from("Hello");
    
    foo(&s);
    println!("main says: {}", s)
}

fn foo(bar: &String) {
    println!("foo says: {}", bar)
}

このコードは無事に実行できます.

このように, Rust では,所有権という概念を導入することで,メモリ安全性を確保しています. 所有権を持つ所有者は必ず 1 つに保たれるため,メモリの確保から開放までのプロセスがコンパイル時点で明示化されるのです. そして,スコープから外れたタイミングで破棄されることにより無駄なメモリ空間を残すことなくリソースを使用できます.

もう少し先へ: エラー処理と Result<T, E> 型

ここでは少しメモリ管理から離れます. プログラマを悩ますもう一つの要素に,エラー処理が挙げられます. 言語によっては 例外 という呼称を使います. エラーは,プログラムを記述する段階で意図しない動作を起こしたときに発生するものです. プログラマは,エラーが発生したときに場合に応じて復帰したり,エラーメッセージを出して異常終了したりします.

このようなエラー処理をきちんと行っていない場合,プログラムは突然終了します. Java のような VM 言語でない限り,エラーメッセージも出さないことが多いでしょう. 特に,メモリ関連の深刻なエラーでは SIGSEGV (Segmentation Fault) シグナルとともに終了します. しかし,これではあまりにも不親切ですし,不具合の原因特定にも寄与しません.

プログラマは,できるだけエラーが発生しないよう,また外部要因によって仕方なく発生したエラーを適切に処理する必要があります. Rust では,こういったエラー処理も非常に安全かつ柔軟に行うことができます. その際に用いられるのが, Result<T, E> 型と Option<T> 型です.

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

use std::fs::File;
use std::io::Read;

fn main() {
    let file = File::open("foobar.txt");  // 変数はデフォルトで変更不可
    let mut bytes: Vec<u8> = Vec::new();  // 変更されるものは mut が必要

    file.unwrap().read_to_end(&mut bytes);
}

現在のディレクトリに foobar.txt存在しない ことを確認してから実行してください. プログラムは以下のようなエラーで異常終了します:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "The system cannot find the file specified." }', src\main.rs:8:10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: process didn't exit successfully: `target\debug\RustSandbox.exe` (exit code: 101)

これを パニックする といいます. 元のルーチンに復帰する手段が定義されていないために,終了するしかなくなるのです.

実は, File::open メソッドは File 型ではなく, Result<File, Error> 型を返します. その代わり,多くの言語にある例外という概念は存在しません.

この型は,処理が成功したか失敗したかという情報と,結果またはエラーを持ちます. read_to_end メソッドは結果側の File に存在するので,これを取り出す必要があります. unwrap メソッドを使えば強制的に取り出すことができますが,エラーの場合はそこでパニックします. したがって,上記のコードは動作するものの, Rust ではあまり推奨されません.

以下は親切なエラーメッセージを出力する例です:

use std::error::Error;
use std::fs::File;
use std::io::Read;
use std::process::exit;

fn main() {
    let result = File::open("foobar.txt");
    let mut bytes: Vec<u8> = Vec::new();

    match result {
        Ok(mut file) => {
            println!("Read {} bytes", file.read_to_end(&mut bytes).unwrap());
        },
        Err(error) => {
            println!("Failed to open foobar.txt: {}", error);
            exit(1)
        },
    }
}

実行すると以下のようなエラーが発生して終了します:

Failed to open foobar.txt: The system cannot find the file specified. (os error 2)
error: process didn't exit successfully: `target\debug\RustSandbox.exe` (exit code: 1)

親切なエラーメッセージを出力することができました. ユーザ側から見ても分かりやすく,プログラマ側から見てもデバッグしやすいメッセージが一番良いでしょう.

でも実は,このコードにはまだ危険な箇所が潜んでいます. read_to_end メソッドは,実は正常終了する場合としない場合があります. もしこれがエラーを起こした場合, unwrap メソッドを呼ぶとパニックしてしまいます. ではこれを処理するようにしてみましょう:

use std::error::Error;
use std::fs::File;
use std::io::Read;
use std::process::exit;

fn main() {
    let result = File::open("foobar.txt");
    let mut bytes: Vec<u8> = Vec::new();

    match result {
        Ok(mut file) => {
            let result = file.read_to_end(&mut bytes);

            match result {
                Ok(length) => println!("Read {} bytes", length),
                Err(error) => {
                    println!("Failed to read from foobar.txt: {}", error);
                    exit(1)
                }
            }
        },
        Err(error) => {
            println!("Failed to open foobar.txt: {}", error);
            exit(1)
        },
    }
}

なんと!ネストがとても深くなってしまいました. いくら安全といえど,ネストの深いコードは良いコードではありません. Rust には,こういったことを避けるために, Result<T, E> 型のための便利な実装がたくさんあります.

簡潔にしてみましょう:

use std::fs::File;
use std::io::Read;
use std::process::exit;

fn main() {
    let mut bytes: Vec<u8> = Vec::new();
    let result = File::open("foobar.txt").and_then(|mut f| f.read_to_end(&mut bytes));

    match result {
        Ok(length) => println!("Read {} bytes", length),
        Err(error) => {
            println!("Failed to read from foobar.txt: {}", error);
            exit(1)
        }
    }
}

and_then メソッドは,その Result<T, E> が成功 (Ok) だった場合に与えられた関数を呼び出し,その関数による新しい Result<T, E> を返します. 失敗 (Err) だった場合はそこでその Result<T, E> を返し,与えられた関数は呼び出されません.

プログラム全体で Result<T, E> 型を使うようにしておけば,エラーメッセージを表示する処理は main 関数に書くだけで済みます. エラーの種類ごとにメッセージを変えたければ,プロジェクトの名前空間に独自の Error 型を定義して, Result<T, Error> として使えばよいでしょう.

このように, Rust では,エラー処理でさえも安全に行えるようになっています. もう Segmentation Fault の文字列を見てエラー原因箇所を必死に探すことは (Rust の不具合がない限り) する必要がありません.

深渕へ: ライフタイム

最後に,私が Rust で最も難しいと感じた ライフタイム という概念を紹介します.

序文で述べたように, C などでは mallocstatic といった機能で静的なメモリ空間を確保してきました. しかし, Rust では手動で静的なメモリ空間を自由に確保することができません. すべての変数は所有権を持ち,スコープから抜けた瞬間に開放されます

ライフタイムを使えば,変数が存在するスコープを明示することができます. つまり,より長い時間参照を確保しておけるのです. その時間の長さを指定するのが ライフタイム指定子 です.

以下の例を考えてみましょう:

struct Foo {
    bar: &String,
}

fn main() {
    let s = String::from("Hello");
    let f = Foo { bar: &s };

    println!("{}", f.bar)
}

このコードは動作しません. 以下のようなコンパイルエラーが発生します:

error[E0106]: missing lifetime specifier
 --> src\main.rs:2:10
  |
2 |     bar: &String,
  |          ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 | struct Foo<'a> {
2 |     bar: &'a String,
  |

構造体に参照を保持するには,その構造体が有効な間, 参照先の値がメモリ空間上に存在している必要 があります. 構造体が有効なまま s 変数のメモリ空間が開放されてしまうと, f.bar にアクセスできなくなってしまいます. これではメモリ管理上の安全性を確保できません.

また,ここまでエラーメッセージを見てきてお気づきの方もいるかと思いますが, Rust のコンパイラは非常に丁寧なメッセージを出力します. 上記の例では解決方法も教えてくれるのです.

さて,ライフタイム指定子を書いた例がこちらです:

struct Foo<'a> {
    bar: &'a String,
}

fn main() {
    let s = String::from("Hello");
    let f = Foo { bar: &s };

    println!("{}", f.bar)
}

このコードは正常に動作し,標準出力に Hello を書き込みます. ライフタイム指定子 'a によって, Foo 構造体 (のインスタンス) と bar メンバの参照は同じライフタイムを持ちます. 言い換えると, Foo 構造体が有効な間, bar メンバの参照も有効であることが保証されるようになります.

ライフタイムが正常に機能することを確かめるために,あえて bar メンバの参照,つまり s を無効化してみましょう:

struct Foo<'a> {
    bar: &'a String,
}

fn main() {
    let f: Foo;

    {
        let s = String::from("Hello");
        f = Foo { bar: &s };
    }

    println!("{}", f.bar)
}

このコードは動作しません. 以下のようなコンパイルエラーが発生します:

error[E0597]: `s` does not live long enough
  --> src\main.rs:10:24
   |
10 |         f = Foo { bar: &s };
   |                        ^^ borrowed value does not live long enough
11 |     }
   |     - `s` dropped here while still borrowed
12 | 
13 |     println!("{}", f.bar)
   |                    ----- borrow later used here

スコープを分けることで,中のブロックから抜けたときに s 変数は破棄 (メモリ空間が開放) されます. そして, f に保持した s への参照は有効でなくなります.

このように, Rust では,ライフタイムを導入することによって決まった時間だけメモリ空間を確保した上で,その間は無効な参照が生まれないことを保証できるのです.

まとめ

今回の記事では,あえて簡単な言語事項の説明を省いた上で, Rust の特徴的な機能をいくつか紹介しました.

これを読んで, Rust が難しいと感じた方がいるかもしれません. 確かに,他の言語と違う革新的な要素が多いため,困惑することもあるでしょう. しかし,なぜその機能が存在するのかや,どのように安全性が担保されているかを理解すれば,決して難しい機能たちではないと思います.

速さの代償に難解な言語を書くわけではありません. 安全性の代償に長いエラー処理を書くわけでもありません. Rust は,速くて安全なロジックをいかにプログラマフレンドリな形で記述できるかを目指した言語だと私は考えています.

私自身も, Rust を書き始めてまだ少ししか経っていません. Rust にはまだまだ魅力的な言語機能やベストプラクティスがあると思っています. そして,これからも進化し,使われていく言語だと感じています!


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

ReasonMLは新しい言語ではありません。実は言語でもありません。OCaml の構文です。2016 年に Jordan Walke(React の開発者)に作成されました。OCaml のおかげで TypeScript より丈夫なタイプシステムがあり、JavaScript のような書き方で OCaml を書いたことがない方でもすぐ学べ、NPM・Yarn でもパッケージをインストールできます。ビルド時間が TypeScript より早く、Reason の JS アウトプットは Webpack より読みやすいです。OCaml の上で動いているので、プログラムのスタートアップが素早く、データが不変、ブラウザーの JS だけではなくネーティブコードもターゲットできます。

TypeScript vs ReasonML(英語)

ReasonML は Facebook に応援されています。ReasonML チームがオフィシャル Reason と React のバインドを開発しています。React は最初に作られた時に JS ではなく、スタンダード ML で書かれていました。もし React を書くなら、ReasonML は最高です

では、コードを見ましょう

最初は、簡単な JS を見てみましょうか?

変数

var firstName = "ブランドン";
let middleName = "リー";
const lastName = "ピットマン";

ReasonML だと…

let firstName = "ブランドン"
let middleName = "リー"
let lastName = "ピットマン"

ReasonML ではletしかありません。

記号列

JS と ReasonML の記号列はあまり変わらないので、スキップします。

配列

JS…

const numbers = [1, 2, 3];

ReasonML…

let numbers = [1,2,3] //これは配列ではなく、	連結リストです

// 配列にするなら…
let numbersArray = [|1,2,3|]

// タプルもある
type myName = { name: string }
let otherStuff = ("Brandon", 36, { name: "Brandon" })

配列と連結リストの内容は均質じゃないとだめですが、タプルは不均質でも大丈夫です。

オブジェクト

ReasonML(と OCaml)には関数型プログラミング言語の特徴が多いんですが、OCaml にオブジェクトもあります。けれども、ReasonML のドキュメントを読むと JS の経験者向けではありません。オブジェクトみたいなデータが必要な時に ReasonML のレコードがおすすめです。レコードとは?

type language = Reason | Ocaml | Typescript;

type person = {
	name: string;
	age: number;
	hometown: string;
	favoriteLanguage: language
}

let me = {
	name: "Brandon",
	age: 36,
	hometown: "Cincinnati, OH, USA",
	favoriteLanguage: Reason
}

レコードを定義する前にタイプも定義しなければなりません。TypeScript のタイプの書き方とそんなに変わりませんが、ReasonML のタイプは引数を受けることができます。私の誕生日を定義しましょう。

type threeThings('a) = ('a,'a,'a)
let myBirthdate: threeThings(int) = (1983, 8,31)

/* 上記のように使えますが、あとでそのタイプをもう一回使えます。*/

let fullName: threeThings(string) = ("Brandon", "Lee", "Pittman")

上記の ReasonML こんな JS にアウトプットされます。

// Generated by BUCKLESCRIPT, PLEASE EDIT WITH CARE
"use strict";

var myBirthdate = /* tuple */ [1983, 8, 31];

var fullName = /* tuple */ ["Brandon", "Lee", "Pittman"];

exports.myBirthdate = myBirthdate;
exports.fullName = fullName;
/* No side effect */

最後に出る JS は意外にきれいです。

if 文

JS と ReasonML のif文の書き方はそんなに変わりませんが、使い方が一つの大きい違いがあります。ReasonML のif文では最後の式がリターン値になります。下記の例を見てください。

let number = 1;

/* Unicodeを使うなら、下記の{j|...|j}を使わないといけない */
let result =
  if (number > 0) {
    {j|ゼロより大きい|j};
  } else {
    {j|1より少ない|j};
  };
/* resultは「ゼロより大きい」になる */

あとif文を書くとelseを絶対に書かないといけません。

Null と undefined

ReasonML にはnull又はundefinedというコンセプトはありません。その2つがないので、JS でよく起こるエラーに襲われません。もしnullみたいの値を使わないといけない時にはどうしましょうか?「パターンマッチング」を使います!ReasonML ではswitchをよく使うんですが、JS のswitchより力強いです。こういう風に使えます。

let author = Some("Brandon");

switch (author) {
| Some("Brandon") => Js.log("Hello there, Brandon.")
| Some(string) => Js.log("Welcome, " ++ string ++ "!")
| None => Js.log({j|We have no author. 😞|j})
};

きれいですね?JS のアウトプットはこんなものです。

var author = "Brandon";

if (author !== undefined) {
  if (author === "Brandon") {
    console.log("Hello there, Brandon.");
  } else {
    console.log("Welcome, " + (author + "!"));
  }
} else {
  console.log("We have no author. 😞");
}

exports.author = author;

関数

ReasonML の関数は ES6 の arrow 関数に近いです。functionというキーワードがありません。ES6 の書き方とほぼ一緒に見えます。

type person = {name: string,  age: int};
let me = {name: "Brandon", age: 36};

let greet = ({name, age}) =>
  Js.log({j|Hello, $name. I see you're $age years old.|j});
/* Unicodeのような書き方で内挿もできます。*/

JS のアウトプットは…

function greet(param) {
  console.log(
    "Hello, " + param.name + ". I see you're " + param.age + " years old."
  );
}

var me = {
  name: "Brandon",
  age: 36,
};

インストール

これで今回の簡単な紹介を終わらせますが、ReasonML を使いたいなら、どうすればいいでしょうか?下記の5ステップで初 ReasonML をコンパイルできます。アウトプットされる JS を既にある JavaScript 又は TypeScript のプロジェクトの中でもすぐ実装できます。

npm install -g bs-platform
bsb -init my-new-project -theme basic-reason
cd my-new-project
npm run build
node src/Demo.bs.js

これ以上詳しく調べるなら公式サイトまでお越しください。

ReasonML のオフィシャルサイト

未来の構文

現在はバリバリで TypeScript を書いている方が多いと思いますが、ぜひ ReasonML も調べて欲しいです。Facebook に応援されているので、すぐサポートがなくなる言語ではありません。React の開発者に開発されたので、React のように深く考えらた言語だと思えばいいです。JS の経験があって、関数型プログラミングしたい方におすすめです。今は TypeScript のタイプシステムは不十分だと思う方にもおすすめです。

OCaml の上で動いていますが、ReasonML は JS/TS を使っている皆さんにアピールしたいので、これからも JS に親しむプログラマーたちを歓迎するように向けています。2020年7月には@devブランチで新しい構文も出ています。その新しい構文はどんどん JS に近い書き方になっています。もちろん、互換性も考えられていています。.reのファイル拡張子を使えば公式にサポートされたシンタックスに制限されますが、もし新しい構文を試してみたいなら、.resのファイルが使えます。同じプロジェクトでも両方の構文が使えます。開発チームは今まで書いたコードを大事にしていますが、未来向きに ReasonML の将来を用意しています。

ぜひ使ってみてください!👋🏻