はまったりひらめいたり…とか…

Angularや.NETやAzureやその他色々。

msal.js v2.0.1 をAngularで使用する

はじめに

今回の記事で使用するコードは下記の構成で実装しています。

記事を参照されるタイミングによっては動作しない可能性がありますのでご留意ください。

  • @angular/cli:10.0.5
  • @azure/msal-browser:2.0.1

msal.js v2.0.0がリリース

先月の末頃(2020年7月21日)にmsal.jsのv2.0.0 がリリースされました

(色々試している間にv2.0.1がリリースされちゃいましたが…

PKCEの対応が行われ、よりかんたんにセキュアに使用することができるようになります。

詳細なお話は公式のドキュメントや、今月の.NETラボを見ていただくとして…

ひとまず、僕がメインで使用しているAngularでの使用方法を確認していこうと思います。

@azure/msal-angular

msal.jsには派生ライブラリとして、Angular対応された@azure/msal-angularというライブラリが存在します。

ServiceやInterceptorが提供され、Angularで使用するのに便利なライブラリとなっています。

f:id:TakasDev:20200809223243p:plain

👆microsoft-authentication-library-for-jsより抜粋

上図の通り、@azure/msal-angularもv2.0がPKCE対応されるはずなのですが、まだリリースされていないようです。

Roadmapにも特に予定は明記されていないようですが

何はともあれ使用したいので、プレーンな@azure/msal-browserをAngularで使用してみましょう。

@azure/msal-browserをAngularで使用する。

基本的な使い方は@azure/msal-browserのドキュメントの通りで問題ありません。

ライブラリのインポート

npm install @azure/msal-browser

msalアプリケーションインスタンスの生成

msalのv1からmsalの処理のエントリークラスがUserAgentApplicationからPublicClientApplicationに変更されています。

インスタンスの生成にADアプリなどの設定のConfigurationを与えるあたりの使い方は変わらないようです。

Configurationの構成自体も大きく変わっていないようで、clientIdauthorityなどしか項目を使っていない場合は設定記述部分は変更する必要はありません。

参考 - Configuration

handleRedirectCallback

loginRedirectなどを行った場合に引っ掛けるhandleRedirectCallbackは大きく変更されています。

Promiseで実行されるようになっておりそれに合わせた変更が必要です。

const res = await this.clientApp.handleRedirectPromise().catch(err => {
    console.log({err});
});

自分は検証でエラーをトラップしたかったので雑に👆の実装にしていますが、resがvoid | AuthenticationResultとなるのでthenを使うのもありです。

handleRedirectPromiseの内部処理実行前にloginRedirect処理などが走るとエラーが発生するので

handleRedirectPromiseの処理終了を待機して、処理終了後にログイン処理を行うのがベターです。

login

loginを行う処理のインターフェースは変わっていません。loginRedirectloginPopupでログインを実行します。

Redirectは前述するCallbackで、PopupはPromiseで結果を取得します。

ユーザー情報の取得

msal.jsのv1ではユーザー情報を取得するインタフェースがClientApp上に用意されていましたが、v2でなくなりました。

msal.jsのv2では、ログインやトークン取得時の結果としてアカウント情報が含まれた状態で返却されるので

そこからデータを取得することになります。

リダイレクトの場合は下記のようにhandleRedirectPromiseで取得できたレスポンスから抽出することになります。

const res = await this.clientApp.handleRedirectPromise().catch(err => {
    console.log({err});
});
this.account = (res as AuthenticationResult).account;

PopupはloginPopupの返却値です。

アクセストークンの取得

acquireTokenSilentで必要な引数にアカウント情報のパラメータが追加されています。

const res = await this.clientApp.acquireTokenSilent({ scopes: this.scopes, account: this.account });
return res.accessToken;

ログイン時などにアカウント情報は取得し、プライベートな変数なりに格納しておいたほうが良さそうです。

ここまでの実装はAngular特有というものではなく、普通の@azure/msal-browserの使用の仕方ですね。

本家のmsalServiceよろしく、一つのServiceクラスに👆までの処理を実装しておくと使いやすいと思います。

Interceptorの作成

Angularのサービスらしく。ということでInterceptorを作成し、WebAPIへの通信時に自動的にヘッダにトークンを付与させます。

ここは@azure/msal-angularのInterceptorをちょっとだけ参考にします。

[
  { endPoint: string, scopes: string[] }
]

上記の構造の設定を使用して、アクセスするエンドポイントによって

使用するスコープを切り替える機能を実装していきます。

(👇本家と比べるとかなり雑ですが…!

@Injectable()
export class MsalInterceptor implements HttpInterceptor {

    constructor(
        private auth: MsalService
    ) {}

    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const target =  environment.endPointTargetScope.filter(f => req.url.includes(f.endPoint));
        let scopes = [];
        if (target.length > 0) {
             scopes = target[0].scopes;
        }
        if (!scopes || scopes === []) {
            return next.handle(req);
        }
        return from(
            this.auth.acquireTokenSilent(scopes)
                .then((token: string) => {
                    const authHeader = `Bearer ${token}`;
                    return req.clone({
                        setHeaders: {
                            Authorization: authHeader,
                        }
                    });
                })
        )
        .pipe(
            mergeMap(nextReq => next.handle(nextReq))
        );

    }
}

ひとまずこれで、ログイン👉アクセストークン取得👉WebAPI通信の必要最低限な動きは確認できるようになります。

まとめ

Angularでのmsal.js v2.0.xの使用の仕方を見てきました。

@azure/msal-angularのv2がリリースされる可能性はありますが

@azure/msal-browser準拠のインターフェースや使い方になりそうな気はしているので

これを使用して、変わりそうな箇所を抑えておくのも良いかもしれません。

今回検証で使用したソースコードは下記にのごった煮レポジトリ格納しています。

github.com

AngularのRouterでuseHashをtrueにしているときにmsalで`Error: Cannot match any routes. URL Segment: 'access_token'`が出るときの対処

はじめに

2020年7月時点の記事となります。

msal.jsのv2が出たのですが、書きかけだった記事の供養となります。。ご留意ください。

Angular・msal.js・@azure/msal-angularは下記のバージョンとなっています。

  • Angular: 10.0.1
  • msal.js: 1.3.2
  • @azure/msal-angular: 1.0.0

検証を行うバージョンによっては動作が異なることがありますのでご留意ください。

何が起きているか

タイトルのとおりですが、AngularのRouterModuleを設定するときにuseHashtrueにすると

Angularのルーティングはhttp://localhost:4200/#/<path>のような#付きのパスとなります。

AzureADでimplicit flowでトークンが返却される場合、リダイレクトURLに#access_token=~~のようにトークン情報が付与されて返却されているようです。

参考-Implicit grant (暗黙的な付与)

  1. fragmentでトークンが送られる
  2. Angularのルーティングで#付きのパス=遷移と誤認し遷移を実行
  3. 実際のパスは当然存在しないのでエラー

この流れが原因のようです。

queryで返却されるようになれば良かろうかとも思うのですが

msal.jsのConfigをさらっと眺めた感じだと、responce_modeをいじる設定はないように見えるので別の方法を模索してみます。

参考-msal・Configuration

どのように解消するか

routerのeventをsubscribeするのも良いですがちょっと手間なのでシンプルな解決方法が望ましいですね。

解決方法1:BootstrapModuleの変更

window.parent かどうか判定し、IFrame内部であればRouterを持たないダミーのモジュールを流し込めば良いはずです。

MSAL.js を使用してトークンを自動的に取得、更新するときにページのリロードを回避する」が参考になるかと思います。

別の問題の解消方法ですが、これに似た方法で対応が可能となります。

ただ、上記のサイトに上げた方法だと、useHash=trueにしたRouterModuleをImportすることになるので、問題となっていた現象を解決できません。

そこで、window.parentによる分岐をAppModule下ではなく

もう少し上のレイヤーで実行し、BootstrapするModuleをRouterを持たないダミーのModuleに変更する。といった対応を行います。

処理の分岐を下記のようにmain.tsで実行します。

if (window !== window.parent && !window.opener) {
  // IFrame内で実行されるダミーモジュール
  platformBrowserDynamic().bootstrapModule(RedirectDummyModule)
    .catch(err => console.error(err));
} else {
  // 通常のモジュール
  platformBrowserDynamic().bootstrapModule(AppModule)
    .catch(err => console.error(err));
}

解決方法2:リダイレクト先の変更

そもそもAngularを使用していないプレーンなダミーサイトにリダイレクト先を変更しちゃおうという手です。

ADAL Wiki - FAQ - Q1. My app is re-loading every time adal renews a token.」が参考になります。

これはadalの記事ですがmsal、@azure/msal-angularでも同様の実装が可能となります。

リダイレクト先のHTMLとして、下記のようなHTMLを用意しておきます。

リダイレクト時点でmsalのUserAgentApplicationインスタンスが生成されていないとエラーとなるので

msal.jsの読み込みと、UserAgentApplicationのインスタンス生成を行う必要があります。

<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <title>Frame Redirect</title>
    <script type="text/javascript" src="https://alcdn.msftauth.net/lib/1.3.2/js/msal.min.js" integrity="sha384-4cH4i4aLXsdIJJz5DF27S+GxZXRSqBORS4NN7kc7NyZBBIugxFyt6hQt5FiR/DuZ" crossorigin="anonymous"></script>
</head>
<body>
    dummy
    <script>
        var msalConfig = {
            clientId: "<Azure AD App Client ID>"
        };
        var authContext = new Msal.UserAgentApplication(msalConfig);
        authContext.handleWindowCallback((err, res) => {
            console.log({ err, res });
        });

        if (window === window.parent) { window.location.replace(location.origin + location.hash); }
    </script>
</body>
</html>

仮に、このHTMLがredirect-page.htmlとすると

アクセストークン取得時などにリダイレクトURLとしてこのURLを指定します。

    // 👇msal-angularのMsalService.acquireTokenSilent()
    // 👇msal.jsのUserAgentApplication.acquireTokenSilent()
    const res = await this.msalService.acquireTokenSilent({
      scopes: [ 'スコープ'],
      redirectUri: 'http://localhost:4200/redirect-page.html' // 👈こんな感じ
    });

デプロイする時も、今回作成したredirect-page.htmlが必要になるのででangular.jsonでデプロイ対象に指定しておきましょう。

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
   ....
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            ...
            "assets": [
              ...
              "src/redirect-page.html" 👈追加
            ],
            ...

まとめ

そもそも別問題の対応のために、解決方法1や2を行っていたので今更見つけた感じになります。

無限リダイレクト問題とかにぶつかって、解決方法1,2とかに行き当たる方は多いと思うので知らん間に対応している。といったことが多そうです。

useHash:trueで且つ、そこらの対応がされていないとかのすこしニッチな状況のものになりますね。

msalのv2でこの記事が無意味なものに変わってくれるといいなーと若干期待しています。

今回検証で使用したリポジトリは下記となります。

解決方法1

解決方法2

Microsoft Graphのpresenceについて

修正

2020/7/19 : presenceのβ公開時期を2020年6月から2019年12月に修正しました。

(@karamem0さんご指摘ありがとうございました!)

2020/8/2:presenceのSubscriptionのドキュメントが公開されたので反映しました。

はじめに

2020年7月時点のMicrosoft Graphを使用した記事となります。

また記事で扱うpresenceは2020年7月段階でβ状態なので

参照されるタイミングによっては仕様が変更されている可能性がありますのでご留意ください。

presence

2019年12月に取得WebAPIがβ公開され2020年7月に変更通知がサポートされました

presenceとは具体的になんなのでしょうか。

めちゃくちゃかんたんに説明すると👇コレです。

f:id:TakasDev:20200719131308p:plain

presenceの取得

Graphの変更履歴の7月からのリンクは404なのですがドキュメントは確認できます。

presenceのデータを取得するのは下記のWebAPIで取得します。

※今はβしか公開されていないのでhttps://graph.microsoft.com/beta/エンドポイントからしかアクセスできません。

GET /me/presence
GET /users/{id}/presence
GET /communications/presences

GET /communications/presencesと記載されていますが素直に使用しても複数人取得できず404になりました。

ユーザーid指定が必須のようなので、/communications/presences/{id}が正しいと思います。

複数人のpresenceの取得はgetPresencesByUserIdです。

POSTで取得したいユーザーのidをBodyに指定するので、ちょっと違和感がありますね。

POST communications/getPresencesByUserId
{
    "ids": ["<UserId>", "<UserId>"]
}

presenceで取得するプロパティについて

プレゼンスのリソースの種類

取得するプロパティは少なく、idactivityavailabilityの3種だけです。

f:id:TakasDev:20200719134234p:plain

👆のドキュメントはめったくそややこしいですが、動作させて確認したところこれで正しいようです。

(user's availabilityがavailibityじゃないんだ…的な)

Presenceの状態については、Teams でのユーザーのプレゼンスで確認できます。

availabilityとactivityの対応は下図の通りの認識です。

f:id:TakasDev:20200719134843p:plain

presenceの変更通知

2020年7月に変更通知に対応されました。

CreateSubscriptionにはリソースの明記はありませんが、OverViewによると/communications/presencesのようです。

presencesの取得から考えると単一ユーザー指定の/communications/presences/{id}の指定になるようです。

IDなしやカンマ区切りの複数指定は400になりました。

また、Subscriptionの作成では有効期限を指定する必要があります。

ドキュメントに記載の通り、1時間のようです。

サブスクリプションの作成は他のリソースのときと同様、下記のようなJSONをPOSTすることで作成できます。

{
      "changeType": "updated",
      "notificationUrl":  "<エンドポイントURL>",
      "resource": "communications/presences/<user id>",
      "expirationDateTime":  "<有効期限>"
}

複数指定する場合は$filterを使用して下記のように指定するようです。

参考-Doc-RequestExample

"resource": "communications/presences$filter=id in ('<userid>', '<userid>'...)",

他のリソースのSubscriptionを作成する時とそこまで大きく変わるところはありませんね。

変更通知で通知される内容

Subscriptionを作成して内容を確認した結果、下記のようなデータが取得できました。

{
    "value": [
        {
            "subscriptionId": "cc87f8f1-45aa-4b33-9dd3-660bbaf3ed57",
            "clientState": null,
            "changeType": "updated",
            "resource": "communications/presences('<userid>')",
            "subscriptionExpirationDateTime": "2020-07-18T23:03:50.4305891-07:00",
            "resourceData": {
                "@odata.type": "#Microsoft.Graph.presence",
                "@odata.id": "communications/presences('<userid>')",
                "id": null,
                "activity": "InACall",
                "availability": "Busy"
            },
            "tenantId": "<tenantid>"
        }
    ]
}

activityavailabilityも取得できているので、TeamsやEventのように別途WebAPIでリソースデータの取得などをする必要はなさそうです。

おわりに

β公開中のpresenceの取得と変更通知の使い方を見ていきました。

第 1 回 Japan M365 Dev User Group 勉強会ではこれをネタにLTする予定です。

よろしくおねがいします。

Angular Elementsで作成したWeb Componentsの設定情報をプレーンなJSから変更する

はじめに

この記事内のアプリケーションは下記バージョンで構成しています。

  • Angular CLI: 10.0.1
  • Angular: 10.0.2

この記事を参照されるタイミングによっては

サンプルコードが動作しなかったりする可能性がありますのでご留意くださいませ。

そもそもなにをしたいのか

Microsoft GraphはGraph ToolkitというWeb Componentsを公開していまして

オリジナルでそれを作ってみたいなー。というのが発端です。

今回の記事はその前哨戦。

Web Componentsを作ってみてハマった箇所の備忘録。といった感じですね。

Angular Elements

Angularを使用している場合は、Angularで作成したComponentをWebComponentsでパッケージングしてくれる

Angular Elementsというものが存在します。Angularで諸々なれている自分はこれを使うのが一番手っ取り早そうです。

基本的な使い方などは、👆のドキュメントを参考にすれば良いと思うので割愛します。

最終的に下記のようにAngularを使用していないようなプレーンなHTMLで

作成したWebComponentsのセレクタを指定してAngularで作成したComponentを表示させることが出来ます

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="stylesheet" href="../../dist/custom-element/styles.css">
    <script src="../../dist/custom-element/custom-element.js"></script>
</head>
<body>

    <h1>Web Component (Costum Elements)</h1>
    <scl-custom-button></scl-custom-button>
    <scl-sample-form-group></scl-sample-form-group>
    <scl-router-page></scl-router-page>
</body>
</html>

👇

f:id:TakasDev:20200705111959p:plain

情報を設定する🤔

さて、Micorosoft Graphにアクセスするということは、ADのアプリケーション情報などが必要になります。

設定情報をWeb Components作成時点でビルドインしても良いのですが、それでは使い回しがしにくいです。

具体的には下図のような構成にしたいところですね。

f:id:TakasDev:20200705113551p:plain

ビルドされたソースはどうなっている?

ビルドされたパッケージ内のServiceクラスに値を直接外部から突っ込めるでしょうか?

下記のTSがビルドされたソースを整形して見てみます。

export class LibSettingsComponent implements OnInit {
...
 constructor(
    private service: LibStateService
  ) { }
...
  set setting(setting: { state: string, userId: string, userName: string }) {
    console.log('emit');
    this.service.setting(setting);
    console.log(this.service.state);
    this.dummyStSet = 'emit';
  }
}
        Oy), By = ((Iy = function() {
            function e(t) {
                _classCallCheck(this, e),
                this.service = t,
                this.dummySt = "dd"
            }
            return _createClass(e, [{
                key: "ngOnInit",
                value: function() {}
            }, {
            ...
            }, {
                key: "setting",
                set: function(e) {
                    console.log("emit"),
                    this.service.setting(e),
                    console.log(this.service.state),
                    this.dummyStSet = "emit"
                }
            }]),

this.serviceはfunctionの引数のtで更にそこの呼び出し元の…?

いずれにせよServiceのインスタンスに直接アクションをするのは多大な労力を伴いそうです。

設定用のComponentを用意する

Componentからのアクセスは簡易に行えます。なので設定用のComponentを作成する。というアプローチをとってみます。

中身が空の設定用のComponentを用意します。

設定用のComponentは@InputでSetterのみを提供し、Setterの実装内で設定情報Serviceを書き換えます。

具体的なソースコードは下記のとおりです。

色々視認やデバッグしやすいようにHTMLテンプレートにものを突っ込んでますが空で問題ないと思います。

import { Component, OnInit, Input } from '@angular/core';
import { LibStateService } from '../../services/lib-state.service';

@Component({
  selector: 'scl-lib-settings',
  template: '<div>{{dummySt}}</div>'
})
export class LibSettingsComponent {

  dummySt = 'bf';

  constructor(
    private service: LibStateService
  ) { }

  @Input()
  set setting(setting: { state: string, userId: string, userName: string }) {
    // ここで設定情報をServiceに反映する
    this.service.setting(setting);
    this.dummySt = 'emit';
  }
}

これでプレーンなHTMLのタグ上などで、settingプロパティに値を突っ込むことで設定情報が反映されるようになりました。

設定用のコンポーネントタグを書かなくて良いようにする

さて、設定を外から与えることができるようになりましたが

何も情報を出力しない設定用のコンポーネントをHTML上に書くのはなんともダサ味があります。

なので、スクリプトでも設定情報の反映ができるようにしたいところです。

そこで、

  1. 設定用のコンポーネントタグをcreateElementして
  2. 設定を行い
  3. 設定用のコンポーネントタグを削除する

こんな感じの設定スクリプトをAngular Elementsから提供することにします。

設定スクリプトは下記のようなものです。

class LibSettings {

    setSettings(data) {
        const settingDom = document.createElement('scl-lib-settings');
        document.body.appendChild(settingDom);
        settingDom.dummySt = 'hoge';
        settingDom.setting = data;
        document.body.removeChild(settingDom);
    }
}

作成した設定スクリプトをWebComponentsをビルドする際に一緒に吐き出したいので、angular.jsonをいじります。

    "custom-element": { // Angular Elementsのプロジェクト名
      ...
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            ...
            "scripts": [
               // 👇を追加
              "projects/custom-element/src/static-scripts/lib-settings.js"
            ]

これで、WebComponentsをパッケージングしたときに、同一ディレクトリに設定スクリプトも吐かれるようになりました。

f:id:TakasDev:20200705121442p:plain

使ってみる

設定スクリプトで設定が反映されたサービスのプロパティをアラート表示するボタンComponentを作成しました。

このボタンを押下することで設定情報の反映の確認を行ってみます。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="stylesheet" href="../../dist/custom-element/styles.css">
    <script src="../../dist/custom-element/custom-element.js"></script>
</head>
<body>

    <h1>Web Component (Costum Elements)</h1>
    <scl-custom-button></scl-custom-button>
    <scl-sample-form-group></scl-sample-form-group>
    <scl-router-page></scl-router-page>
    <script src="../../dist/custom-element/scripts.js"></script>
    <script type="text/javascript">
        // 設定スクリプトを使用して設定情報の反映
        const cl = new LibSettings();
        const setting = { state: 'HTMLから設定したよ!', userId: 'user', userName: 'hogehoge' }
        cl.setSettings(setting);
        //
    </script>
</body>
</html>

上記の状態で、アラートに「HTMLから設定したよ!」と表示されればOKです。

f:id:TakasDev:20200705122727g:plain

設定できました。

これで設定情報を外部から与えることができそうです。

おわりに

長い前哨戦が終わったので、次回はようやく目的だったオリジナルのGraph Toolkitの作成に入ってみます。

手を付けれていないのでいつになるかわかりませんが

今回検証を行ったソースコードは下記となります。

github.com

色々検証で試行錯誤したので少々とっちらかっているのはご容赦くださいませ…

Microsoft MVPを初受賞しました

タイトルの通りMicrosoft MVPを初受賞しました。

カテゴリはOffice Developmentとなります。

登壇の機会を多くくださった.NETラボの皆様

セッションを聞いてくださった皆様

フィードバックをくださった皆様

個人の活動であるにも関わらず会社でぼくの活動をサポートしてくださった皆様

僕の活動にあらゆる形で関わってくださった皆様にお礼申し上げます。

本当にありがとうございました。

これからも楽しんで技術を追いかけ続けようと思います。

ただ看板を頂いた以上はそれに恥じない動きを心がけたいと思います。

感想

初受賞ということもあり、Twitterで感想おさまらんなー。というわけで感想をポストしています。

Officeの名を冠するカテゴリで受賞させていただいたのですが

僕のブログは「Office」で検索しても1件も引っ掛からないレベルでOfficeに触れていません。

f:id:TakasDev:20200703202650p:plain

(1件くらいあるやろと思ってたので「マジか」と自分でも思いました。。。)

  • よく言えばMicrosoft Graph特化で
  • OfficeというよりOfficeのデータに熱中していて
  • Webや.NETといったフロント方面から執拗にOfficeのデータにアプローチを行っている

そんな様が審査時に「変な人」的に印象にのこったかもしれないなーとか思ったりしています。

今後の話

受賞したからには継続して受賞していきたいと思いますし

Azureが好きで、Angularが好きで、.NET Coreが好きで、Microsoft Graphが好き。といった状況もまだまだ続くと思います。

そんな自分なので、Microsoft Graphはもちろんですが、Microsoft Graph以外も含め、多くの情報発信ができれば良いな。と考えていますので

今後ともよろしくおねがいいたします🙇

蛇足

受賞メールが届いた日は興奮したのか一睡もできず、かなり久々に完徹して出勤する事になりました。

結果、お昼すぎに睡魔に勝てず途中リタイア。

自分の老いにも確かな手応えを感じてしまったので、体調にも気をつけつつ頑張ろうと思います。。

AzureDevOpsのCIで.NET CoreとAngularのアプリケーションをバージョンアップする

はじめに

この記事内で使用する各フレームは下記バージョンで構成しています。

  • Angular: 9.1.8
  • .NET Core: 3.1

また、AzureDevOpsは記事作成時点のキャプチャやコマンドが掲載されています。

この記事を参照されるタイミングによっては

動作しなかったり画面が違うといった可能性がありますのでご留意くださいませ。

モチベーション

アプリケーションのバージョンアップ作業って地味で忘れがちです。

そういった作業は自動で行ってしまいたい!!

と、いうわけでAzure DevOpsのCIフローの中でバージョンアップ作業ができないか試してみました。

僕が主に使用しているのは.NET CoreとAngularのアプリケーションですので

その二つのアプリケーションでバージョンアップを実施するCIを組んでみようと思います。

.NETアプリケーション

.NETのアプリケーションのバージョンアップをCIで実現しようと思います。

.NETアプリケーションのバージョン番号はメジャー.マイナー.ビルド番号.リビジョンで表現されますが

ビルド番号、あるいはリビジョンに*指定で、ビルド時に自動的にバージョンアップをしてくれます。

なので普通に運用する場合は何も考えなくてもバージョンアップはしてくれるのですが

バージョン付番を独自のルールで運用したり、付番したバージョンをデータストアに格納したりとかしたい場合があります。

なので、今回は独自ルールでバージョンを付番してアプリケーションを発行するCIを構築してみようと思います。

バージョンアップルール

今回は下記のルールでバージョンアップしようと思います

  • 2020年6月2日からの経過日数をバージョン番号とする
  • ビルドを行った日の00:00からの経過分数をリビジョンとする

.NETアプリケーションのビルドコマンドでバージョン番号を指定できるよう準備

MsBuld.exeを使用してビルドする際のコマンドでカスタムプロパティを指定できます。

参考:MSBuild Properties1

そこで、ビルドコマンドのArgumentsでバージョンを変更できるようにします。

<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <UseWPF>true</UseWPF>
    <PublishSingleFile>true</PublishSingleFile>
    <RuntimeIdentifier>win-x86</RuntimeIdentifier>
    <!-- バージョン番号のベースの経過日数を受け取る -->
    <BuildNumber Condition=" '$(BuildNumber)' == '' ">0</BuildNumber>
    <!-- リビジョンのベースの経過分数を受け取る -->
    <Revision Condition=" '$(Revision)' == '' ">0</Revision>
    <!-- バージョンの指定-->
    <AssemblyVersion>0.0.$(BuildNumber).$(Revision)</AssemblyVersion>
    <VersionPrefix>0.0.$(BuildNumber).$(Revision)</VersionPrefix>
  </PropertyGroup>

</Project>

これで、MsBuild.exe <プロジェクト> /p:BuildNumber=<適当な数字> /p:Revision=<適当な数字>でバージョンをコマンド上で指定できるようになりました。

MsBuild.exe <プロジェクト> /p:BuildNumber=1 /p:Revision=2した結果は下図のとおりです

f:id:TakasDev:20200613115738p:plain

CIの設定

CIで使用する変数の設定

CIの複数のタスクでバージョン情報を使い回すので、その情報を格納する変数を指定します。

経過日数を格納する$daysと経過分数を格納する$dayintervalを用意しています。

variables:
  days: 0
  dayinterval: 0

Power Shell

まずはPowerShellでコマンドで指定するバージョン番号とリビジョンを生成しています。

参考:PowerShellタスク

現在日付をゴニョゴニョして、$days$dayinterval 変数に格納します。

  • 2020年6月2日からの経過日数をバージョン番号とする
  • ビルドを行った日の00:00からの経過分数をリビジョンとする

ってことをやっています。

- task: PowerShell@2
  displayName: 'Calc Build Version'
  inputs:
      targetType: inline
      script: |
        $baseDate = [datetime]"06/02/2020"
        $currentDate = $(Get-Date)
        $interval = NEW-TIMESPAN -Start $baseDate -End $currentDate
        $days = $interval.Days
        Write-Host "##vso[task.setvariable variable=days]$days"
        echo $days
        $today = Get-Date -f d
        $startdate = Get-Date $today
        $todayinterval = NEW-TIMESPAN -Start $startdate -End $currentDate
        $dayinterval = [Math]::Truncate($todayinterval.TotalMinutes)
        Write-Host "##vso[task.setvariable variable=dayinterval]$dayinterval"
        echo $dayinterval

Write-Host "##vso[task.setvariable variable=<変数名>]<PowerShell内の変数(突っ込みたいValue)>"で👆で作成した変数に突っ込めます。

このPowershellでは、👆で指定したdaysdayintervalに値を突っ込んでいますね。

バージョンの情報を管理しているサービスなんかがある場合は

WebAPIを作成して、Invoke-WebRequestコマンドを使用して引っ張ってきたり登録したりすればいいかなと思います。

MsBuild

MsBuildで👆で指定したバージョンを指定してビルドを行うコマンドを作成していきます。

参考:MsBuildタスク

- task: MSBuild@1
  inputs:
    solution: '**/<プロジェクト名>/**/*.csproj'
    configuration: Release
    msbuildArguments: /t:Publish /p:BuildNumber=$(days) /p:Revision=$(dayinterval)

これはそこまで複雑なことはしていないですね。

PowerShellで設定した変数をMsBuildで使用するカスタムプロパティに指定しています。

実行結果

PowerShellタスクで指定するバージョン情報を出力しています。

f:id:TakasDev:20200613134513p:plain

Publishされたファイルのバージョンが、Powershellで生成した情報で構成されていることが確認できました。

Angularアプリケーション

普通にWebアプリケーションを作成するだけであれば特に意識する必要はないと思うのですが

ライブラリを作成してPublicなりPrivateなnpmレジストリにPublishしたい場合、バージョンアップは必要な作業です。

基本はnpm versionコマンドでバージョンアップしていけば良いかなと思っています。

バージョンアップルール

今回は下記のルールでバージョンアップしようと思います

  • masterブランチのCIはnpm version patchでパッチバージョンを上げる
  • developブランチのCIのときはプレリリースでdevバージョンとしてリリースする

CIの設定

ちょっとCIの順番と連動していないのですが、行う作業の関連など考慮して順不同で説明しています。

Power Shell

npmコマンドをPowerShellで実行します。

このPowershellコマンドでは下記のことを実行します

  • npm versionの実行
    • ライブラリのpackage.jsonのバージョンを上げたいのでライブラリのディレクトリで行う
  • バージョン変更をリモートリポジトリにPushする
    • そうしないと次回以降も同一のバージョンが生成されてしまうためですね

参考: MS Doc - Run Git commands in a script

参考: MS Doc - Build Azure Repos Git or TFS Git repositories

参考: Stack Overflow - How to increase a version of an npm package using Azure Devops pipeline

先にyamlを掲載すると👇な感じになります。

- task: PowerShell@2
  displayName: 'Version Up & Commit'
  inputs:
    targetType: inline
    script: |
      git config user.name "learn-angular-library-ci Build Service (devtakas-public)"
      git config user.email "dummy@example.com"
      $BranchName = "$(Build.SourceBranch)" -replace "refs/heads/"
      git checkout $BranchName --quiet
      cd projects\sample-lib
      npm version $env:VERSIONCOMMAND --preid=dev -m "$env:VERSIONCOMMENT" --force --silent
      git add .
      git commit -m "$env:VERSIONCOMMENT"
      git push --quiet

Script内1~2行目

git config user.name "learn-angular-library-ci Build Service (devtakas-public)"
git config user.email "dummy@example.com"

gitにコミットするためにユーザーの情報などを設定しています。

ここは好きな値で問題ないです。

Script内3~4行目

$BranchName = "$(Build.SourceBranch)" -replace "refs/heads/"
git checkout $BranchName --quiet

Azure DevOpsのCI実行ログを見ればわかるのですが

ソースコードの操作が行われているブランチは厳密にはCIコマンドで指定したブランチと異なります。

f:id:TakasDev:20200614140458p:plain

バージョン変更を行ったコミットをPushしたいブランチはCIで指定するブランチなので

環境変数に格納されている作業ブランチをベースに作業ブランチのチェックアウトを行う必要があります。

Script内最後の行まで

cd projects\sample-lib
npm version $env:VERSIONCOMMAND --preid=dev -m "$env:VERSIONCOMMENT" --force --silent
git add .
git commit -m "$env:VERSIONCOMMENT"
git push --quiet

作業ディレクトリをライブラリのpackage.jsonがあるところまで移動して諸々作業します。

$env:VERSIONCOMMAND はAzure DevOpsのCI上で設定する変数です。

f:id:TakasDev:20200614141100p:plain

masterのCIではpatch、developのCIではprereleaseを指定しています。

AzureDevOpsのCIでは、rootディレクトリ以外ではnpm versionしてもCommitが発生しなかったので

別途 git add .git commitしています。

警告などでるとAzure DevOpsのCIでエラーを吐いてしまうので

適宜--quiet--silentでエラーにならないように回避しています。

参考: Stack Overflow - How to generate NPM release candidate version

参考: はらへり日記 - npm scriptsでエラーログを表示させたくない話

認証の設定

参考: MS Doc - Run Git commands in a script

AzureDevOpsで管理されているリポジトリに対してPushを行いたいので

ソースを取得する段階で認証情報を設定しちゃいます。

下記で設定可能です。

steps:
- checkout: self
  persistCredentials: true

Trigger

例えばdevelopブランチの変更があった場合、developブランチにCommit/Pushが行われます。

つまり、なにも考慮しないと無限ループになります(なった)。

自分は、単純に下記の通り自動変更される対象のファイルをexclude設定にしました。

trigger:
  branches:
    include:
    - develop
  paths:
    exclude:
    - projects/sample-lib/package.json

ここは人により設定が変わりそうなところですね。

ユーザーの設定

CIには実行するユーザーが割り振られています。

なのでそのユーザーがソースコードに対してCommitしたりPushできたりするように設定する必要があります。

参考: MS Doc - Run Git commands in a script

設定場所は下図から確認できます。

f:id:TakasDev:20200614142736p:plain

Usersの<プロジェクト名> Build Serviceという名前に基本なっていると思います。

設定は下記のとおりですね。

f:id:TakasDev:20200614143016p:plain

  • Contribute / Read
    • 必須
  • Create Branch / Create Tag
    • 必要があれば
    • リリース時にタグ付けしたいとか、自動コミットじゃなくてブランチとPR作りたいとかの場合かな?と思います
  • Bypass policies when pushing
    • ブランチポリシーで保護しているブランチに対してPushしたい時に必要です。

実行結果

今回はDevOps上のArtifactsにライブラリをPublishしました。

  • developブランチのCIの場合

f:id:TakasDev:20200614144429p:plain

preleaseで出力されていることが確認できます。

  • masterブランチのCIの場合

f:id:TakasDev:20200614145048p:plain

patchで出力されていることが確認できます。

おわりに

今回使用したリポジトリは下記の通りです。

Angularの方はブランチポリシー下の動作検証のためAzure DevOpsでソース管理もしています。

.NET Core

Angular

供養

長く苦しい戦いの記録

f:id:TakasDev:20200614150251p:plain

Angular MaterialのComponentHarnessを使ってみた(Autocomplete編)

はじめに

この投稿内のAngularは下記のバージョンで構成しています。

この記事を参照されるタイミングによってはサンプルコードが動作しない可能性がありますのでご留意くださいませ。

  • Angular CLI: 9.1.6
  • @angular/material: 9.2.3

ComponentHarness

先日、ng-japan OnAir #16 を視聴した際にでてきた

Angular Materialで提供されているComponentHarnessを使ってみました。

自分が作るAngularのアプリケーションはMaterialを多用しているものが多く

Componentのテストが壊れにくくなりそうである。ということが魅力的でした。

ComponentHarness含む「CDK Testing API」については

下記の@lacolacoさんのレポートをご参考いただければと思います。

hackmd.io

AutocompleteのComponentHarness

割とMaterial全般を使っているので、一個ずつComponentを確認しようかな、と思いまして。

まずは「Autocomplete」に手を付けてみました。

(構成の都合上MatInputもちょいちょい入ってますが…)

material.angular.io

超絶雑ですが👇がテストを行うComponentの構成です。

f:id:TakasDev:20200516174532p:plain

Serviceからリストを受け取ってAutocompleteでリスト表示。

チェックボックスでAutocompleteコントロールのDisbaledを制御できる。みたいな感じです。

Harnessの作成

使用するのは初めてなので、そもそもHarnessの作り方、使い方から確認していきます。

Angular Materialのドキュメントと、ng-japan OnAirで実装されていたソースなどを参考に実装しました。

Componentテスト用のHarnessクラスを作成し、そこに諸々準備していきます。

MaterialのComponentHarnessは、各Materialのtesting内に格納されているそうです。 あとはMatInputと組み合わせて使用するので、MatInputのHarnessも必要そうです。

import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing';
import { MatInputHarness } from '@angular/material/input/testing';

で、AutocompleteとMatInputのHarnessをインポートしておきます。

privateでlocatorを準備して、コントロールにアクセスできるようにします。

今回、プレーンなチェックボックスも使用するので、locatorは👇な感じになりました。

  private getCheckBoxLocator = this.locatorFor('[data-testctl="checkbox"]');

  private getIncputLocator = this.locatorFor(
    MatInputHarness.with({
        placeholder: 'Test',
    })
  );

  private getAutoCompleteLocator = this.locatorFor(MatAutocompleteHarness);
  • チェックボックスのコントロールはテスト用の属性を指定して取得
  • MatInputは、MatInputHarnessを介して、placeholderが「Test」になっているコントロールを取得
  • AutocompleteはComponentに一つしかないので「MatAutocompleteHarness」のみで取得

コントロールの動作を実装する

Harnessクラスの中で公開する、コントロールの動作を実装していきます。

例えば、Dsiableを制御するチェックボックスのトグルは👇な感じですね。

  async toggleCheckBox(): Promise<void> {
    const chkbox = await this.getCheckBoxLocator();
    await chkbox.click();
  }

Locatorを介して取得したオブジェクトのClickイベントを叩くだけです。かんたん。

Disable状態を変更する動作をしたあとは、実際のコントロールの状態を確認したいです。

  async inputControlDisabledState(): Promise<boolean> {
    const input = await this.getIncputLocator();
    return input.isDisabled();
  }

こっちはlocatorを介してInputのオブジェクトを取得し、そのオブジェクトのisDisbaledステータスを返却します。

こっちもかんたん。

テストを実装してみる

さて、specファイルでテストを記述していきます。

テストで先程作成したHarnessを使用するためにfixtureを作成したあとに

使用するHarnessをFixtureを関連付けます(fixtureにharness(補助具)を割り当てるという表現になるんですかね?)。

  beforeEach(async () => {
    fixture = TestBed.createComponent(テストするComponent);
    component = fixture.componentInstance;
    harness = await TestbedHarnessEnvironment.harnessForFixture(
      fixture,
      作成したHarnessクラス
    );
    fixture.detectChanges();
  });

これで準備完了です。

行うテストを実装していきます。

まずは「チェックボックスをクリックしたらAutocompleteのInputが入力不可になること」を実装してみます。

  it ('CheckBoxToggle -> Input is Disabled', async () => {
    await harness.toggleCheckBox(); // Harness上で実行されるクリック処理
    const isDisabled = await harness.inputControlDisabledState; // Harness上で取得されるInputのDisabled状態
    expect(isDisabled).toBeTruthy();
  });

なんぞこれ?ですね。めちゃくちゃ簡単になっています。

Harnessなしの状態だと、Specファイル内でやっていた、ControlをElementから取得してー。とか。

そのコントロールから値を読んでー。といった処理が一掃されています。

そういったコードが抹消されたことにより、テストコードの可読性も上がっていますね。

さて、本題です。

AutocompleteのComponentHarness(2)

Autocompleteのコントロールでは下記3点をテストしてみようと思います。

  • Autocompleteのリストの初期状態
  • 対応するInputに入力があった場合に絞り込まれた状態となるか
  • Autocompleteのリストから選択された場合は対応するInputに文字列が入力されるか

まぁ、Materialで担保している内容もありますが、勉強なので…ということで。

さっそくハマる

リストの状態は、下記で取得できるのかいなと思っていたのですが

結果として帰ってきたのは空配列でした。

  async getAutoCompleteList(): Promise<MatOptionHarness[]> {
    const ctrl = await this.getAutoCompleteLocator();
    return await ctrl.getOptions();
  }

実装を確認する

ひとまず、困ったときは実装を確認するのみです。

実装の細かい部分はさておき、getOptions()の動きとしてはMatOptionのオブジェクトを取得してきているようです。

では、表示されるオプションはどうなっているのか確認してみます。

実際に動作させてElementを参照してみるとわかるのですが

AutoCompleteのリストはフォーカスがInputにあたった際に生えてくるもののようです。

f:id:TakasDev:20200516183232p:plain
リストが表示されているとき

f:id:TakasDev:20200516183316p:plain
リストが表示されていないとき

なので、オプションの情報を読むためには、「フォーカスをインプットに当てる」というワンアクションが必要であると予測します。

「フォーカスをインプットに当てる」処理を実装する

うまく行かなかった方法

インプットのlocatorはすでに作成したので、こいつが使えないかと思いました。

実装すると👇な感じですね。

  async inputFocus(): Promise<void> {
    const input = await this.getIncputLocator();
    await input.focus(); // focusを当てる
  }

これを、オプション情報の取得前に実行します。

  async getAutoCompleteList(): Promise<MatOptionHarness[]> {
    const ctrl = await this.getAutoCompleteLocator();
    this.inputFocus();
    return await ctrl.getOptions();
  }

結果、うまく行きませんでした。

f:id:TakasDev:20200516184201p:plain
テスト結果

フォーカスはあたっているが…!って感じですね。Autocompleteのリストが表示されていません。

ただ、デバッグを行っているウィンドウを選択していたらオプションが表示されるのでテストは通るのです。

f:id:TakasDev:20200516184534p:plain
デバッグウィンドウアクティブ時のテスト結果

結果はともかく、方針としては問題ないようです。

先人にならう

そもそも、MaterialのAutocompleteのComponent自体は同じようなテストをしているはずです。

どのようなテストを行っているのか確認してみました。

実装を見てみると、フォーカスをあてる処理としてdispatchFakeEvent(input, 'focusin');なることをしているようです。

ひとまず、dispatchFakeEvent周りの実装(この子この子この子)はそのまま使用させていただこうかと思います。

dispatchFakeEventに食わせるElementの取得

dispatchFakeEventの引数としてHtmlElementを要求されているので、その情報がほしいです。

locatorからElementの情報を取得できるので、それが利用できないか試してみました。

  async getAutoCompleteList(): Promise<MatOptionHarness[]> {
    const ctrl = await this.getAutoCompleteLocator();
    const inputElement: any = await ctrl.host(); // Element取得
    dispatchFakeEvent(inputElement, 'focusin'); // focus当てる
    return await ctrl.getOptions();
  }

しかし、host()の情報を利用するのはだめでした。

Elementの情報は取得できるのですが、HtmlElementそのものではないようで、dispatchEventが参照できずに終了します。

f:id:TakasDev:20200516190250p:plain

ここは本家よろしく、fixtureから直接取得する他なさそうです。

苦肉の策

fixtureから直接取得することにはしましたが、なるたけspecの方に実装したくはありません。

フォーカスを当てるのは、あくまでAutocompleteのリストを表示させてその値を参照したいからであって

そういった値を取得するまでのあれやこれやの処理はHarness側に寄せたいと思ったためです。

なので👇のように実装しました。

  async getAutoCompleteList(fixture: ComponentFixture<AutoCompleteComponent>): Promise<MatOptionHarness[]> {
    const ctrl = await this.getAutoCompleteLocator();
    const inputElement = fixture.debugElement.query(By.css('[data-testctl="autcompinput"]')).nativeElement;
    dispatchFakeEvent(inputElement, 'focusin');
    return await ctrl.getOptions();
  }

fixtureを引数でもらって、そこからElementを取得する力技です。

ベストプラクティスからは遠いかな?とも思いますが、これでうまくいくかどうか試してみます。

f:id:TakasDev:20200516224139p:plain

うまくいきました。

テストの実装はどうなったか

では、テスト本体の実装はどうなったのか見てみます。

テスト実行部分だけ抜粋します。

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it ('init option list', async () => {
    const datas = await harness.getAutoCompleteList(fixture);
    expect(datas.length).toEqual(10);
  });

  it ('filter option list', async () => {
    await harness.inputText('0');
    fixture.detectChanges();
    const datas = await harness.getAutoCompleteList(fixture);
    expect(datas.length).toEqual(1);
  });

  it ('option selection', async () => {
    await harness.inputText('');
    fixture.detectChanges();
    await harness.selectOption('0さん', fixture);
    const iputValue = await harness.inputTextValue();
    expect(iputValue).toEqual('0さん');
  });

  it ('CheckBoxToggle -> Input is Disabled', async () => {
    await harness.toggleCheckBox();
    const isDisabled = await harness.inputControlDisabledState;
    expect(isDisabled).toBeTruthy();
  });

ものすごくスッキリしました。

改めて見て「これがAngularのComponentのテストコードってマジ?」とか思いました。

まとめ

Angular MaterialのAutocompleteのComponentHarnessの使い方を見てきました。

実装した感覚、コード自体は普段と比べて書く量はそこまで変わらないような印象ですが

テストコード本体のスッキリ具合や

意図せずともテストの動作を個別に実装することができるようになるので

ソースコードの再利用性や可読性はぐっと上がるのではないかと思いました。

(ひょっとしたら再利用性のほうが恩恵でかいのでは…?と思ってたりもします。)

MaterialのComponentHarnessは最初から色々とコントロールを動かすためのAPIを用意してくれているので積極的に使っていきたいですね!

今回作成した諸々は下記レポジトリに格納しています。

ちょいちょいMatrialのComponentHarnessで実験して、ハマったら記事に上げようかと思います。。

github.com