のむログ

技術メモ / 車 / 音楽 / 雑記 / etc...

【mac】Docker初心者が最短で「Django」 の開発環境作ってみた

f:id:nomunomu0504:20200128014626p:plain:w0

開発環境は持ち歩く時代

流石に物理的には持ち歩けませんが笑、今回は開発環境をポータブル化するべく「docker」を使って環境を作っていきたいと思います。docker初めて使う民なので、下調べ多めで行きます。

dockerで作成したい開発環境群はこちら。最低限これが作れて動けば問題なさそう。

・python3.5.x + pip3(主にDjango)

・ruby2.5.1 + Rails 5.x

・php 7.x

docker

dockerに関しての記事は、こちらの記事がとても参考になりました。ここまでまとまってるのすごい。 qiita.com

インストールに関しては、こちらの記事が参考になりました。 qiita.com

先にdockerとgithubを連携させておく

どうもdockerは「dockerfile」というファイルに書かれている情報を元に環境を構築していくようです。前回書いた「Brewfile」とかと同じ原理っぽそう。そのdockerfileをgithubで管理して、リポジトリにpushしたら、自動的にdocker-imageを作ってくれるように連携ができるらしい。 dockerfile更新したら、毎回手動で作り直す手間が面倒なので、先に設定しておきます。

参考になった記事はこちらです qiita.com

この設定(Automated build ってやつ)をしておけば、masterブランチが更新されたら自動的に最新のイメージが生成されるみたいでした。

dockerを管理するディレクトリ

今後、dockerで開発することを考えると様々な環境を作ることになりそうなので、dockerfileに関しては以下のようなディレクトリを構成してgit管理することにしました。

dockers/
├── python3.x(git管理)
  ├── Dockerfile
  ├── requirements.txt
  └── src    # ソース用のディレクトリ
    ├── source_dir_001
    └── source_dir_002
|
└── Rails 5.x(git管理)
  ├── Dockerfile
  ├── Gemfile
  └── src    # ソース用のディレクトリ

python3.x系のdocker-imageを作る

pythonのDocker-imageは公式の物がDocker-Hubにあるみたいですが、今回は勉強も兼ねてubuntu上に環境を構築していくことにします。

mkdir dockers ; cd dockers
mkdir python3.x ; cd python3.x ; git init

# ここで、さっき作成したdocker管理のリポジトリを紐づけておいてください

touch Dockerfile

そして、作成したDockerfileに以下を記述します。以下のDockerfileの内容を元にimageが生成されます。なので、ここに色々追記していくことで、カスタムimageを生成することができます。

FROM ubuntu:18.04
RUN apt-get -y update \
    && apt-get -y upgrade \
    && apt-get install -y locales curl python3-distutils python3-dev gcc make \
    && curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py \
    && python3 get-pip.py \
    && pip install -U pip \
    && mkdir /code \
    && rm -rf /var/lib/apt/lists/* \
    && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
ENV LANG en_US.utf8
WORKDIR /app
ADD requirements.txt /app
RUN pip install -r requirements.txt    # requirements.txtからパッケージのインストール

そしてDockerfileと同じ場所にrequirements.txtを作成します。必要なパッケージを書き込んだら、masterブランチにコミット/プッシュします。すると、自動的にDockerfile, requirements.txtを読み込みdocker-imageの生成が始まります。

image作成中のlogなどは、DockerHubから確認することができます。(これは失敗している例です) f:id:nomunomu0504:20200127132039p:plain

エラーを確認したければ、エラーがでてるbuildをクリックすると詳細がみれます。 f:id:nomunomu0504:20200127132339p:plain

実際にmac上で正常にdocker-buildができるかどうかを確認する場合には以下のコマンドを実行します。<DOCKER_IMAGE_NAME>には、自分でdocker-imageにつける名前を入力します。DockerHubで管理している場合には、DockerHubで作成したアカウント名/イメージ名にしておかないとpushすることができないので注意!!

docker build -t <DOCKER_IMAGE_NAME> .

色々処理が流れますが、最終的にSuccessfully tagged <DOCKER_IMAGE_NAME>:latestの表示が出ていれば成功しています。docker imagesコマンドを実行してimageが表示されていれば成功です。 f:id:nomunomu0504:20200127140157p:plain

docker-imageからコンテナを作る

さて、開発環境のベースが出来上がったわけですが、これをもとにプロジェクトの元になるコンテナを作っていきたいと思います。このコンテナを複数作れるところがいいところなんだと思います。同じような環境のプロジェクトがコマンド1つで作れるわけですから...。

cd dockers/python3.x
docker run --name python3x -p 8000:8000 -v $(pwd):/app -d -it nomunomu0504/docker-for-webscrap

このコマンドを実行することで、コンテナを作成できます。どのようなコンテナを作成しているかは以下の通りです。

docker run ; docker-imageからコンテナを生成し実行する
--name python3x ; コンテナの名前を"python3x"にする
-p 8000:8000 ; コンテナの8000番ポートをローカルマシンの8000番ポートにポートフォワードする
-v $(pwd):/app ; ローカルマシンのカレントディレクトリをコンテナの /app にバインドする
-d ; コンテナをバックグラウンドで実行する
-it nomunomu0504/docker-for-webscrap ; コンテナを生成する際に参照するdocker-imageを指定する

dockerプロセスが正常に動作していれば、開発環境構築は終了です。

docker ps
CONTAINER ID        IMAGE                              COMMAND             CREATED             STATUS              PORTS                    NAMES
4d3ca6aff93c        nomunomu0504/docker-for-webscrap   "/bin/bash"         5 minutes ago       Up 5 minutes        0.0.0.0:8000->8000/tcp   python3x

あとはコンテナ内に入って操作したりします。

docker exec -it python3x /bin/bash

を実行することで、コンテナ内のbashを操作することが可能。以下のようなディレクトリ構造になっていれば問題なし。

root@4d3ca6aff93c:/app# ls -la
total 12
drwxr-xr-x  5 root root  160 Jan 27 14:36 .
drwxr-xr-x  1 root root 4096 Jan 27 15:51 ..
-rw-r--r--  1 root root 6148 Jan 27 14:02 .DS_Store
drwxr-xr-x 32 root root 1024 Jan 27 13:37 source_dir_001
drwxr-xr-x 19 root root  608 Jan 27 03:52 source_dir_002

あとは通常通り、コンテナ内のdjangoでrunserverを 0.0.0.0:8000 に向けて起動すれば、ローカルマシンで localhost:8000 で通信することができる。

root@4d3ca6aff93c:/app# cd source_dir_001
root@4d3ca6aff93c:/app/source_dir_001# python3 manage.py runserver 0.0.0.0:8000 &
[1] 32
root@4d3ca6aff93c:/app/source_dir_001# Performing system checks...

System check identified no issues (0 silenced).
January 27, 2020 - 16:01:59
Django version 1.11.27, using settings 'source_dir_001.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
^M
root@4d3ca6aff93c:/app/source_dir_001#

コンテナ自体はバックグラウンドで起動しているため、コンソールを抜けてもサーバーが落ちることはない。

root@4d3ca6aff93c:/app/source_dir_001# exit

とりあえずこれで、dockerでDjangoの開発環境は構築できたので、めでたしめでたし。 f:id:nomunomu0504:20200128014348p:plain

【mac】開発環境構築を dotfile + mackup + git + ...etc を使って最短で仕上げる(その1)

f:id:nomunomu0504:20200127035424p:plain:w0

環境構築ほど面倒なものはない

macを買い換えたときに、開発環境をそっくりそのまま移行するのって、とても面倒なので、どうにか簡潔にできないかって思ったところ「dotfile」「mackup」などがあるらしく。それらの設定ファイルを「git」で管理して、いつでもどこでも同じような開発環境が構築できるようにします。

作業の流れ

homebrewなどを整理する

意外とインストールばっかりで整理しないmacportsやhomebrewを掃除します。これだけで数GBの容量が稼げる場合があるので、容量が足りない人は実行してみる価値ありです。

これらのサイトが参考になりました。 qiita.com qiita.com

masを導入する

github.com

App Storeからインストールする必要のあるアプリをCLIからインストールできる仕組み。 homebrew-bundle にも対応しているので、homebrew-bundle経由でインストールを実行することも可能です。 次に説明する homebrew-cask もだが、自分の使う全てのMacで必要なもの(エディタとかSNS, ブラウザとか)はこれで導入することにした。

brew install mas

mas listで現在のmacに入っているアプリでApp Store経由でインストールした物が表示される。

~ via Node v10.14.2
✦ ❯ mas list
1333542190 1Password 7 (7.4.1)
405399194 Kindle (1.26.1)
441258766 Magnet (2.4.5)
844918735 PicGIF Lite (2.0.8)
497799835 Xcode (11.3)
409183694 Keynote (9.2.1)
425424353 The Unarchiver (4.1.0)
823766827 OneDrive (19.192.0926)
803453959 Slack (4.2.0)
409201541 Pages (8.2.1)
539883307 LINE (5.20.1)
1176895641 Spark (2.5.1)
409203825 Numbers (6.2.1)
897118787 Shazam (2.9.0)

次に説明する homebrew-bundle と連携させることができるが、直接masを触ることはなく、インストールされていれば自動的にピックアップしてくれるようなので、masに関しては一旦これで終わり。

homebrew-bundleを導入する

github.com

Brewfileというファイルを作っておいてその中に必要なものを書いておいてbrew bundleコマンドで複数パッケージをまとめてインストールすることができるみたいです。しかも「mas」のリストを自動的にdumpしてくれるという優秀ぶり。

brewが入っていればbrew bundleと実行するだけで初期設定を行ってくれる。初回起動時は Brewfile が見つからないと言われるが、実行できる状態にはなっている。

~ via Node v10.14.2
✦ ❯ brew bundle
==> Tapping homebrew/bundle
Cloning into '/usr/local/Homebrew/Library/Taps/homebrew/homebrew-bundle'...
remote: Enumerating objects: 92, done.
remote: Counting objects: 100% (92/92), done.
remote: Compressing objects: 100% (82/82), done.
remote: Total 92 (delta 5), reused 32 (delta 4), pack-reused 0
Unpacking objects: 100% (92/92), done.
Tapped 1 command (190 files, 266.9KB).
Error: No Brewfile found

現在インストールされているパッケージをピックアップするためにdumpを指定して実行すると、ファイルとして書き出してくれるようだ。これをdotfilesで管理することで、どのmacでも同様のパッケージを維持することができる。ファイルの中身は後々編集するとして、dotfilesへファイルを移動して、シンボリックリンクを作ることを忘れないように。

brew bundle dump
mv Brewfile dotfiles/
ln -s dotfiles/Brewfile .

masが入っているので、dumpによって生成されたファイルを確認すると、ファイルの下の方にmasの行があることが確認できるはずだ。。。すごい。

:
:
mas "1Password 7", id: 1333542190
mas "Keynote", id: 409183694
mas "Kindle", id: 405399194
mas "LINE", id: 539883307
mas "Magnet", id: 441258766
mas "Numbers", id: 409203825
mas "OneDrive", id: 823766827
mas "Pages", id: 409201541
mas "PicGIF Lite", id: 844918735
mas "Shazam", id: 897118787
mas "Slack", id: 803453959
mas "Spark", id: 1176895641
mas "The Unarchiver", id: 425424353
mas "Xcode", id: 497799835

homebrew-caskを使うようにする

github.com

App Storeで配信されていないアプリ(.pkgなど外部からインストールするアプリ)をCLIでインストールするために使います。caskの利用設定はこちらの記事が参考になります。 qiita.com

自分のインストールしているアプリがcaskに対応していれば、caskからのインストールに置き換える方が今後簡単に環境を移行することができます。 (※ 基本的に最新バージョンしかインストールすることができないようです。ダウングレードインストールもできるようですが、骨が折れそうな作業をしている方が多数だったためオススメしません。その場合は手動でインストールする方がよさそうです。)

自分のインストールしたいアプリが対応しているか検索してみましょう(例: google chrome)

~ via Node v10.14.2 took 2s
✦ ❯ brew search chrome
==> Formulae
chrome-cli                                                          chrome-export

==> Casks
chrome-devtools                              epichrome                                    homebrew/cask-versions/google-chrome-canary
chrome-remote-desktop-host                   google-chrome                                homebrew/cask-versions/google-chrome-dev
chromedriver                                 mkchromecast
dmm-player-for-chrome                        homebrew/cask-versions/google-chrome-beta

こんな感じで検索結果が出てくるので「Casks」の部分に注目してください。「google-chrome」があると思うので、cask経由でインストールできそうです。caskでインストールするアプリの詳細をみたい場合にはinfoコマンドを利用します。

~ via Node v10.14.2
✦ ❯ brew cask info google-chrome
google-chrome: 79.0.3945.130 (auto_updates)
https://www.google.com/chrome/
Not installed
From: https://github.com/Homebrew/homebrew-cask/blob/master/Casks/google-chrome.rb
==> Name
Google Chrome
==> Artifacts
Google Chrome.app (App)

Casksにない場合はCLIインストールができないので、手動インストールになります。 (※ homebrew-caskにプルリク発行して追加してもらうっていうやり方もあるみたいです。) qiita.com

各環境ファイルを保存するディレクトリ作成

とりあえず「~/dotfiles」を管理するファイルを集めるディレクトリとしておきます。このディレクトリに「.zshrc」「.mackup.cfg」とかを入れて、それらのファイル群を「git」で管理します。gitでリポジトリを作成してリポジトリのリンクを取得しておいてください。

cd
mkdir dotfiles ; cd dotfiles ; git init
git push --set-upstream origin master
echo "# dotfiles" >> README.md ; git add README.md
git commit -m "first commit"
git remote add origin https://github.com/xxxxxxxxxxx/yyyyyyyyy.git
git push

これでgitにコミットできるようになりました。今後「dotfiles」内のファイルを変更した時にはコミットするようにしましょう。

.zshrc

「~/.zshrc」を「~/dotfiles/.zshrc」にコピーしてシンボリックリンクを作っておきます。

cd
mv .zshrc dotfiles/
ln -s dotfiles/.zshrc .

いままで「oh-my-zsh」を使ってたんですが、これが死ぬほど重くなって使えなかったので、これを機に使うのをやめました。結果、現在の.zshrcの中身はこんな感じ(※ aliasとかPATH設定は除いて記載してます)

# Tabで選択できるように
zstyle ':completion:*:default' menu select=2

# 補完で大文字にもマッチ
zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}'

# ファイル補完候補に色を付ける
zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS}

setopt auto_param_slash       # ディレクトリ名の補完で末尾の / を自動的に付加し、次の補完に備える
setopt mark_dirs              # ファイル名の展開でディレクトリにマッチした場合 末尾に / を付加
setopt auto_menu              # 補完キー連打で順に補完候補を自動で補完
setopt interactive_comments   # コマンドラインでも # 以降をコメントと見なす
setopt magic_equal_subst      # コマンドラインの引数で --prefix=/usr などの = 以降でも補完できる
setopt complete_in_word       # 語の途中でもカーソル位置で補完
setopt print_eight_bit        # 日本語ファイル名等8ビットを通す
setopt extended_history       # record timestamp of command in HISTFILE
setopt hist_expire_dups_first # delete duplicates first when HISTFILE size exceeds HISTSIZE
setopt share_history          # 他のターミナルとヒストリーを共有
setopt histignorealldups      # ヒストリーに重複を表示しない
setopt hist_save_no_dups      # 重複するコマンドが保存されるとき、古い方を削除する。
setopt extended_history       # $HISTFILEに時間も記録
setopt print_eight_bit        # 日本語ファイル名を表示可能にする
setopt hist_ignore_all_dups   # 同じコマンドをヒストリに残さない
setopt auto_cd                # ディレクトリ名だけでcdする
setopt no_beep                # ビープ音を消す

# コマンドを途中まで入力後、historyから絞り込み
autoload -Uz history-search-end
zle -N history-beginning-search-backward-end history-search-end
zle -N history-beginning-search-forward-end history-search-end

mackup

mackupを利用すれば、設定ファイルなどを特定のディレクトリに同期・特定のディレクトリからリストアすることができるので、各アプリケーションの環境設定を1から設定することなく移行することができるみたいです。

とりあえずインストールしておきます。

brew install mackup

このmackupの設定ファイルが「~/.mackup.cfg」を読み込むようになっているので、手動で「~/dotfiles/.mackup.cfg」を作ってシンボリックリンクを作っておきます。

cd dotfiles
touch .mackup.cfg
cd
ln -s dotfiles/.mackup.cfg .

以下の内容を.mackup.cfgに書き込みます

[storage]
engine = file_system
path = dotfiles

ここで注意が必要で「dotfiles」で「.bashrc」などを管理して、さらに別の場所にインストールしてシンボリックリンクを貼る、 みたいな設定をしてる場合にはちょっと注意が必要で、 mackupをしてしまうと一度mackupのシンボリックになり、uninstallすると 実体そのものが「~/.bashrc」に作られるので元のdotfilesとのリンクが切れてしまいます。

なので、dotfileで管理するものはバックアップしないように指定しておきます。mackup自身もdotfileで管理する場合にはmackupもignore指定してください。

mackup listを実行すれば、対象のアプリケーション名が表示されるので、そこからピックアップして設定してください。[applications_to_ignore]を追記することで、環境設定をバックアップしないアプリケーションを指定することができます。

[applications_to_ignore]
bash
vim
zsh
mackup
# 必要に応じて追記してください。

そしてmackup backupを実行します。自分の場合はAdobe Illustratorのバックアップでエラーがでていたので環境設定をバックアップしない方向性にするので.mackup.cfgに以下を追記します。

[applications_to_ignore]
illustrator

とりあえずここまでにして、時間見つけて「その2」を書き上げます...。

第5回高専キャリア全国大会でチーム優勝してきた

みなさん、お久しぶりです。

仕事、研究、イベントなどで時間がなく、まっっっっったくブログを更新できていませんでした。すいません🙇‍♂️

さて、毎度おなじみになりつつある【第5回 高専キャリア全国大会】に12/26, 27で参加してきました!

実は、第4回も参加しているんですが、ブログを書いていないことに気づいたのが、ほんのちょっと前という失態を犯しているので、今回はちゃんと早めに書きました。

高専キャリアとは

これは耳タコな程に紹介していますので、省略しておきますね。リンクは以下から。

kosen-career.tech

高専キャリア全国大会とは

これもリンクを紹介しておきます。(このリンク先のバナー画像で賞状を持っているピンク髪が僕です)

fifthzenkokutaikai.mystrikingly.com

第5回高専キャリア全国大会

今回のイベントページを載せておきますが、まじでゲストが凄すぎる。特に2日目!!(自分は予定の都合上、2日目午後からは不参加だったのですが、死ぬほど行きたかった....🙄) kosen-career-vol1.peatix.com

ポスター作ったりと、今回は運営側もちょこっとだけお手伝いしてました!

しかしながら、今回も相変わらずのタイムスケジュールで進んでいきました。

・1日目 f:id:nomunomu0504:20191230233654p:plain

・2日目 f:id:nomunomu0504:20191230233830p:plain

会場は「DMM.make AKIBA」さんをお借りしてやりました!
(写っているのは、高専キャリアCTOのかねしろさん) f:id:nomunomu0504:20191230235142p:plain

ドミネーター置いてあったり f:id:nomunomu0504:20191230235024j:plain

「シェアスペースAKIBA」を見学させてもらったり f:id:nomunomu0504:20191230234742j:plain f:id:nomunomu0504:20191230235008j:plain akiba.dmm-make.com

一般的なイベントでは考えられない環境とゲストでした!!(毎回そうなんだけど、今回は特にそう)

なにより、毎回楽しいのが、その場でチームを組み、提供されるお題に取り組むビジコン!! f:id:nomunomu0504:20191231000302j:plain f:id:nomunomu0504:20191231000048j:plain

今回は、高専キャリアが提供してくれたお題を解決するチームに、チームリーダー(プレゼンター)として参加しました!

f:id:nomunomu0504:20191231000410j:plain f:id:nomunomu0504:20191231000446j:plain f:id:nomunomu0504:20191231000535j:plain

数チームある中で、優勝することができてよかったです。チームメンバーに感謝!!🙇‍♂️🎉🎊

他のチームに関する情報は、僕がTogetterでまとめてありますので、是非見てください。 togetter.com

そして、このまとめの一部が編集部のオススメに選定されるという快挙?を上げましたので、是非とも見てください(2回目)

f:id:nomunomu0504:20191231000810p:plain

まとめ

今年は学生最後の年で、来年からは社会人です。これまで何回も高専キャリアのイベントや、他のイベントに参加してきましたが、高専キャリアのイベントほど楽しく身になるイベントはないと思います。もっと早くからあればよかったのにと、何回か思ったことがあるぐらいです。この先どうしようと思っている高専生は絶対に参加するべきです。これはメリットしかありません。交通費も全額支給してくれるという神のようなイベントだし、時間見つけてでも参加するべき。(次は2020.03とか言ってたので、ギリギリ学生のうちに参加できるかな....🤔)

もう今年も残すところ24時間です。今年お世話になったみなさん。ありがとうございました!

来年もよろしくおねがいします!!

Slack App + GAS で【学生団体】の作業効率を爆上げ【その2】

その1はこちらから

www.nomunomu0504.work

開発

④:取得した内容を元にFacebookページに投稿するための準備

ここでは、管理しているFacebookページにGASから投稿するために、FacebookAppを作成して、FacebookGraphAPIを経由します。が、このAPIにはトークンが必要になります。一般的なものは、トークンに有効期限があるので、無期限のトークンを発行して、GASで利用します。

FAcebook PageのページIDの取得

公式のこちらを参照してください。ページIDを取得したら、GASの PageId 変数を書き換えてください www.facebook.com

FacebookAppの作成

以下のサイトを参考にしています。 (※ 前提として、Facebookアカウントを所有していることが必須になります) developers.facebook.com wpxaf.com

まずはFacebook for developers の公式サイトへ飛びます developers.facebook.com

すると、以下のようなサイトが表示されると思います。 スクリーンショット 2019-12-13 0.14.50.png

このページの、右上にある【マイアプリ】を選択し、アプリの作成をクリックします。すると【新しいアプリIDを作成】というポップアップが表示されます。

表示名は、Facebookに投稿した際に、投稿者として表示される名前となりますので、あまり変な名前は付けないようにしましょう。連絡先メールアドレスは、個人アカウントに紐づけられているアドレスが自動的に補完されていると思います。 スクリーンショット 2019-12-13 0.17.30.png

作成が終わると、次のような画面が表示されていると思います。(アプリIDなどの情報は消しています) スクリーンショット 2019-12-13 0.19.42.png

左の【プロダクト +】をクリックして、【Facebookログイン】を設定します スクリーンショット 2019-12-13 0.29.53.png

このような画面になりますが、無視して、左側の【Facebookログイン】にある【設定】をクリックします スクリーンショット 2019-12-13 0.30.14.png

このような設定になっていれば、Facebookログインの部分は完了です。最後にFacebookアプリを開発モードではなく、ライブモードにします。 スクリーンショット 2019-12-13 0.33.06.png

ライブモードにするためには【設定】→【ベーシック】の中にある【プライバシーポリシーのURL】を設定する必要があります。個人で作成しているブログなどがあれば、ブログのアドレスを入力します。なければ、TwitterのプロファイルURLでも問題ありません。

入力が終了したら、右上にある【ステータス:開発中】と書かれている左側のオフとなっている、ボタンをオンにして、ライブモードにします。これでFacebookアプリの設定は終わりです。次にアクセストークンの取得を行います。 スクリーンショット 2019-12-13 1.05.00.png

また、ここに表示されている【アプリID】【app secret】はGASに入力する部分がありますので、自分のものと置き換えてください。

アクセストークンの取得

以下のサイトにアクセスしてトークンなどの取得を行います

developers.facebook.com

アクセスすると以下のような画面になっていると思います。(選択されている項目は異なるはずです) スクリーンショット 2019-12-13 0.35.30.png

右側パネルの【ユーザーまたはページ】のプルダウンを選択して【ページアクセストークンの取得】をクリックします スクリーンショット 2019-12-13 0.37.27.png

ログインを要求されるので、ログインすると、現在作成しているアプリでアクセスすることのできるページ一覧が表示されます。実際にGASから投稿したいページを選んでください。 スクリーンショット 2019-12-13 0.38.31.png

選択して、次へをクリックします。すると、次のようなログインレビューの申請が出てくるので、チェックが入っていることを確認して、完了をクリックします。 スクリーンショット 2019-12-13 0.40.22.png

すると、先ほどのプルダウンにアクセスを許可したページが表示されているはずのなので、選択します。 スクリーンショット 2019-12-13 0.41.24.png

そして、右側の【許可を追加】と書かれている部分に、以下を入力して、アクセス許可をしてください。途中まで入力すると、自動的に下に該当する物が出てくるので、それを選択してもOKです。

manage_pages
pages_show_list
publish_pages
ads_management
ads_read
business_management
publish_to_groups

以下のように追加をしたら、青色ボタンの【Get Access Token】をクリックして、ポップアップで表示されるログインレビューでOKをクリックします。 スクリーンショット 2019-12-13 0.44.36.png

すると、先ほどまで、緑だった選択項目が黒表記になっていることがわかります。これでトークンを発行する準備が整いました。次に【アクセストークン】と表示されているテキストボックス左側の青色のインフォメーションボタンをクリックします。 スクリーンショット 2019-12-13 0.45.36.png

すると、以下のような表示が出てきますので、【アプリ】【ユーザー】【ページ】【スコープ】の部分を確認して、間違いがなければ【アクセストークンツールで開く】をクリックします。 スクリーンショット 2019-12-13 0.47.31.png

以下のようなページが表示されると思うので、ページを下にスクロールして【アクセストークンの延長】をクリックします。すると、このように「この長期アクセストークンは期限切れになりません」と表示され、専用のトークンが表示されます。右側の【デバッグ】をクリックします。 スクリーンショット 2019-12-13 0.56.32.png

同じような画面が表示されますが、有効期限の欄が【受け取らない】になっていると思います。これで無期限のアクセストークンを取得することができました。画面上部のトークンをコピーして、前回作成したGASのスクリプトに書き移します。 スクリーンショット 2019-12-13 0.57.42.png

④, ⑤, ⑥ の解説

さて、ここからコードの解説になります。

var BaseUrl = "https://graph.facebook.com/v5.0/";
var PageId = "ooooooooooooooo";
var AppID = "xxxxxxxxxxxxx";
var AppSecret = "zzzzzzzzzzzzzzzzz";
var PageAccessToken = "[CHANGE_YOUR_ACCESS_TOKEN]";
var PostContentUrl = BaseUrl + PageId + "/feed?access_token=" + PageAccessToken;

var PostContentBody = "【ブログを更新しました!】\n\n";
PostContentBody += json.submission.facebook_content + "\n\n";
PostContentBody += json.submission.blog_url;

var PostContents = {
  "method" : "post",
  "contentType" : "application/json",
  "payload": JSON.stringify({
    "message": PostContentBody,
    "link": json.submission.blog_url
  })
};

//<<<<<< Posting
var PostingComment = {
  "attachments": [{
        "color": "#36a64f",
        "fields": [
          {
            "title": "Facebookへ投稿中...",
            "value": "",
            "short": false
          },
        ]
    }
  ]
};  

var options = {
  "method" : "post",
  "contentType" : "application/json",
  "payload" : JSON.stringify(PostingComment)
};

var response = UrlFetchApp.fetch(SlackChannelPostUrl, options);
//>>>>>> Posting

var postResText = "";
var postResColor = "";
try {
  UrlFetchApp.fetch(PostContentUrl, PostContents);
  postRes = "成功しました。";
  postResColor = "#36a64f";
} catch (e) {
  postRes = "失敗しました( " + e + " )";
  postResColor = "#f24646";
}
//<<<<<< Post Facebook

var payload = {
  "attachments": [{
        "color": postResColor,
        "fields": [
          {
            "title": "投稿結果",
            "value": postRes,
            "short": false
          },
        ]
    }
  ]
};  

var options = {
  "method" : "post",
  "contentType" : "application/json",
  "payload" : JSON.stringify(payload)
};

response = UrlFetchApp.fetch(SlackChannelPostUrl, options);
return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(ContentService.MimeType.JSON);

④:取得した内容を元にFacebookページに投稿

var BaseUrl = "https://graph.facebook.com/v5.0/";
var PageId = "ooooooooooooooo";
var AppID = "xxxxxxxxxxxxx";
var AppSecret = "zzzzzzzzzzzzzzzzz";
var PageAccessToken = "[CHANGE_YOUR_ACCESS_TOKEN]";
var PostContentUrl = BaseUrl + PageId + "/feed?access_token=" + PageAccessToken;

var PostContentBody = "【ブログを更新しました!】\n\n";
PostContentBody += json.submission.facebook_content + "\n\n";
PostContentBody += json.submission.blog_url;

var PostContents = {
  "method" : "post",
  "contentType" : "application/json",
  "payload": JSON.stringify({
    "message": PostContentBody,
    "link": json.submission.blog_url
  })
};

var PostingComment = {
  "attachments": [{
        "color": "#36a64f",
        "fields": [
          {
            "title": "Facebookへ投稿中...",
            "value": "",
            "short": false
          },
        ]
    }
  ]
};  

var options = {
  "method" : "post",
  "contentType" : "application/json",
  "payload" : JSON.stringify(PostingComment)
};

var response = UrlFetchApp.fetch(SlackChannelPostUrl, options);

前回と、今回で作成したAppの情報を元に変数を書き換えます。PostContentBodyには、前回作成したポップアップから取得した内容を利用します。Facebookには、動画や画像も投稿することができます。詳しくは Facebook Graph Api 公式リファレンス を参照してください。

今回は、メッセージ本体とブログなどのWebリンクのみを扱うとします。ブログなどのリンクは link に設定するようにしましょう。そうしないと OGP が効かないのです...。

また、投稿中かどうかがSlackからは分からないため、Facebookへ投稿するコンテンツの生成が終わったら、Slackのチャンネルに対して、投稿中である旨の投稿を行うようにしています。

⑤:投稿結果の取得

var postResText = "";
var postResColor = "";
try {
  UrlFetchApp.fetch(PostContentUrl, PostContents);
  postRes = "成功しました。";
  postResColor = "#36a64f";
} catch (e) {
  postRes = "失敗しました( " + e + " )";
  postResColor = "#f24646";
}

投稿を UrlFetchApp.fetch(PostContentUrl, PostContents); で行います。 try - catchで記述することで、投稿時にエラーが発生したらキャッチすることができます。投稿結果を格納するための変数を try - catch の外に記載して、それぞれで代入する値を変更するようにしています。

⑥:投稿結果をSlackチャンネルに投稿

var payload = {
  "attachments": [{
        "color": postResColor,
        "fields": [
          {
            "title": "投稿結果",
            "value": postRes,
            "short": false
          },
        ]
    }
  ]
};  

var options = {
  "method" : "post",
  "contentType" : "application/json",
  "payload" : JSON.stringify(payload)
};

response = UrlFetchApp.fetch(SlackChannelPostUrl, options);
return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(ContentService.MimeType.JSON);

上記の結果を元にして、Slackのチャンネルに投稿しています。これで、一連の設定が終わっているため、SlackからFacebookページへ投稿が行うことができます。

おわりに

何気なく、めんどうだけどやってる作業って、実は効率化できたり、技術で解決できることがありますよね。でも、学生だからとか技術がないからとかで、ほっておくんじゃなくて、調べて何事もやってみるってことですね。効率化最高!!

終わり!!

Slack App + GAS で【学生団体】の作業効率を爆上げ【その1】

今年イチ!お勧めしたいテクニック by ゆめみ feat.やめ太郎 Advent Calendar 2019 8日目

ふとみたら、投稿されてない日程があったので、投稿しました😇 最近Google App Scriptsを使ったサーバーレスのシステムが多いので、作ってみました。

ゆめみさんのアドカレは、実は去年も書いていてAmazonギフトをいただいているので、何かとご縁がありそうです。その時の記事リンクを貼っておきます。ぜひ、見てください🙇‍♂️

RaspberryPi + OBD2 で取得した車両情報を解析してみる(オフィスや自宅を快適にするIoT byゆめみ③ Advent Calendar 2018 7日目)

RaspberryPi 3 Model B+ でlircを使ってリモコン化する(その1)(オフィスや自宅を快適にするIoT byゆめみ③ Advent Calendar 2018 8日目)

RaspberryPi 3 Model B+ でlircを使ってリモコン化する(その2)(オフィスや自宅を快適にするIoT byゆめみ③ Advent Calendar 2018 9日目)

2019/12/13 追記

続編

www.nomunomu0504.work

TL;DR

問題は身近なところから(前置き)

所属しているでは学生団体( 学生団体with: http://with-sabae.com/ )は、連絡手段としてLINE以外にも、Slackを導入してます。

毎週1回会議を開いていて、各会議に関するブログ( https://ameblo.jp/gakuren/ )を書いているんですが、ブログを投稿すると、SNS連携しているTwitterには自動でブログを書いたことを投稿してくれますが、Facebookに関しては思うようにできません。

Facebookに投稿はできるのですが、学生団体のFacebookページ( https://www.facebook.com/sabaewith/ )に投稿したいのです。ブログのSNS連携では個人のFB投稿として、扱われてしまうため無意味だったという...🤔

さらに条件として、Facebookページへ誰でも投稿できると、ちょっとマズいので権限付与済の人しか投稿できないようにしてます。そのため、権限のない人が投稿しようとすると、権限のある人に頼まないといけなくなります(とても面倒)

じゃあSlackからできればいいじゃん

となると、Slackからブログ投稿に関することだけ投稿できるようになればいいと思い立ったわけです。Slackから「ブログURL」「Facebookに投稿する内容」を記入すると、Facebookページに投稿してくれるという仕組みを作りました。

処理フロー

flow.001.png

①:入力するためのダイアログ取得コマンドを実行
②:入力ダイアログの項目を取得して表示
③:ダイアログに入力された内容を送信
④:取得した内容を元にFacebookページに投稿
⑤:投稿結果の取得
⑥:投稿結果をSlackチャンネルに投稿

参考にしたサイト/記事一覧(とても参考になりました)

qiita.com

qiita.com

qiita.com

www.indetail.co.jp

開発準備

以下のサイトがとても分かりやすいので、僕の方では割愛します。

qiita.com

qiita.com

事前準備として様々な設定をしておきます。 まずは、Google App Scriptのファイルを作成して、「公開」→「Webアプリケーションとして導入」で、scriptのアドレスを取得して、メモしておきます(↓こんなやつ) image.png

作成したScriptファイルに全ての処理を記載して行くことになります。

次に、SlackAppを作成して「Incoming Webhooks」「Incoming Components」「Bot User」を設定してください。 「Incoming Webhooks」設定時のURLは、ScriptファイルのURL、channelには処理の実行結果を出力したいチャンネルを選択してください。

「OAuth & Permissions」は次の設定をしておきます。
「bot」「chat:write:bot」「chat:write:user」「commands」「incoming-webhook」

ブログ投稿用の入力ダイアログを表示するために、自作のSlash commandを作成します。 Slackでは「Slash Command」を自作することができるAppが公開されています。ここから、Slash Commandを設定してください。

image.png

「Command」に入力した文字列(ここでは"/blog_to_facebook")は、後からスクリプトを書くときに使うので、メモしましょう。「Request URL」には、さっき取得したScriptファイルのアドレスを記入してください。

開発

全てのコードを先に記載しておきます。下記に個別で解説を入れていきます。

システムコード全体

/**
 * Slackからのリクエストを受ける処理
 */
function doPost(e) {
  
  // SlackApp VerificationToken
  var slackAppToken = '[CHANGE_YOUR_SLACK_APP_TOKEN]';
  // Slack OAuthKey
  var OAuthKey = '[CHANGE_YOUR_SLACK_OAUTH_KEY(ex. xoxp-...)]';
  
  if (e.parameter.command == "/blog_to_facebook")
  {
    if (e.parameter.token != slackAppToken)
    {
      throw new Error("blog_to_facebook token error");
    }
    var trigger_id = e.parameter.trigger_id;
    var message_ts = e.parameter.message_ts;
    var slackUrl = "https://slack.com/api/dialog.open";
    
    var headers = {
      "Authorization": "Bearer " + OAuthKey
    };
    
    var payload = {
      "token": OAuthKey,
      "trigger_id": trigger_id,
      "dialog": JSON.stringify({
        "callback_id": "blog_to_facebook",
        "notify_on_cancel": true,
        "state": "Limo",
        "title": "Facebookに投稿する",
        "submit_label": "投稿する",
        "elements": [
          {
            "type": "text",
            "label": "ブログのURLを入力",
            "name": "blog_url"
          },
          {
            "type": "textarea",
            "label": "Facebookに投稿する内容を入力",
            "name": "facebook_content"
          }
        ]
      })
    }
    
    var options = {
      'method' : 'post',
      "headers": headers,
      'payload': payload
    }; 
    UrlFetchApp.fetch(slackUrl, options);
    return ContentService.createTextOutput();
  }
  else
  {
    var json = JSON.parse(decodeURIComponent(e.parameter.payload));
    if (json.token != slackAppToken)
    {
      throw new Error("incoming web hooks token error");
    }
    
    var SlackChannelPostUrl = 'https://hooks.slack.com/services/xxxxxx/xxxxxxx/XXxxXXXXXxXXxXXxx';
    
    //>>>>>> Before Posting to Facebook
    var payload = {
      "attachments": [{
            "color": "#36a64f",
            "pretext": "以下の内容でFacebookに投稿します。",
            "fields": [
              {
                "title": "blogのURL",
                "value": json.submission.blog_url,
                "short": false
              },
              {
                "title": "Facebook投稿内容",
                "value": json.submission.facebook_content,
                "short": false
              }
            ]
        }
      ]
    };  

    var options = {
      "method" : "post",
      "contentType" : "application/json",
      "payload" : JSON.stringify(payload)
    };

    UrlFetchApp.fetch(SlackChannelPostUrl, options);
    //<<<<<< Before Posting to Facebook
    
    //>>>>>> Post Facebook
    var BaseUrl = "https://graph.facebook.com/v5.0/";
    var PageId = "ooooooooooooooo";
    var AppID = "xxxxxxxxxxxxx";
    var AppSecret = "zzzzzzzzzzzzzzzzz";
    var PageAccessToken = "[CHANGE_YOUR_ACCESS_TOKEN]";
    var PostContentUrl = BaseUrl + PageId + "/feed?access_token=" + PageAccessToken;
    
    var PostContentBody = "【ブログを更新しました!】\n\n";
    PostContentBody += json.submission.facebook_content + "\n\n";
    PostContentBody += json.submission.blog_url;
    
    var PostContents = {
      "method" : "post",
      "contentType" : "application/json",
      "payload": JSON.stringify({
        "message": PostContentBody,
        "link": json.submission.blog_url
      })
    };
    
    //<<<<<< Posting
    var PostingComment = {
      "attachments": [{
            "color": "#36a64f",
            "fields": [
              {
                "title": "Facebookへ投稿中...",
                "value": "",
                "short": false
              },
            ]
        }
      ]
    };  

    var options = {
      "method" : "post",
      "contentType" : "application/json",
      "payload" : JSON.stringify(PostingComment)
    };

    var response = UrlFetchApp.fetch(SlackChannelPostUrl, options);
    //>>>>>> Posting
    
    var postResText = "";
    var postResColor = "";
    try {
      UrlFetchApp.fetch(PostContentUrl, PostContents);
      postRes = "成功しました。";
      postResColor = "#36a64f";
    } catch (e) {
      postRes = "失敗しました( " + e + " )";
      postResColor = "#f24646";
    }
    //<<<<<< Post Facebook
    
    var payload = {
      "attachments": [{
            "color": postResColor,
            "fields": [
              {
                "title": "投稿結果",
                "value": postRes,
                "short": false
              },
            ]
        }
      ]
    };  

    var options = {
      "method" : "post",
      "contentType" : "application/json",
      "payload" : JSON.stringify(payload)
    };

    response = UrlFetchApp.fetch(SlackChannelPostUrl, options);
    return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(ContentService.MimeType.JSON);
  }
}

①:入力ダイアログを生成するための処理を作成

Slack Appからデータを受信する

function doPost(e)
{
  // 省略
}

取得したスクリプトのアドレスにPostメソッドでアクセスされると、doPostが呼び出されるようになります。受け取ったデータは、引数の e に格納されています。

送信元をトークンを用いて認証/実行種類の特定

if (e.parameter.command == "/blog_to_facebook") // こっちの処理はダイアログ表示用
{
  if (e.parameter.token != slackAppToken)
  {
    throw new Error("blog_to_facebook token error");
  }
  // 中略
}
else  // こっちの処理はダイアログから受け取る用
{
  // 中略
}

Slash Commandが実行されたことにより、スクリプトがコールされたことを e.parameter.command に格納されている文字列を使って確かめています。

表示するダイアログデータの設定

var payload = {
  "token": OAuthKey,
  "trigger_id": trigger_id,
  "dialog": JSON.stringify({
    "callback_id": "blog_to_facebook",
    "notify_on_cancel": true,
    "state": "Limo",
    "title": "Facebookに投稿する",
    "submit_label": "投稿する",
    "elements": [
      {
        "type": "text",
        "label": "ブログのURLを入力",
        "name": "blog_url"
      },
      {
        "type": "textarea",
        "label": "Facebookに投稿する内容を入力",
        "name": "facebook_content"
      }
    ]
  })
}

このコードにより、作成されるダイアログはこんな感じ スクリーンショット 2019-12-09 23.04.18.png

elements内にダイアログに表示したいパーツを記載していきます。Slackの公式サイトにさまざまな種類のフォームが載ってます。 api.slack.com

あと、どんな感じで表示されるか試すことのできる、公式サイトあるので、リンク貼っておきます。 api.slack.com

②:入力ダイアログの項目を取得して表示

チャンネルへダイアログデータを送信

var headers = {
  "Authorization": "Bearer " + OAuthKey
};

var options = {
  'method' : 'post',
  "headers": headers,
  'payload': payload
}; 

UrlFetchApp.fetch(slackUrl, options);
return ContentService.createTextOutput();

作成したダイアログデータをJSON形式に変換してからチャンネルへ送信します。最後に必ず ContentService.createTextOutput(); を実行してください。正しくダイアログが表示されない場合があります。

③:ダイアログに入力された内容をGASに送信

さて、①, ②でダイアログを表示することはできるようになりました。次はダイアログに入力されたデータを取得して、GAS上で処理できるようにします。①の部分に記載してあるように、ダイアログから受け取る方を作っていきます。

var json = JSON.parse(decodeURIComponent(e.parameter.payload));
if (json.token != slackAppToken)
{
  throw new Error("incoming web hooks token error");
}

ダイアログから送信されたデータは e.parameter.payload に格納されています。が、中身はJSON形式の文字列で書かれているため、JSON.parseを使ってオブジェクト化します。その後、Tokenによるチェックを行ったあと、確認のために入力された内容をSlackのチャンネルに投稿します。

//>>>>>> Before Posting to Facebook
var SlackChannelPostUrl = 'https://hooks.slack.com/services/xxxxxx/xxxxxxx/XXxxXXXXXxXXxXXxx';
var payload = {
  "attachments": [{
    "color": "#36a64f",
    "pretext": "以下の内容でFacebookに投稿します。",
    "fields": [
      {
        "title": "blogのURL",
        "value": json.submission.blog_url,
        "short": false
      },
      {
        "title": "Facebook投稿内容",
        "value": json.submission.facebook_content,
        "short": false
      }
    ]
  }]
};  

var options = {
  "method" : "post",
  "contentType" : "application/json",
  "payload" : JSON.stringify(payload)
};

UrlFetchApp.fetch(SlackChannelPostUrl, options);
//<<<<<< Before Posting to Facebook

こんな感じでSlackチャンネルには表示されます。

スクリーンショット 2019-12-10 0.29.38.png

次回につづく

次は実際にFacebookページに投稿するための準備をするんですが、これがまた曲者で結構時間かかりました ^^;

www.nomunomu0504.work

学生団体withのご紹介

【学生団体with】

・ホームページ with-sabae.com

・Twitter twitter.com

・Facebook www.facebook.com

・Instagram instagram.com

結局Slackって何が出来るの?LINEと何が違うの?

What is Slack?

IT系の人や普段から使い慣れてる人ならば、このお題に答えられると思いますが、そこまで馴染みのない人には "Slackって普通のチャットアプリと何が違うんだ" って感じると思います。
最近良き耳にする 『ビジネスチャット』というものに分類されるのが Slack です。今回は分かりやすくするために、LINE と比較してみて、何が違うのかを見てみます。

LINEなどのSNSと何が違うのか

送信したメッセージを編集することができる

基本的にLINEでミスメッセージを送信した場合、24時間以内であれば『送信取り消し機能』を使って無かったことにできるが、規定時間を超えた場合はもう一度送信し直す必要があります。 しかし、Slackでは自分が送信したメッセージであれば過去に遡ってでも、送信したメッセージを編集することができるんです(もちろん、編集したら分かるような仕組みになってます。こんな感じで)

f:id:nomunomu0504:20190522010136p:plain

これは僕が送信したメッセージですが、sin(x)をx→∞とした時に-1となるのは limsup(上極限) ではなく liminf(下極限) です。なので、下極限に編集します。

f:id:nomunomu0504:20190522010133p:plain

すると、送信されたメッセージが編集され、右側に(編集済み)が表示されるようになりました。

相手が入力中なのかが分かる

FacebookのメッセンジャーやiPhoneのiMessengeには似たような機能が搭載されていますが、Slackにも小さくですが搭載されてます。

f:id:nomunomu0504:20190522011441p:plain

こんな感じで相手が入力中なのかどうかが分かるので、送信のバッティングが起こることが少なくなりそう。(これが必要かどうかは知らないけど😇)

送られてきたメッセージに対してリアクションができる

SlackにはLINEのような『既読通知』が存在しません。そのため、送ったメッセージが読まれたのか読まれてないのかは、送った側からは確認しようがないのです。
そのため、メッセージに対してリアクションをすることで開封しましたよって知らせてあげます。リアクションってこんな感じ

f:id:nomunomu0504:20190522011937p:plain

このメッセージの下の部分にある、クラッカーや文字スタンプのことです。これをつけてあげることで、読んだか読んでないかが一目瞭然。(割とこの行動は必須条件に近いかも...

あと、このリアクションはメッセージとして送ることも可能です。

f:id:nomunomu0504:20190522012301p:plain

このスタンプの送り方としては

f:id:nomunomu0504:20190522012754p:plain

メッセージを入力するところにある、この顔マークを押すか、

:に続けて、その絵文字に紐づけられたエイリアスを入力すると打てますが、顔マークから呼び出した方が早いかと(覚えてれば別ですけど) f:id:nomunomu0504:20190522012604p:plain

メッセージに装飾ができる

LINEとかだと『この部分に下線引きたい』『この部分は太文字にしたい』とかができない(はず)ですが、Slackではこんな感じですることができます。

例えば『太文字』にしたい場合は『*』で文字を括ります。 f:id:nomunomu0504:20190522015909p:plainf:id:nomunomu0504:20190522015912p:plain

さらに『長文を送りたいけど、文字の大きさや感覚的に見えづらいからみやすくしたい』
そんな時には『```』でメッセージを括ります。

PCで『`』を出したい場合、基本的には 『shift + @』 で出せます。

iPhoneだと、英語キーボードの 『'』を長押ししたときに出てくるポップアップの一番左側です。

f:id:nomunomu0504:20190522014250p:plain

実際に、長めで見辛い文章を普通に送ってみると、こんな感じ

f:id:nomunomu0504:20190522013422p:plain 文字がずれてたり、行間狭かったりします

ですが、『```』で括ってから送信してあげると... f:id:nomunomu0504:20190522013417p:plain f:id:nomunomu0504:20190522013432p:plain

こんな風に、見やすく表示してくれます。ソースコードやログを送ったりするのが便利です。(インデントも保持してくれる。タブも認識してくれる

文章全体じゃなくて、部分的にしたい!って時には『`』を1個だけ使って括ります。するとこんな感じで強調されます。 f:id:nomunomu0504:20190522014736p:plain

もし反映されず、以下のように表示された場合『`』の前後に半角空白を入れてください。 f:id:nomunomu0504:20190522014949p:plain

引用符を使うことができる

最近のメールでも『>』が先頭についてると色が付いていたり、文字が薄くなったりしているのを見たことないですか?

それと同じような感じで Slack でも『引用符』をつけることができます。Slackでの引用符は2種類あって『>』と『>>>』です。

『>』の方は、1行だけ引用符をつけます。 f:id:nomunomu0504:20190522015433p:plainf:id:nomunomu0504:20190522015435p:plain

『>>>』の場合は、それをつけた行以降全てに引用符がつきます。 f:id:nomunomu0504:20190522015602p:plain f:id:nomunomu0504:20190522015559p:plain

この『>>>』では、途中で引用符を止めることができないので注意が必要です。

まとめ

というような感じで、Slackには業務を効率化したり伝達ミスをなくすような工夫があり、ビジネスツールとしてはもってこいということなのです。まだまだたくさんの機能がありますが、とりあえずこれだけ知っておけば問題ない👍っていうのをピックアップしました。

f:id:nomunomu0504:20190522005039j:plain:w0