24
20

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.

Slack Appに挑戦(1) - WebhookとSlash Commandまで

Last updated at Posted at 2019-09-23

「SlackBotにまじめに取り組む」の1回目
#この記事は
いままで、旧タイプのレガシートークンでIncoming Webhookを使ってSlackと連携していました。少し前にSlackのAppの仕様が変わったこともあって、レガシートークンではなくSlack Appの仕組みをちゃんと勉強してみることにしました。
今回は、Slack AppにあるIncoming Webhookとslash commandまでやってみます。Event APIは次の機会で。

##今回使う環境

  • Heroku
    • PHP 7.3
    • Laravel 6.0.3
    • Clear DB
  • Windows
    • PHP 7.2.11
    • Laravel 6.0.3
    • Maria DB Ver 15.1

いつもどおり、基本的にはWindowsでの作業です。

Slash CommandでSlackからの呼びかけを受け取るために、インターネット上にHTTP/Sのサーバーを立てる必要があります。今回は、Laravel6系をHeroku上にデプロイして試しますが、Laravel6の新機能要素はゼロです。

#まず下準備

##Laravel 6 をインストール

C:\slalack-bot
composer create-project laravel/laravel --prefer-dist .

php artisan -V
Laravel Framework 6.0.3

#動作確認
php artisan serve
http://127.0.0.1:8000/

#いつもの画面が出ることを確認

image.png

:sunny:

##Laravel をHerokuにデプロイ

C:\slalack-botにProcfileを作る

/slalack-bot/Procfile
web: vendor/bin/heroku-php-apache2 public/

Heroku上にアプリを定義してデプロイ
アプリ名はslalack-bot

#Heroku app 作成
heroku create slalack-bot
https://slalack-bot.herokuapp.com/ | https://git.heroku.com/slalack-bot.git

git init
Initialized empty Git repository in C:/slackbot/.git/

heroku git:remote -a https://git.heroku.com/slalack-bot.git
set git remote heroku to https://git.heroku.com/slalack-bot.git

git commit -m "application init"
git push heroku master

Laravelの.envの必要最低限をHerokuのconfigへ

heroku config:set APP_NAME="slalack-bot"
heroku config:set APP_ENV="production"
heroku config:set APP_KEY="base64:GlmfutBxM/hJJBdNuQyI267djR55srF15nJaoLV2QZo="
heroku config:set APP_DEBUG="true"
heroku config:set APP_URL="https://slalack-bot.herokuapp.com/"

https://slalack-bot.herokuapp.com/ にアクセスして、Laravelの画面が出ることを確認

#SlackにAppを登録
Slackに独自のワークスペースがある前提で進めます。

ブラウザでSlackのAPIページへいく
https://api.slack.com/apps?new_app=1

新規App画面が出るので、適当に入力し```Create App``ボタンを押す
image.png

AppNameはあとで変えられるので、適当につけておく。いったんこんな感じで。

image.png

##incoming webhookを試す
まずは、独自Appの動作確認のためにincoming webhookを試してみます。ひと昔前はWebhookが単独でメニュー化されていましたが、最近は自分で定義したAppの一部に組み込まれるようになりました。

image.png

Webhookは作成時にChannelを指定します。昔はメッセージを送るときに無差別に送るチャンネルを変更できましたが、最近は推奨されていないです。複数チャンネルに送りたい場合には、この手順を繰り返しチャンネルごとにWebhookを用意しないといけません。
image.png

webhookを登録すると、こんな感じにメッセージの送信先URLが発行されます。
image.png

このURLを誰かに知られるとワークスペースにメッセージ送られ放題になるので、気を付けましょう。

:warning: curlコマンドのワンライナーもコピーできますが、Windowsのコマンドプロンプトではシングルクオートの囲みがエラーになるので、ダブルクオートに書き換えます。("の中の"は\でエスケープも)

curl -X POST -H "Content-type: application/json" --data "{\"text\":\"HelloWorld\"}" https://hooks.slack.com/services/aaa/bbb/ccc

指定したチャンネルにメッセージが届きます:sunny:
image.png

##Slash Commandを試す
本命のスラッシュコマンドをやります。

###1.コマンドの受信用のHTTP/Sアプリを作る

artisanコマンドでコントローラを作り

php artisan make:controller SlalackBotController
Controller created successfully.

最初はdebugメソッドを用意。
内容は、(Slackから)受け取ったパラメータを全部ログに書き出しつつ、簡単なメッセージだけ返却するだけです。

app/Http/Controllers/SlalackBotController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Log;

class SlalackBotController extends Controller
{
    public function debug(Request $request){
        \Log::info($request->all());

        $date = date('Y-m-d H:i:s');
        $responseText = 'debug method are called at ' . $date;
        return response()->json(['text'=>$responseText]);
    }
}

Slash CommandのレスポンスはJSONで戻す必要があります。 Laravelのresponse()->json()を使うと、PHPの配列を自動でJSONにしてくれて、レスポンスヘッダーにapplication/jsonが自動で付与されるので便利です。
https://readouble.com/laravel/5.5/ja/responses.html#json-responses

###2.routeの設定
web.phpではなくてapi.phpに登録します。この場合、api/debugがエンドポイントになります

routes/api.php
Route::post('/debug', 'SlalackBotController@debug');

###3.ログの設定
HerokuにデプロイしたLaravelのログを見るのは面倒なので、SlackにLogを送ってしまう。最初に作ったIncoming Webhookにログが送られるようにします。(ここは必須ではないです)

config/logging.php
    'channels' => [
        'stack' => [
            'driver' => 'stack',
            #slackを追加
            'channels' => ['daily','slack'],
            'ignore_exceptions' => false,
        ],

.envファイルにWebhookのURLを設定する

.env
LOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/aaa/bbb/ccc

Heroku側にも設定を追加

heroku config:set LOG_SLACK_WEBHOOK_URL="https://hooks.slack.com/services/aaa/bbb/ccc"

###4. 変更した内容をHerokuへデプロイ

git add .
git commit -m "log to slack"
git push heroku master

これで、https://slalack-bot.herokuapp.com/api/debug でPOSTを受けられるようになります。

###5.Slash Command として登録

Slack AppのBasicInformationからSlach Commandsへ

image.png

Create New CommandからSlashコマンドの設定をする
Request URLは、先ほど作ったhttps://slalack-bot.herokuapp.com/api/debugにしています。ここは各自環境により変わります。それ以外の部分はおまかせです。

image.png

これでいったん準備完了です。

Slash Commandの登録完了したときに、permissionが変わったから再インストールするようにメッセージが出たら、メッセージに従って作業してください。

###6.いざ実行
Slackを開いて、スラッシュで呼びかけてみます

image.png

Botからのレスポンス。うまくいったっぽい:sunny:
image.png

正しく設定されていると、Botの裏で動いているLaravelのLogがSlackのWebhookにも届きます
image.png

スラコンのお試し、無事完了。

ログを見てみると、スラッシュコマンドに書いたメッセージは、Laravelの$Requestの中のtextから取れるってわけですね。PostメソッドでJSONが送られているはずなのに、LaravelではRequestのインスタンスで透過的に取れるようにしてくれます。

##Slash Commandを試す(2)
Slash Commandは3秒以内に200のレスポンスを返すことが求められています。もし、処理に時間がかかって返せない場合には、Slack CommandのPOST内容に含まれているresponse_urlを覚えておいて、あとからHTTPでPOSTすれば、Slash Commandを送ってきた相手にメッセージが返せます。

URLの有効期限は30分以内、メッセージを送るのは5回までと決まっています。

ということで、response_urlを使うバージョンを試してみました。

###1.Guzzleをインストール

LaravelからHTTPSアクションを起こすので、Guzzleをインストール

composer require guzzlehttp/guzzle

###2.コントローラにdelayメソッドを追加

Guzzleクライアントは、jsonという名前のキーでPHPの配列を渡すと、自動的にリクエストヘッダーにapplication/jsonをセットし、BodyにJSON文字列を渡してくれるので、実装がとても簡単です。
http://docs.guzzlephp.org/en/stable/request-options.html#json

app/Http/Controllers/SlalackBotController.php
    public function delay(Request $request){

        $responseUrl = $request->input('response_url');
        $text = $request->input('text');
        \Log::debug($responseUrl);
        \Log::debug($text);
        
        $guzzle = new \GuzzleHttp\Client();
        $option = [];
        $option['debug']=false;
        $option['verify']=false;
        $option['http_errors']=false;

        ##5回以上はエラーになるはずなので6回
        for( $i=0; $i<6; $i++){
            $option['json']=['text'=> "$text : reply from bot ($i)" ];
            $response = $guzzle->post($responseUrl, $option);
            $body = $response->getBody();
            $data = (String)$body;

            if( $response->getStatusCode() != 200 ){
                \Log::error($data);
                break;
            }
        }

        $date = date('Y-m-d H:i:s');
        $responseText = 'Direct reply at ' . $date;
        return response()->json(['text'=>$responseText]);
    }

上で作ったdelayメソッドAPIとして登録する。

routes/api.php
Route::post('/delay', 'SlalackBotController@delay');

###3.Slack Appに追加
delayメソッドを新しくスラッシュコマンドに追加で登録します

image.png

###4.いざ実行
image.png

6回目の呼び出しは404のエラーになり、Log::errorで書き出ししたものもSlackに無事到着。その後にDirectなレスポンスも届きました。

#Laravelらしく、Queueに入れてから処理する

ここまでの実験で、何をやる必要があるかは理解できました。
せっかくLaraveで作っているので、LaravelのQueueの仕組みを使ってJobを実行して、Slackに返信を行うようにしてみます。

やることは、

  1. Slashコマンドを受ったら、メッセージの内容をDBに保存し
  2. Laravelのキューに入れ、Slackにはいったんメッセージを返す
  3. キューに入ったジョブを実行し、Slackを呼び出しした人へ返答する

##1.データベース設定
Slackからのデータを一時的に保存する必要があるので、HerokuでClearDBのAddonを入れます。あわせてClearDBの接続パラメータをHerokuのConfigに必要なパラメータを設定しておきます。

heroku config:set DB_CONNECTION="mysql"
heroku config:set DB_HOST="us-cdbr-iron-east-02.cleardb.net"
heroku config:set DB_PORT="3306"
heroku config:set DB_DATABASE="heroku_xxxxx"
heroku config:set DB_USERNAME="bd048xxxxxx"
heroku config:set DB_PASSWORD="xxxx"

#これも必要なので一緒に
heroku config:set QUEUE_CONNECTION="database"

##2.Job Queueの準備
Jobを保存する仕組みを構築します。Laravelの基本的な仕組みをそのまま使います。

###必要なテーブルの準備

php artisan queue:table
Migration created successfully!

database/migrationsにファイルができます。不要なファイルもできるので消しておきます。

  • 2014_10_12_000000_create_users_table.php (いらない)
  • 2014_10_12_100000_create_password_resets_table.php (いらない)
  • 2019_08_19_000000_create_failed_jobs_table.php
  • 2019_09_22_215540_create_jobs_table.php

MySQLを使っている場合で、jobsテーブルのupメソッドのqueueカラムで以下のエラーが出たら、migrationのファイルを編集して凌ぎます。

Illuminate\Database\QueryException : SQLSTATE[42000]: Syntax error or access violation: 1071 Specified key was too long; max key length is 767 bytes (SQL: alter table `jobs` add index `jobs_queue_index`(`queue`))

今回は、indexを作らないように修正して避ける

2019_09_22_215540_create_jobs_table.php
    public function up()
    {
        Schema::create('jobs', function (Blueprint $table) {
            $table->bigIncrements('id');

            //ここ編集
            //$table->string('queue')->index();
            $table->string('queue');

            $table->longText('payload');
            $table->unsignedTinyInteger('attempts');
            $table->unsignedInteger('reserved_at')->nullable();
            $table->unsignedInteger('available_at');
            $table->unsignedInteger('created_at');
        });
    }

###Jobクラス作成

php artisan make:job ProcessSlackMessage
Job created successfully.

Jobs/ProcessSlackMessage.phpが作られるので、Slackにメッセージを送るように実装をします。

Jobs/ProcessSlackMessage.php
<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

use Log;

class ProcessSlackMessage implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $slackMessage = null;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct(\App\SlackMessage $slackMessage)
    {
        //
        $this->slackMessage = $slackMessage;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        $id = $this->slackMessage->id;
        \Log::debug('handling job ' . $id);

        $guzzle = new \GuzzleHttp\Client();
        $option = [];
        $option['debug']=false;
        $option['verify']=false;
        $option['http_errors']=false;

        $message = 'ID:' . $id . "\n" . $this->slackMessage->text . 'への返信です';
        $option['json']=['text'=> $message ];

        $response = $guzzle->post($this->slackMessage->response_url, $option);
        $body = $response->getBody();
        $data = (String)$body;

        if( $response->getStatusCode() != 200 ){
            \Log::error($data);
        }else{
            $this->slackMessage->delete();
            \Log::debug('SlackMessage ID ' . $id .' was deleted.');
        }
    }
}

コンストラクタでSlackMessageを受けとるようにしておくと、Queueに入れるときに関連づけたSlackMessageのインスタンスが渡ってきます。あとはhandleメソッドで、SlackMessageに入っている情報でお仕事をし、SlackのAPIをコールすると返答ができます。

##3. SlackMessageのモデル作成
SlackのAPIで渡された値をそっくりそのまま入れておくテーブルを作ります

###migrationファイルを作成

php artisan make:migration create_slack_messages_table
Created Migration: 2019_09_22_221816_create_slack_messages_table

database/migrations/2019_09_22_221816_create_slack_messages_table.phpが作られる。upメソッドを以下のように実装して、テーブルが作られるようにする。

database/migrations/2019_09_22_221816_create_slack_messages_table.php
    public function up()
    {
        Schema::create('slack_messages', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('token');
            $table->string('team_id');
            $table->string('team_domain');
            $table->string('channel_id');
            $table->string('channel_name');
            $table->string('user_id');
            $table->string('user_name');
            $table->string('command');
            $table->string('text');
            $table->string('response_url');
            $table->string('trigger_id');
            $table->timestamps();
        });
    }

###SlackMessageモデルを作成

php artisan make:model SlackMessage
Model created successfully.

SlackMessageモデルクラスはfillableにカラム名を宣言するだけ

SlackMessage.php
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;

class SlackMessage extends Model
{
    protected $fillable = [
        'token', 'team_id', 'team_domain', 'channel_id', 'channel_name',
        'user_id','user_name','command','text','response_url','trigger_id'
    ];

}

##4.Slackから受け取るAPI

SlalackBotControllerにqueueメソッドを作ります。Slackからのリクエスト内容をSlackMessageテーブルに保存してJobへDispatchする。5行だけとは。

App/Http/Controllers/SlalackBotController.php

    public function queue(Request $request){
        $slackMessage = new \App\SlackMessage();
        $slackMessage->fill($request->all());
        $slackMessage->save();

        \App\Jobs\ProcessSlackMessage::dispatch($slackMessage);
        return response()->json(['text'=>'saved ' . $slackMessage->id]);
    }

APIとして呼び出せるよう設定

routes/api.php
Route::post('/queue', 'SlalackBotController@queue');

###Slack Appに登録
Appに新たにスラッシュコマンド/queueを登録します。もう手慣れた感じです。

image.png

##5.Herokuにデプロイ
###GIT

git add .
git commit -m "implements queue"
git push heroku master

###テーブルのmigration

heroku run php artisan migrate
Running php artisan migrate on ⬢ slalack-bot... up, run.5569 (Free)
**************************************
*     Application In Production!     *
**************************************

 Do you really wish to run this command? (yes/no) [no]:
 > yes

Migration table created successfully.
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (0 seconds)
Migrating: 2019_09_22_215540_create_jobs_table
Migrated:  2019_09_22_215540_create_jobs_table (0 seconds)
Migrating: 2019_09_22_221816_create_slack_messages_table
Migrated:  2019_09_22_221816_create_slack_messages_table (0.01 seconds)

###テーブル確認

Windowsからmysqlコマンドで接続

mysql -u (user) -p(pass) -h xxx-cleardb.net heroku_74cea6efa72d039

MySQL [heroku_74cea6efa72d039]> show tables;
+----------------------------------+
| Tables_in_heroku_74cea6efa72d039 |
+----------------------------------+
| failed_jobs                      |
| jobs                             |
| migrations                       |
| slack_messages                   |
+----------------------------------+
4 rows in set (0.18 sec)

MySQL [heroku_74cea6efa72d039]> desc jobs;
+--------------+---------------------+------+-----+---------+----------------+
| Field        | Type                | Null | Key | Default | Extra          |
+--------------+---------------------+------+-----+---------+----------------+
| id           | bigint(20) unsigned | NO   | PRI | NULL    | auto_increment |
| queue        | varchar(255)        | NO   |     | NULL    |                |
| payload      | longtext            | NO   |     | NULL    |                |
| attempts     | tinyint(3) unsigned | NO   |     | NULL    |                |
| reserved_at  | int(10) unsigned    | YES  |     | NULL    |                |
| available_at | int(10) unsigned    | NO   |     | NULL    |                |
| created_at   | int(10) unsigned    | NO   |     | NULL    |                |
+--------------+---------------------+------+-----+---------+----------------+
7 rows in set (0.18 sec)

MySQL [heroku_74cea6efa72d039]> desc failed_jobs;
+------------+---------------------+------+-----+-------------------+----------------+
| Field      | Type                | Null | Key | Default           | Extra          |
+------------+---------------------+------+-----+-------------------+----------------+
| id         | bigint(20) unsigned | NO   | PRI | NULL              | auto_increment |
| connection | text                | NO   |     | NULL              |                |
| queue      | text                | NO   |     | NULL              |                |
| payload    | longtext            | NO   |     | NULL              |                |
| exception  | longtext            | NO   |     | NULL              |                |
| failed_at  | timestamp           | NO   |     | CURRENT_TIMESTAMP |                |
+------------+---------------------+------+-----+-------------------+----------------+
6 rows in set (0.18 sec)

MySQL [heroku_74cea6efa72d039]> desc slack_messages;
+--------------+---------------------+------+-----+---------+----------------+
| Field        | Type                | Null | Key | Default | Extra          |
+--------------+---------------------+------+-----+---------+----------------+
| id           | bigint(20) unsigned | NO   | PRI | NULL    | auto_increment |
| token        | varchar(255)        | NO   |     | NULL    |                |
| team_id      | varchar(255)        | NO   |     | NULL    |                |
| team_domain  | varchar(255)        | NO   |     | NULL    |                |
| channel_id   | varchar(255)        | NO   |     | NULL    |                |
| channel_name | varchar(255)        | NO   |     | NULL    |                |
| user_id      | varchar(255)        | NO   |     | NULL    |                |
| user_name    | varchar(255)        | NO   |     | NULL    |                |
| command      | varchar(255)        | NO   |     | NULL    |                |
| text         | varchar(255)        | NO   |     | NULL    |                |
| response_url | varchar(255)        | NO   |     | NULL    |                |
| trigger_id   | varchar(255)        | NO   |     | NULL    |                |
| created_at   | timestamp           | YES  |     | NULL    |                |
| updated_at   | timestamp           | YES  |     | NULL    |                |
+--------------+---------------------+------+-----+---------+----------------+
14 rows in set (0.18 sec)

##6.いざ実行

image.png
image.png

id=42として登録したようだ。

###データ確認

slack_messagesテーブル

id=42を検索

MySQL [heroku_74cea6efa72d039]> select * from slack_messages where id=42\G
*************************** 1. row ***************************
          id: 42
       token: 1YxL3GCEx7R9CFgal0LvIDpK
     team_id: TDWRKFV0S
 team_domain: kanaxx
  channel_id: CDWNT347M
channel_name: channel2
     user_id: UDX2143CM
   user_name: kanaxx-user
     command: /queue
        text: キューへ入れー!!
response_url: https://hooks.slack.com/commands/TDWRKFV0S/756504982386/NMTbFokC89DGibmiqvTrxnaY
  trigger_id: 762861257665.472869539026.78157a4a62d1c306e2fd26f2c6c27257
  created_at: 2019-09-23 11:12:34
  updated_at: 2019-09-23 11:12:34
1 row in set (0.19 sec)

入ってる!

jobsテーブル

jobsテーブルも確認

MySQL [heroku_74cea6efa72d039]> select * from jobs\G
*************************** 1. row ***************************
          id: 32
       queue: default
     payload: {"displayName":"App\\Jobs\\ProcessSlackMessage","job":"Illuminate\\Queue\\CallQueuedHandler@call","maxTries":null,"delay":null,"timeout":null,"timeoutAt":null,"data":{"commandName":"App\\Jobs\\ProcessSlackMessage","command":"O:28:\"App\\Jobs\\ProcessSlackMessage\":9:{s:15:\"\u0000*\u0000slackMessage\";O:45:\"Illuminate\\Contracts\\Database\\ModelIdentifier\":4:{s:5:\"class\";s:16:\"App\\SlackMessage\";s:2:\"id\";i:42;s:9:\"relations\";a:0:{}s:10:\"connection\";s:5:\"mysql\";}s:6:\"\u0000*\u0000job\";N;s:10:\"connection\";N;s:5:\"queue\";N;s:15:\"chainConnection\";N;s:10:\"chainQueue\";N;s:5:\"delay\";N;s:10:\"middleware\";a:0:{}s:7:\"chained\";a:0:{}}"}}
    attempts: 0
 reserved_at: NULL
available_at: 1569204754
  created_at: 1569204754
1 row in set (0.19 sec)

よくわからないけど、1個だけ何か入ってる!

###Queueの処理
Queueに溜まっているJobを一つ処理してみます。artisanqueue:workで実行します。

heroku run php artisan  queue:work --once

Running php artisan queue:work --once on ⬢ slalack-bot... up, run.7817 (Free)
[2019-09-23 11:17:49][32] Processing: App\Jobs\ProcessSlackMessage
[2019-09-23 11:17:49][32] Processed:  App\Jobs\ProcessSlackMessage

無事終了。

うまくいくと、この後にSlackにメッセージが届きます。

image.png

届いた!:sunny:

:warning: 本番環境では、php artisan queue:workでバックグラウンド処理をするか、ジョブスケジューラなどから、定期的にartisanコマンドを叩く必要があります。
https://readouble.com/laravel/6.0/ja/queues.html#running-the-queue-worker

###データの再確認

QueueとSlackMessageを処理したあとのデータの確認

MySQL [heroku_74cea6efa72d039]> select * from slack_messages where id=42\G
Empty set (0.20 sec)

MySQL [heroku_74cea6efa72d039]> select * from jobs\G
Empty set (0.20 sec)

無事、両方とも空っぽですね。すばらしい

#まとめ
SlackのAPIの中で、Webhookで何かを一方的に送り付ける方法と、Slashコマンドできっかけをユーザからもらって反応する方法を試しました。まぁ、返答している内容がイマイチなので、もうちょっと楽しくしたいところです。本当は、SlackコマンドをもらってからHistoryを読み出したり、@コマンドで話しかけられたいのですが、それはもうちょっと勉強と実験が必要なようです。

Queueに入れて遅延実行がこんなに簡単にできるとは思わなかった。今回分かったことは、やっぱりLaravelが便利だということです。

#参考資料
Slack Botの種類と大まかな作り方
https://qiita.com/namutaka/items/233a83100c94af033575

Slack API - Slash Commands
https://api.slack.com/slash-commands

MySQLのインデックスサイズに767byteまでしかつかえない問題と対策
https://blog.e2info.co.jp/2017/04/17/mysql%E3%81%AE%E3%82%A4%E3%83%B3%E3%83%87%E3%83%83%E3%82%AF%E3%82%B9%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%AB767byte%E3%81%BE%E3%81%A7%E3%81%97%E3%81%8B%E3%81%A4%E3%81%8B%E3%81%88%E3%81%AA%E3%81%84/

24
20
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
24
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?