33
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

AWS CDKの'aws-lambda-nodejs'を使ってCDKとLambdaの間の壁を破壊する

Posted at

動機

久々にAWS CDKのリファレンスを眺めていたらこんなConstructを発見!!

aws-lambda-nodejs module

This library provides constructs for Node.js Lambda functions.

これはもしかして、CDKのソースとLambdaのソースを一気通貫で管理できるってことかい!?もうこんな過去記事のようにCDKのソースとLambdaのソースを分けて管理したり別々のnode_modulesやtsconfig.jsonを持たなくても良いのかい!?なんて美しいんだ!!バチが当たるじゃないか!?

ということでやってみました。

作るもの: S3式FizzBuzz

じゃあ、検証ということでFizzBuzzでも作りますかね。折角のAWS CDKでただただFizzBuzzをやるlambdaを作っても面白くないのでこんな感じでどうでしょう?

仕様

s3-fizzbuzz.png

S3に置いた設定ファイルの設定値を元にFizz Buzzを実行した結果をS3に出力します。

設定ファイル例

test.yaml
from: 1 #始点
to: 15 #終点
fizz: 3 #fizzに変換する値(任意)
buzz: 5 #buzzに変換する値(任意)

結果例

test.20200223103211.log
1
2
fizz
4
buzz
fizz
7
8
fizz
buzz
11
fizz
13
14
fizzbuzz

作ってみた

ソースの全体像について

GitHubに置いたので、全体像を把握したい方はこちらからご覧ください。

ポイントはやはり、今までのようにlambda専用にディレクトリを切って独自のpackage.jsonやtsconfig.jsonを置いて管理しているわけではなく、./libディレクトリの中にスタック定義と一緒にlambda定義も置いてあって、package.jsonやtsconfig.jsonはCDKのものと共有している点だと思います。いや、これだけでかなりスッキリしましたよね。感動。。

環境準備

まずは公式チュートリアルを参考に、お使いのアカウントに対してAWS CDKからリソースをデプロイできるようにしておいてください。

特にこだわりがなければ、AWS Cloud9上に環境作るのが楽だと思います。ただ、CDKに限ったことではないですが、デフォルトのt2.microインスタンスでtypescriptを扱っているとビルドやユニットテストの際にメモリ不足で停止することがよくあるので、お財布と相談の上で1個上のt2.smallに上げることを個人的にはおすすめします。

以下、cdkコマンドは使えるようになった前提で話を進めます。

i.プロジェクトの作成

$ mkdir s3-fizzbuzz
$ cd s3-fizzbuzz
$ cdk init --language=typescript

まあ、ほぼほぼチュートリアルの通りですね。

ii.ドメイン層

『たかだかFizzBuzzでドメイン層って。。。』というツッコミが聞こえてきそうではありますが、まあまあ、ちょっとだけお付き合いください。
cdk initで作成されたlibディレクトリ直下に以下のようなソースを書きました。

./lib/domain.ts
/**
 * FizzBuzzの設定
 **/
export interface FizzBuzzSetting{
    /**始点*/
    from: number
    /**終点*/
    to: number
    /**fizzに変換する数*/
    fizz?: number
    /**buzzに変換する数*/
    buzz?: number
}

/**
 * [[FizzBuzzSetting]]のtype guard
 * @param obj チェックしたいオブジェクト
 * @return objがFizzBuzzSettingであればtrue
 **/
export const isFizzBuzzSetting = (obj: any): obj is FizzBuzzSetting =>{
    return typeof obj.from === "number"
        && typeof obj.to === "number"
        && (!obj.fizz || typeof obj.fizz === "number")
        && (!obj.buzz || typeof obj.buzz === "number");
}

/**
 * 引数が正の整数かどうか
 * @param n チェックしたい数
 * @return nが正の整数であればtrue
 **/
const isPositiveInt = (n:number): boolean => n > 0 && Math.floor(n) === n;

/**
 * [[FizzBuzzSetting]]のバリデーション
 * @param from 始点(正の整数)
 * @param to 終点(正の整数)
 * @param fizz "fizz"に変換する数(正の整数/デフォルト3)
 * @param buzz "buzz"に変換する数(負の整数/デフォルト5)
 * @return デフォルト値割り当て済みのFizzBuzzSetting
 **/
const validate = ({from, to, fizz=3, buzz=5}: FizzBuzzSetting):Required<FizzBuzzSetting> => {
    if(!isPositiveInt(from)) throw new Error(`from:${from}は正の整数である必要があります`);
    if(!isPositiveInt(to)) throw new Error(`to:${to}は正の整数である必要があります`);
    if(to <= from) throw new Error(`to:${to}はfrom:${from}よりも大きい数値である必要があります`);
    if(!isPositiveInt(fizz)) throw new Error(`fizz:${fizz}は正の整数である必要があります`);
    if(!isPositiveInt(buzz)) throw new Error(`fizz:${buzz}は正の整数である必要があります`);
    if(fizz === buzz) throw new Error(`fizz:${fizz}とbuzz:${buzz}は異なる数値である必要があります`);
    
    return {from,to,fizz,buzz};
}

/**
 * fizzbuzzを実行する
 * @param setting 設定値
 * @return 結果が格納された配列
 **/
export const fizzbuzz = (setting: FizzBuzzSetting): string[] => {
    const {from, to, fizz, buzz} = validate(setting);
    
    return Array.from({length: (to - from + 1)}, (_,i) => i + from).map((n):string =>{
        if(n % (fizz * buzz) === 0) return "fizzbuzz";
        if(n % fizz === 0) return "fizz";
        if(n % buzz === 0) return "buzz";
        return n.toString();
    });
}

閑話休題:サーバレス/FaaSはベンダーロックインになる?

よく「サーバレス/FaaSはクラウドベンダーにロックインするよね」と言われますが、それに対する答えというか対策が「ドメイン層の分離」だと思っています。つまり、AWS Lambdaの文脈で言えば、

  1. LambdaやS3、AWS SDKなどに依存せず、ピュアなjs/tsでアプリケーションの仕様を書き表した層(≒ドメイン層)
  2. LambdaやS3、AWS SDKとドメイン層を接続する層(≒インタフェース層)

を明確に分離して2.の部分を薄く保つことによって、仮にAWSをやめたくなったとしても上記1.の部分に関しては、ExpressやApploServerに乗っけるなり何なりして使い回せるわけです。これは別に目新しい考え方でも何でもなくて、例えばMVCに於いて「Controller層を薄く作りましょう」と言っているのと同じ考え方なわけで、サーバレスであろうが何であろうが『アーキテクチャをクリーンに保つ』という考え方は変わらないわけです。

このあたりの理屈を詳しく勉強したい方は、アンクル・ボブ先生のClean Architectureがおすすめです。

閑話休題:テスタビリティ

もう1つ、ドメイン層を分離する動機として、テスタビリティ(テストのしやすさ)の向上があります。
「FizzBuzzのアルゴリズムが正しく組めているか」「設定の内容を正しく読めているか」をテストしたいときに、「S3 Bucketへの書き込み/読み込みができるサービスロールが付いているか」みたいなゴリゴリAWSと密結合な関心事について1ミリ秒足りとも考えたくないのです。
 
ドメイン層を明確に分離したことによって、以下のような「ドメインの関心ごとに特化した」テストをシンプルに書くことができます。

./test/domain.test.ts
import {fizzbuzz, isFizzBuzzSetting} from '../lib/domain';

/**
 * fizzbuzz関数に関するテスト
 **/
describe("fizzbuzz function",()=>{
    /**
     * 設定値に応じてfizzbuzzを実行して結果を返す
     **/
    it("returns result of fizzbuzz by setting",()=>{
        const result = fizzbuzz({
           from: 1,
           to: 15
        });
        
        expect(result).toEqual(["1","2","fizz","4","buzz","fizz","7","8","fizz","buzz","11","fizz","13","14","fizzbuzz"]);
    });
    
    /**
     * fizzに変換する値を書き換えることができる
     **/
    it("assumes overwriting 'fizz' value by setting",()=>{
        const result = fizzbuzz({
           from: 1,
           to: 10,
           fizz: 2
        });
        
        expect(result).toEqual(["1","fizz","3","fizz","buzz","fizz","7","fizz","9","fizzbuzz"]);
    });
    
    /**
     *  buzzに変換する値を書き換えることができる
     **/ 
    it("assumes overwriting 'buzz' value by setting",()=>{
        const result = fizzbuzz({
            from: 1,
            to: 10,
            fizz: 2,
            buzz: 3
        });
        
        expect(result).toEqual(["1","fizz","buzz","fizz","5","fizzbuzz","7","fizz","buzz","fizz"]);
    });
    
    /**
     * 設定値が不正な場合はエラーが発生する
     **/ 
    it("throws Error if setting is invalid",()=>{
        expect(() => fizzbuzz({from:-1,to:2})).toThrow();
        expect(() => fizzbuzz({from:1.1,to:2})).toThrow();
        expect(() => fizzbuzz({from:1,to:-2})).toThrow();
        expect(() => fizzbuzz({from:1,to:2.1})).toThrow();
        expect(() => fizzbuzz({from:2,to:1})).toThrow();
        expect(() => fizzbuzz({from:1,to:10, fizz:-1})).toThrow();
        expect(() => fizzbuzz({from:1,to:10, fizz:1.1})).toThrow();
        expect(() => fizzbuzz({from:1,to:10, buzz:-1})).toThrow();
        expect(() => fizzbuzz({from:1,to:10, buzz:1.1})).toThrow();
        expect(() => fizzbuzz({from:1,to:10, fizz:2,buzz:2})).toThrow();
    });
});

/**
 * isFizzBuzzSettingに関するテスト
 **/
describe("isFizzBuzzSetting",()=>{
    /**
     * objectがFizzBuzzSettingかどうかをチェックする 
     **/
    it("checks if the object is FizzBuzzSetting",()=>{
        expect(isFizzBuzzSetting({from:1, to:100})).toBe(true);
        expect(isFizzBuzzSetting({from:1, to:100, fizz:4})).toBe(true);
        expect(isFizzBuzzSetting({from:1, to:100, fizz:4, buzz:7})).toBe(true);
    });
    
    /**
     * 引数が不正であればfalseを返す
     **/ 
    it("returns false if argument is invalid",()=>{
        expect(isFizzBuzzSetting(1)).toBe(false);
        expect(isFizzBuzzSetting({})).toBe(false);
        expect(isFizzBuzzSetting({from:1})).toBe(false);
        expect(isFizzBuzzSetting({to:1})).toBe(false);
        expect(isFizzBuzzSetting({from:"a", to:3})).toBe(false);
        expect(isFizzBuzzSetting({from:1, to:"b"})).toBe(false);
        expect(isFizzBuzzSetting({from:1, to:3, fizz:"c"})).toBe(false);
        expect(isFizzBuzzSetting({from:1, to:3, fizz:2, buzz:"d"})).toBe(false);
    });
});

今回はaws-lambda-nodejsを使ってソースをEndToEndで管理しているので、npm testコマンドでCDKのテストとドメイン層のテストを一気通貫で実行できるのもまた魅力の1つだと思っています。

iii. Lambda関数(≒インタフェース層)

ということで本題に戻って、Lambdaを書いて行きましょう。

aws-lambda-nodejsのOverviewによると、

Define a NodejsFunction:

new lambda.NodejsFunction(this, 'my-handler');

By default, the construct will use the name of the defining file and the construct's id to look up the entry file:

.
├── stack.ts # defines a 'NodejsFunction' with 'my-handler' as id
├── stack.my-handler.ts # exports a function named 'handler'

ということで命名規則がありそうなのでそれに従って行きましょう。
cdk init./lib/s3-fizzbuzz-stack.tsというスタック定義用のソースが作られていて、関数名をfizzbuzz-funcだとすると、命名規則的にはs3-fizzbuzz-stack.fizzbuzz-func.tsを作ればいいはずです。

その前に関連パッケージをnpm経由でinstallします。package.json/node_modulesをCDKと共用できるので楽でいいですね!

$ npm install --save aws-sdk js-yaml moment
$ npm install --save-dev @types/aws-lambda 

ということで、Lambdaのhandlerを書いていきましょう。

./lib/s3-fizzbuzz-stack.fizzbuzz-func.ts
import {S3Handler,S3Event, S3EventRecord} from 'aws-lambda';
import {S3} from 'aws-sdk';
import * as yaml from 'js-yaml';
import {Moment} from 'moment';
import {isFizzBuzzSetting, fizzbuzz} from './domain';


const s3 = new S3();

/**
 * 設定ファイルを読み込む
 * @param bucket 対象バケット
 * @param key 対象key
 * @return 読み込んだ結果
 **/
const loadSetting = async(bucket: string, key: string): Promise<Object> => {
    const response = await s3.getObject({
        Bucket: bucket,
        Key: key
    }).promise();
    
    if(response.Body){
        const body = response.Body.toString();
        if(key.endsWith(".json")){
            return JSON.parse(body);
        }else{
            return yaml.safeLoad(body);
        }
    }else{
        throw new Error(`Load setting file failure:${key} on ${bucket}`);
    }
}

/**
 * 実行結果を保存する
 * @param key 対象バケット
 * @param settingName 設定ファイル名
 * @param result 実行結果
 **/
const saveResult = async (bucket: string, settingName: string, result: string[]): Promise<void> => {
    const moment = require("moment");
    const now: Moment = moment().format("YYYYMMDDHHmmss");
    const resultKey = settingName.split(".").slice(0,-1).join(".") + `.${now}.log`;
    
    await s3.putObject({
        Bucket: bucket,
        Key: resultKey,
        Body: result.join("\n")
    }).promise();
}

/**
 * このLambdaの実行対象レコードかどうか
 * @param record チェックするrecord
 * @return 実行対象であればtrue
 **/
const isTargetEvent = (record: S3EventRecord): boolean => record.eventName.startsWith("ObjectCreated:") 
    && (record.s3.object.key.endsWith(".json") || record.s3.object.key.endsWith(".yaml") || record.s3.object.key.endsWith(".yml"));

/**
 * fizzbuzz-funcのhandler
 * @param event S3のイベント
 **/
export const handler:S3Handler = async(event:S3Event) => {
    for(const record of event.Records){
        if(isTargetEvent(record)){
            try{
                const bucketName = record.s3.bucket.name;
                const targetKey = record.s3.object.key;
                const setting = await loadSetting(bucketName, targetKey);
                
                if(isFizzBuzzSetting(setting)){
                    const result = fizzbuzz(setting);
                    await saveResult(bucketName, targetKey, result);
                }else{
                    throw new Error(`Invalid setting ${JSON.stringify(setting)}`);
                }
            }catch(e){
                console.error(e.message);
            }
            
        }
    }
}

まあ、大したことはやっていないですね。S3のObjectCreated:*を拾って、S3から設定ファイル(YAMLもしくはjson)を読み込んで、その設定値を先ほど作ったドメイン層のfizzbuzz関数に渡して、結果をまたS3に保存しているだけです。

iv. スタック定義

ということでいよいよ、aws-lambda-nodejsを利用してlambdaを作ってみましょう。

まずは関連ライブラリのinstallをします。

$ npm install --save-dev @aws-cdk/aws-lambda-nodejs @aws-cdk/aws-s3 @aws-cdk/aws-s3-notifications 

続いて、./lib/s3-fizzbuzz-stack.tsを編集します。

./lib/s3-fizzbuzz-stack.ts
import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda-nodejs';
import * as s3 from '@aws-cdk/aws-s3';
import * as s3n from '@aws-cdk/aws-s3-notifications';

/**
 * S3式FizzBuzzのスタック
 **/
export class S3FizzbuzzStack extends cdk.Stack {
  /**
   * コンストラクタ
   * @param scope スコープ
   * @param id Constructのid
   * @param props 設定値
   */
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    /**
     * fizzbuzz-func関数
     * ${スタックのソース名}.${関数のID(第2引数)}.tsが
     * exportするhandler関数をLambdaのハンドラと見做してデプロイしてくれる
     **/
    const fizzbuzzFunc = new lambda.NodejsFunction(this, "fizzbuzz-func");
    
    /**
     * 設定ファイルと結果を格納するバケット
     **/
    const fizzbuzzBucket = new s3.Bucket(this, "fizzbuzz-bucket");
    
    /**
     * fizzbuzz-bucketへの読み込み/書き込み権限をfizzbuzz-funcに付与する
     **/
    fizzbuzzBucket.grantReadWrite(fizzbuzzFunc);
    
    /**
     * fizzbuzz-bucketへの.json/.yaml/.ymlファイルのOBJECT_CREATEDを
     * fizzbuzz-funcへ通知する
     **/
    fizzbuzzBucket.addEventNotification(
      s3.EventType.OBJECT_CREATED, 
      new s3n.LambdaDestination(fizzbuzzFunc), 
        {suffix: ".json"}
      );
    fizzbuzzBucket.addEventNotification(
      s3.EventType.OBJECT_CREATED, 
      new s3n.LambdaDestination(fizzbuzzFunc), 
        {suffix: ".yaml"}
      );
    fizzbuzzBucket.addEventNotification(
      s3.EventType.OBJECT_CREATED, 
      new s3n.LambdaDestination(fizzbuzzFunc), 
        {suffix: ".yml"}
      );
  }
}

いやーシンプルですね!

閑話休題:AWS CDKとガバナンス

AWS CDKを導入したい理由のほとんどがいわゆる"効率面"だとは思いますが、個人的には、実は"ガバナンス強化"がデカいんじゃないかと思っています。

特にこういったサーバレスの世界ではファイアウォールのようなネットワーク的境界があるわけではないので、IAMの管理がセキュリティ上の肝だと思います。ところが今回のlambdaを作るとき、コンソールから作るときでもCloudFormationのテンプレートを直書きするときでも、ついLambdaのサービスロールに対して、AmazonS3FullAccessのような雑なポリシーを当てがちではないでしょうか?まあ、単に「めんどくさい」というパターンと「そもそもIAMがよく分かっていない」というパターンがあるとは思いますが。何れにしても、こういったことがセキュリティリスクであることには変わりないわけです。

ところが、CDKのスタック定義を見てください。

    /**
     * fizzbuzz-bucketへの読み込み/書き込み権限をfizzbuzz-funcに付与する
     **/
    fizzbuzzBucket.grantReadWrite(fizzbuzzFunc);

この1行だけで、「適切なバケットに対して」「適切なポリシーを持つ」ロールが作成されるのです。実際にこのソースから作成されたサービスロールのテンプレートを見てみましょう。

  fizzbuzzfuncServiceRoleD547EF86:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
        Version: "2012-10-17"
      ManagedPolicyArns:
        - Fn::Join:
            - ""
            - - "arn:"
              - Ref: AWS::Partition
              - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
    Metadata:
      aws:cdk:path: S3FizzbuzzStack/fizzbuzz-func/ServiceRole/Resource
  fizzbuzzfuncServiceRoleDefaultPolicy9977BB4B:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Action:
              - s3:GetObject*
              - s3:GetBucket*
              - s3:List*
              - s3:DeleteObject*
              - s3:PutObject*
              - s3:Abort*
            Effect: Allow
            Resource:
              - Fn::GetAtt:
                  - fizzbuzzbucketD2245D8E
                  - Arn
              - Fn::Join:
                  - ""
                  - - Fn::GetAtt:
                        - fizzbuzzbucketD2245D8E
                        - Arn
                    - /*
        Version: "2012-10-17"
      PolicyName: fizzbuzzfuncServiceRoleDefaultPolicy9977BB4B
      Roles:
        - Ref: fizzbuzzfuncServiceRoleD547EF86
    Metadata:
      aws:cdk:path: S3FizzbuzzStack/fizzbuzz-func/ServiceRole/DefaultPolicy/Resource

自作のテンプレートでここまで作り込むには、相当CloudFormationやIAMに精通している必要があるのではないでしょうか?熟練のAWS職人がわんさかいるプロジェクトならともかく、「初めてAWSに挑戦します!」みたいな若手だらけのプロジェクトにここまでの技術を期待しますか?

そんな状態に陥るくらいなら、コンソールからのLambda作成やCloudFormationの直書きを禁止した上で、CDKを使ってBucket#grantReadWriteメソッドを使用すること(.grantRead.grantWriteもあります)を強制した方がよっぽどガバナンスが守られるわけです。もちろん効率面でも相当効果が高いですし、まさに一石二鳥ですね!

deploy

早速デプロイしてみましょう!

$ cdk deploy

しばらく待つとスタックが出来ると思います。

動作確認

test.yaml
from: 1
to: 100
test2.json
{
  "from":100,
  "to": 200,
  "fizz": 5,
  "buzz": 7
}

結果
スクリーンショット 2020-02-23 14.27.57.png

上手くいってそうですね!!

考察

デプロイされたlambdaのパッケージの依存関係が気になる

これまでも散々、

package.jsonやnode_modulesをCDKと共用できる!

と書いてきましたが、その結果、デプロイされたlambdaのnode_modulesはどうなってるんでしょう...?
まさか、aws-cdk本体とか@types/xxxとか、要らないものまでまとめてコピーされてるんじゃ。。。

ということで、コンソールからデプロイされたlambdaを見てみましょう。

スクリーンショット 2020-02-23 15.03.27.png

おや...?そもそも/node_modulesが無い?しかもindex.jsの中に書いた覚えのないコードが沢山。。

実はリファレンスにも書いてあるのですが、内部でParcelというパッケージングツールが動いているみたいです。

Parcelについての詳しい説明は先人の素晴らしい記事があるのでそちらを見て頂くとして、

どうもwebpackのように、依存関係を解決して一つのファイルにまとめてくれるツールのようです。

なるほど!これで元のnode_modulesの中から必要な(lambdaが依存している)パッケージだけ引っ張ってきてindex.jsにまとめデプロイしてくれるというわけですね。こんな過去記事のようなことをしなくていいわけだ。。

所感

"アプリ"と"インフラ"がボーダレスに

我々は伝統時に、サーバやネットワークなどの"インフラ"とそこで動くプログラムとしての"アプリ"を分離して仕事していましたし、今ままでいくら「サーバレス」といっても、CloudFormationで定義する"インフラ"とlamdbdaの関数として実行される"アプリ"にはまだ壁があったように思います。

このaws-lambda-nodejsのおかげでようやく、"アプリ"と"インフラ"の間に本当に壁が無くなって、同じTypeScriptという言語で同じアセットの中で同じ設定値(package.jsonやtsconfig.json)を共有しながら書けるようになったというのは、私にとって『新感覚』な出来事でした。

"Infrastructure as Code"ならぬ"Whole system as Code"とか"Everything as Code"とか呼んでもいいんじゃないでしょうか。

TypeScriptの適用範囲の広さよ..

今やReact,Vue,Angular(およびReact NativeやNativeScript)のようなモダンなクライアントサイドはjavascriptでなくTypeScriptで書くのが当たり前となっていますし、このaws-lambda-nodejsのおかげでサーバサイドをTypeScriptに統一する動機も増えたということで、ますますTypeScriptを勉強するモチベーションが上がりますね。

いやもちろん、「マルチ言語」はマイクロサービスのメリットでもあるので、必要な局面で必要な言語(例えば、機械学習やるなら明らかにpythonですよね)を使えばいいとは思うのですが、むしろこういた「マルチ言語」の時代だからこそ、いわば『公用語』としてTypeScriptを選択するのは悪くないんじゃないかと思っています。

CloudFormationのテンプレートを直書きするのはもはやアンチパターンか

「閑話休題」でも少し触れましたが、CloudFormationのテンプレートを自作するのはもはや苦行な上にガバナンス的なデメリットもあって、良いこと無い気がしてきました。

のような素晴らしいツールがある中で、CloudFormationのテンプレート直書きはもはや積極的に避けるべきなのかも知れません。

一方でこれらのツールの挙動をちゃんと理解したりトラブルシュートをするためにはCloudFormationに関する知識は必須であるわけで。。この辺りの抽象化のバランスは難しいですね。とはいえ、例えば私のようにプログラマを名乗っていてもアセンブラを書けない人が大多数の時代も来たわけなので、そのうちtemplate.yamlが「低級言語」と言われる時代が来るのかも知れませんね。

33
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
33
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?