レスポンス・ヘッダフィールドからnginxのバージョンを消す...から学べること

f:id:kumamon_engineer:20200928214516p:plain

 

こんにちは、もうすっかり秋ですね。エンジニアの三宅です。

秋といえばnginxですね。

 今回は、nginxのバージョンがHTTPのレスポンス・ヘッダフィールドにdefaultで設定されるという噂を耳にしたので確認します。

 

 

nginxのバージョン設定

nginxに特に設定をしていないとHTTPのレスポンスヘッダーにサーバーアプリケーションのバージョンが表示されてしまいます。

f:id:kumamon_engineer:20200928212914p:plain

あ、見えちゃってる…!
 
レスポンスヘッダーにアプリケーションのバージョンがあると何が良くないかというのは、HTTPのRFCには以下の記述があります。

tools.ietf.org

 
      Note: Revealing the specific software version of the server might
      allow the server machine to become more vulnerable to attacks
      against software that is known to contain security holes. Server
      implementors are encouraged to make this field a configurable
      option.
 
      注意: サーバーの特定のソフトウェアバージョンを明らかにすると、
      以下のようになる場合があります。
      サーバマシンが攻撃に対してより脆弱になることを可能にします。
      セキュリティホールが含まれていることが知られているソフトウェア
       に対してサーバ実装者は、このフィールドを設定可能なオプションを
       使用します。

 

特定のバージョンで動いている事を公開していることは、セキュリティホールが含まれたバージョンを使っていた場合、攻撃対象になってしまいます…ということですね。

まぁ、見せて得することはあまり無さそうですね。

 

nginxの方ではconfig設定で以下を定義する事で消すことが出来るそうです。

 

Syntax: server_tokens on | off | build | string;
Default: server_tokens on;
Context: http, server, location
 
Enables or disables emitting nginx version on error pages and in the “Server” response header field.

conf用ファイル
http {
    server_tokens off;
    ...
}
 
これでnginxを再起動
 
はい、消えました!

f:id:kumamon_engineer:20200928213019p:plain

ありがとうございました!お疲れさまでした!
 
 
 
 
 
fin...?
 
 
 
 
さて、これってnginxのソフトウェア上では何が起きてるのでしょうか..。
今回はnginxのコードを読んで理解を深めていこうと思います。
 

nginxのコードを読む

nginxのコードはGithubで見れます!ただここで管理されている訳ではなく、
公式のコードを1時間おきに更新しているミラーです。
参照するには十分ですね。
 
さて、nginxはC言語で書かれていますので、コードを読むには最低限のC言語の知識は必要になります。

main関数のループ

まずはmain関数を確認します!
 
ありました、ありました!
C言語のソフトウェアはエントリーポイントとしてmain関数が呼ばれます。
この関数抜けるとソフトウェアが終了してしまうため、どこかに無限ループがあるはずです。
 
ここでプロセス管理してそうですね
 
if (ngx_process == NGX_PROCESS_SINGLE) {
 
ngx_single_process_cycle(cycle);
 
 
} else {
 
ngx_master_process_cycle(cycle);
}
 
さて、 ngx_master_process_cycle 関数を見てみます。
C言語では関数を呼び出すためにはheaderファイルで定義したものをincludeすることで出来ます。ngx_master_process_cycleが定義されているのはngx_process_cycle.hで、これはngx_core.hでincludeされており、nginx.cではngx_core.hをincludeしています。
 
あれngx_process_cycle.hが2つあるな…

f:id:kumamon_engineer:20200928214007p:plain

ngx_process_cycle.hファイルはOSがunix系かwindows系かで分かれるようですね。OSによって中の実装が変わるシステムコール系の処理をラッピングする為に、同名でOS毎に分けて書かれているんですね

 
どっちが呼び出されるか…
UNIX_DEPSとして定義】
UNIX_DEPS="$CORE_DEPS $EVENT_DEPS \
            src/os/unix/ngx_time.h \
            src/os/unix/ngx_errno.h \
            src/os/unix/ngx_alloc.h \
            src/os/unix/ngx_files.h \
            src/os/unix/ngx_channel.h \
            src/os/unix/ngx_shmem.h \
            src/os/unix/ngx_process.h \
            src/os/unix/ngx_setaffinity.h \
            src/os/unix/ngx_setproctitle.h \
            src/os/unix/ngx_atomic.h \
            src/os/unix/ngx_gcc_atomic_x86.h \
            src/os/unix/ngx_thread.h \
            src/os/unix/ngx_socket.h \
            src/os/unix/ngx_os.h \
            src/os/unix/ngx_user.h \
            src/os/unix/ngx_dlopen.h \
            src/os/unix/ngx_process_cycle.h"
【WIN32_DEPSとして定義】
https://github.com/nginx/nginx/blob/38196b8ba63f04830db0b8793eca738e162c6d8e/auto/sources#L228
WIN32_DEPS="$CORE_DEPS $EVENT_DEPS \
            src/os/win32/ngx_win32_config.h \
            src/os/win32/ngx_time.h \
            src/os/win32/ngx_errno.h \
            src/os/win32/ngx_alloc.h \
            src/os/win32/ngx_files.h \
            src/os/win32/ngx_shmem.h \
            src/os/win32/ngx_process.h \
            src/os/win32/ngx_atomic.h \
            src/os/win32/ngx_thread.h \
            src/os/win32/ngx_socket.h \
            src/os/win32/ngx_os.h \
            src/os/win32/ngx_user.h \
            src/os/win32/ngx_dlopen.h \
            src/os/win32/ngx_process_cycle.h"

この設定はmakeファイルにあるはずです。

C言語はmakeコマンドでコンパイルする場合、makeファイルにコンパイル設定を定義します。
 
ここの配下でOS毎の定義をまとめています

f:id:kumamon_engineer:20200929092257p:plain

OS毎に色々やってますね
case "$NGX_PLATFORM" in
FreeBSD:*)   . auto/os/freebsd   ;;    Linux:*)   . auto/os/linux   ;;   SunOS:*)   . auto/os/solaris   ;; Darwin:*)   . auto/os/darwin   ;;   win32)   . auto/os/win32   ;; 

 

windowsはここでWIN32_DEPSを設定

https://github.com/nginx/nginx/blob/c85d6fec217d1b17291779542de20ad77ae68661/auto/os/win32#L9

CORE_DEPS="$WIN32_DEPS"

LinuxはここでUNIX_DEPSを設定

https://github.com/nginx/nginx/blob/c85d6fec217d1b17291779542de20ad77ae68661/auto/os/linux#L9

CORE_DEPS="$UNIX_DEPS $LINUX_DEPS"

Solarisだったらここ(LinuxSolarisUNIX系なのでUNIX_DEPSは共通ですね)

https://github.com/nginx/nginx/blob/c85d6fec217d1b17291779542de20ad77ae68661/auto/os/solaris#L9

CORE_DEPS="$UNIX_DEPS $SOLARIS_DEPS"

 makeファイルでCORE_DEPSを利用

https://github.com/nginx/nginx/blob/c85d6fec217d1b17291779542de20ad77ae68661/auto/make#L56

ngx_deps=`echo $CORE_DEPS $NGX_AUTO_CONFIG_H $NGX_PCH \
| sed -e "s/ *\([^ ][^ ]*\)/$ngx_regex_cont\1/g" \
-e "s/\//$ngx_regex_dirsep/g"`
 

…ということで、ngx_master_process_cycleはUNIX系かWindows系かで別れていますよ…と。

 

差分を見ると関数の中身、初っ端から全然違いますね。。

f:id:kumamon_engineer:20200929083312p:plain

windows(左側)はHANDLEとか使ってますね。

windowsでスレッドやイベント生成した時に返ってくるポインタの型ですね。

windowsプログラムの基本ですね、懐かしい

(そして、今はweb上にこんなドキュメントがあるのか...羨ましい)

docs.microsoft.com

 

そうそうmain関数の中にある無限ループを探すのが目的でした…

 

ありました!このfor文の中をグルグル回ってるんですね、nginxは..

https://github.com/nginx/nginx/blob/9c3ac44de268f0cf057bc5dd67929e74c9bbc3e3/src/os/unix/ngx_process_cycle.c#L139

for ( ;; ) {
 ・・・
}

本筋を見失ってしまいましたが、この無限ループ内でworkerプロセスのイベント監視して非同期にhttp通信を管理しているんですね。。。

 

アーキテクチャはこの図がわかりやすいです!

f:id:kumamon_engineer:20200929055307p:plain

 responseに設定しているのは何処

では今回のレスポンス・ヘッダーにサーバーアプリケーションのバージョンを設定している部分はソフトウェアではどうしているのでしょうか。

  

まずは、NGINXのバージョンを定義している部分を見つけました

https://github.com/nginx/nginx/blob/554916301c424f02b1cabc073845b64f8681099b/src/core/nginx.h#L14

#define NGINX_VERSION "1.19.3"
#define NGINX_VER "nginx/" NGINX_VERSION

恐らく、この定義を元に設定している部分があるのでしょう

 

ありますね!ngx_http_header_filter_module.cファイルですね。ヘッダーを組み立てる処理が書かれていそうです。 

https://github.com/nginx/nginx/blob/4bf4650f2f10f7bbacfe7a33da744f18951d416d/src/http/ngx_http_header_filter_module.c#L50

static u_char ngx_http_server_string[] = "Server: nginx" CRLF;
static u_char ngx_http_server_full_string[] = "Server: " NGINX_VER CRLF;
static u_char ngx_http_server_build_string[] = "Server: " NGINX_VER_BUILD CRLF;

ngx_http_server_full_stringがversion付きの文字列ですね

ここで使ってますね

https://github.com/nginx/nginx/blob/4bf4650f2f10f7bbacfe7a33da744f18951d416d/src/http/ngx_http_header_filter_module.c#L452

        if (clcf->server_tokens == NGX_HTTP_SERVER_TOKENS_ON) {
            p = ngx_http_server_full_string;
            len = sizeof(ngx_http_server_full_string) - 1;

        } else if (clcf->server_tokens == NGX_HTTP_SERVER_TOKENS_BUILD) {
            p = ngx_http_server_build_string;
            len = sizeof(ngx_http_server_build_string) - 1;

        } else {
            p = ngx_http_server_string;
            len = sizeof(ngx_http_server_string) - 1;
        }

        b->last = ngx_cpymem(b->last, p, len);

if (clcf->server_tokens == NGX_HTTP_SERVER_TOKENS_ON) { で判定されてますね。b->lastにcopyされてます。

ちなみにngx_cpymemmemcpyをラッピングしたマクロになります。
https://github.com/nginx/nginx/blob/6c3838f9ed45f5c2aa6a971a0da3cb6ffe45b61e/src/core/ngx_string.h#L107

#define ngx_cpymem(dst, src, n)   (((u_char *) memcpy(dst, src, n)) + (n))

clcf はなんでしょう。
https://github.com/nginx/nginx/blob/4bf4650f2f10f7bbacfe7a33da744f18951d416d/src/http/ngx_http_header_filter_module.c#L280

clcf = ngx_http_get_module_loc_conf(r, ngx_http_core_module);

関数名を見る限り、ローカルの設定のようです。

構造体の定義struct ngx_http_core_loc_conf_sはここにあります。
設定を集約した定義のようです。https://github.com/nginx/nginx/blob/b82c08f6102d65a5e5902e6fa85082e184a75003/src/http/ngx_http_core_module.h#L397

struct ngx_http_core_loc_conf_s {
  ...
    ngx_uint_t    server_tokens;           /* server_tokens */
  ...
}

初期値はONなのか? 

https://github.com/nginx/nginx/blob/b82c08f6102d65a5e5902e6fa85082e184a75003/src/http/ngx_http_core_module.c#L3794

ngx_conf_merge_uint_value(conf->server_tokens, prev->server_tokens,
                              NGX_HTTP_SERVER_TOKENS_ON);

// 定義
#define ngx_conf_merge_value(conf, prev, default)                            \
    if (conf == NGX_CONF_UNSET) {                                            \
        conf = (prev == NGX_CONF_UNSET) ? default : prev;                    \
    }

確かにdefaultはONで設定されていました!

offをconfigに設定するとNGX_HTTP_SERVER_TOKENS_OFFが設定されることも確認できます。

https://github.com/nginx/nginx/blob/b82c08f6102d65a5e5902e6fa85082e184a75003/src/http/ngx_http_core_module.c#L125

static ngx_conf_enum_t  ngx_http_core_server_tokens[] = {
    { ngx_string("off"), NGX_HTTP_SERVER_TOKENS_OFF },
    { ngx_string("on"), NGX_HTTP_SERVER_TOKENS_ON },
    { ngx_string("build"), NGX_HTTP_SERVER_TOKENS_BUILD },
    { ngx_null_string, 0 }
};

ということで...

http {
    server_tokens off;
    ...
}

を設定すると、condig設定でoff→NGX_HTTP_SERVER_TOKENS_OFFに置き換わり、header_filter_moduleの分岐にてどの文字列をcopyするかが決まります。 

response送信

最後、送信するところですね

https://github.com/nginx/nginx/blob/b82c08f6102d65a5e5902e6fa85082e184a75003/src/http/ngx_http_core_module.c#L1733

ngx_int_t
ngx_http_send_response(ngx_http_request_t *r, ngx_uint_t status,
    ngx_str_t *ct, ngx_http_complex_value_t *cv)
{
   ...
        rc = ngx_http_send_header(r);
   ...
}

// ヘッダー生成
ngx_int_t
ngx_http_send_header(ngx_http_request_t *r)
{
    if (r->post_action) {
        return NGX_OK;
    }

    if (r->header_sent) {
        ngx_log_error(NGX_LOG_ALERT, r->connection->log, 0,
                      "header already sent");
        return NGX_ERROR;
    }

    if (r->err_status) {
        r->headers_out.status = r->err_status;
        r->headers_out.status_line.len = 0;
    }

    return ngx_http_top_header_filter(r);
}

ngx_http_send_response でresponseを生成しています。
ngx_http_send_headerでヘッダー部分を作ります。
その中でngx_http_top_header_filterを呼び出しています。

ngx_http_top_header_filterは以下で設定されます。

https://github.com/nginx/nginx/blob/4bf4650f2f10f7bbacfe7a33da744f18951d416d/src/http/ngx_http_header_filter_module.c#L625

static ngx_int_t
ngx_http_header_filter_init(ngx_conf_t *cf)
{
    ngx_http_top_header_filter = ngx_http_header_filter;

    return NGX_OK;
}

ここで ngx_http_top_header_filter に ngx_http_header_filter を設定しています。ngx_http_header_filterは上記のngx_cpymemで設定してる関数になります。
ここでヘッダーの設定部分と実際にresponseを生成している関数が繋がります。

 

おわりに

という事でserver_tokens off;の設定から、nginxが実際に設定する所を読んでみました。オープンソースは気になったら実際にコードを読み、どんな挙動をしているか明確にわかるのが良いですね。また、そのアーキテクチャ含め、コードから色んな事を学べることが大きな利点だと思います。

また機会があればこういう記事を書いていこうと思います。

【テレワーク推進!!】OpenTokを使ってビデオチャットを作ってみた

社内ブログ

こんにちは。ばしおです。

まだまだコロナは落ち着きませんね。
昨今のコロナ情勢によりオンラインで打ち合わせをする企業も増えたのはないでしょうか?
企業によってはセキュリティ云々でZoomやWherebyが使えないとかなんとか。

導入できない?じゃ、作るしか無いよね?

というわけで今回は
OpenTokというサービスを使って、Wherebyみたいなサービスを作りたいと思います。

OpenTokとは?

www.vonagebusiness.jp

VONAGE(旧Tokbox)社が提供しているWebRTCプラットフォームです。 WebRTCの難しいところをサクッと実装できてしまいます。 また、録画(アーカイブ)することもでき、クラウドストレージに保存も可能です。 他にもリアルタイムチャットやブロードキャスト配信も出来るようです。

導入

今回、サーバーサイドではOpenTokが提供しているPHPSDKを使用していきたいと思います。

フレームワークSlimを使います。(Laravel重たすぎ...)
Slimは1ファイルで完結してしまうくらいシンプルなフレームワークです。

今回は、一般的なMVCフレームワークに近くなるよう少しカスタマイズしたものを作成したので、これをひな形として使用していきます。

https://github.com/bashi4/slim-template.git

OpenTokのAPIKey, SecretKey作成

OpenTokに登録していきましょう。
2ページ目で業種を入力するページがありますが、何も入力せずNextでスキップしても大丈夫です。
Eメールの認証まで完了すれば登録は完了です。

f:id:bashioo:20200916092348p:plain

ログインをすると、AdminNameを求められます。
AdminNameのグループの中に、いくつもプロジェクトを作っていく感じになるので、サービス名などをつけるのがよいでしょう。 今回は「TechBlog」としました。

f:id:bashioo:20200916091427p:plain

次にプロジェクトを作成します。
このプロジェクトがAPIKeyの単位となります。こちらは開発やテストなど環境ごとに分けると良いかと思います。今回は「local_video_chat」としました。

f:id:bashioo:20200916091455p:plain

完了するとAPI KeyとSecret Keyが発行されます。忘れずにメモしておきましょう。

f:id:bashioo:20200916091645p:plain

アプリケーションの実装

雛形をクローンしてきて、トップページを見れる状態にしてください。

ReadMe通りにうまく行けば、Hello画面が表示されると思います。

https://github.com/bashi4/slim-template#how-to-use

トークルームの作成

トークルーム作成の実装をしていきましょう。

流れとしては、下記になります

  1. トップページの「トークルームを作る」ボタンを押下
  2. indexメソッドのpostアクションでルームに対して一意となるセッションIDを作る
  3. セッションIDを画面に表示する

ではやっていきましょう。

まずは、トップページに「トークルームを作る」ボタンを作ります。

views/index.blade.php
- <h1>Hello</h1>

+ @if($url)
+ <a href="{{ $url }}">{{ $url }}</a>
+ @endif
+ <form action="/" method="post">
+     <button type="submit" class="btn btn-primary">トークルームを作る</button>
+ </form>

get/postでアクションでControllerのindexメソッドを実行するようにrootファイルも修正します。

config/route.php
- $app->get('/', 'App\Controller\Controller:index');

+ $app->map(['get', 'post'], '/', 'App\Controller\Controller:index');

次に、OpenTokServiceクラスを作ります。

srcディレクトリ配下にServiceディレクトリを作成し、下記の内容でOpenTokService.phpを作成してください。

src/Service/OpenTokService.php
<?php
namespace App\Service;

use OpenTok\OpenTok;
use OpenTok\MediaMode;
use OpenTok\ArchiveMode;
use OpenTok\Role;

class OpenTokService
{
    /** @var OpenTok */
    protected $opentok;
    
     /** @var string */
    protected $key;

    public function __construct()
    {
        $this->key = env('OPENTOK_KEY');
        $this->opentok = new OpenTok($this->key, env('OPENTOK_SECRET'));
    }

    /**
     * セッションIDを返却
     * 
     * @return string
     */
    public function createSession()
    {
        return $this->opentok->createSession([
            // 'archiveMode' => ArchiveMode::ALWAYS, // 常に録画
            'mediaMode'   => MediaMode::ROUTED // Routedモード(アーカイブのときはRoutedモード必須)
        ]);
    }
}

作成したOpenTokServiceクラスをコンテナ経由で呼べるように ServiceProvider修正します。

src/ServiceProvider.php
public function register(\Pimple\Container $container)
{          
+     $container['videoChatService'] = new \App\Service\OpenTokService();
    return $container;
}

最後に、Controllerのindexメソッドを修正します

src/Controller/Controller.php
public function index(Request $request, Response $response)
{
     
-     return $this->app->view->render($response, 'index');

+     $url = null;
+     if ($request->isPost()) {
+         $sessionId = $this->app->videoChatService->createSession();
+         $url =  $request->getUri()->getBaseUrl() . "/rooms/{$sessionId}";
+     }
+     return $this->app->view->render($response, 'index', [
+         'url' => $url,
+     ]);

}

実行してみましょう。

f:id:bashioo:20200916093012p:plain

f:id:bashioo:20200916093026p:plain

セッションIDが作成されURLが表示できました!

ビデオチャット実装する

トークルームのURLの生成まではできました。

次はURLからトークルームに遷移し、ビデオチャットが開始される部分を作成します。

  1. /rooms/:sessionIdでroomsメソッドを実行し、tokenを発行する
  2. tokenをjavascriptのパラメータとして渡し、ビデオチャットを開始する

では実装していきましょう。

rootにアクションを追加します。

config/route.php
+ $app->get('/rooms/:sessionId', 'App\Controller\Controller:room');

OpenTokServiceクラスに下記を追加します。

src/Service/OpenTokService.php
public function getToken($sessionId)
{
    return $this->opentok->generateToken($sessionId, [
        'role' => Role::MODERATOR, // ロールをモデレータに設定
        'expireTime' => time()+(60 * 60), // 有効期限を1時間に設定
    ]);
}

public function getKey()
{
    return $this->key;
}

Controllerにroomメソッドを追加します

src/Controller/Controller.php
public function room(Request $request, Response $response, $params)
{
    $sessionId = $params['sessionId'];

    $token = $this->app->videoChatService->getToken($sessionId);

    $key = $this->app->videoChatService->getKey();

    $jsonData = [
        'sessionId' => $sessionId,
        'token' => $token,
        'key' => $key,
    ];

    return $this->app->view->render($response, 'room', [
        'params' => json_encode($jsonData, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
    ]);
}

viewsにroom.blade.phpを作成し下記をコピペしてください。

views/room.blade.php
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
        integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
        integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
        integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
</head>
<body>
    <div class="container-fluid my-4">
        <div class="row">
            <div class="col-6" style="height: 80vh;">
                <div id="subscriber"></div>
            </div>
            <div class="col-6" style="height: 80vh;">
                 <div id="publisher"></div>
            </div>
        </div>
    </div>

    <!-- ▼ ここから ▼ -->
    <script src="https://static.opentok.com/v2/js/opentok.min.js"></script>
    <script>
        let params = {!! $params !!};

        initializeSession();

        function handleError(error) {
            if (error) alert(error.message)
        }

        function initializeSession() {
            const session = OT.initSession(params.key, params.sessionId);

            const publisher = OT.initPublisher("publisher", {
                    targetElement: "publisher",
                    width: "100%",
                    height: "100%",
                }, handleError);

            session.on("streamCreated", function(event) {
                session.subscribe(event.stream, "subscriber", {
                    targetElement: "subscriber",
                    width: "100%",
                    height: "100%",
                }, handleError);
            });

            session.on("sessionDisconnected", function sessionDisconnected(event) {
                console.error("You were disconnected from the session.", event.reason);
            });

            session.connect(params.token, function(error) {
                error ? handleError(error) : session.publish(publisher, handleError);
            });
        }
    </script>
    
</body>
</html>

これで完了です!

先程、生成したURLをクリックしてみましょう

f:id:bashioo:20200916100041p:plain

ブラウザで別タブを開き、さらに生成したURLにアクセスしてみましょう

f:id:bashioo:20200916100107p:plain

1対1のビデオチャットができました!

リアルタイムチャット

リアルタイムチャットも簡単に実装できます。 下記の内容で room.blade.phpを上書き、画面をリロードしてみてください。

views/room.blade.php
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
        integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
        integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
        integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>


    <style>
        .message-container {
            width: 100%;
            height: 100vh;
            position: relative;
        }
        .message-form {
            width: 100%;
            position: absolute;
            bottom: 15px;
        }
        .message-list {
            height: 90vh;
            overflow-y: scroll;
        }
        .message-list-item {
            padding: .5rem .75rem;
            border: 1px solid #ececec;
            border-radius: .25rem;
        }
        .message-list-item.mine {
            background-color: #17a2b8;
            color: #fff;
            margin-left: 4rem;
        }
        .message-list-item.theirs {
            background-color: #f8f9fa;
            margin-right: 4rem;
        }
    </style>
</head>
<body class="bg-light">
    <div class="container-fluid">
        <div class="row">
            <div class="col-9 d-flex">
                <div id="publisher"></div>
                <div id="subscriber"></div>
            </div>
            <div class="col-3 bg-white">
                <div class="message-container">
                    <div class="message-list"></div>
                    <form class="message-form">
                        <div class="input-group">
                            <input type="text" class="form-control">
                            <div class="input-group-append">
                                <button class="btn btn-primary" type="submit">送信</button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>

    <script src="https://static.opentok.com/v2/js/opentok.min.js"></script>
    <script>
        let params = {!! $params !!};

        initializeSession();

        function handleError(error) {
            if (error) alert(error.message)
        }

        function initializeSession() {
            const session = OT.initSession(params.key, params.sessionId);

            // オプションは下記参考
            // https://tokbox.com/developer/sdks/js/reference/OT.html#initPublisher
            const publisher = OT.initPublisher("publisher", {
                    insertMode: "append",
                    width: "360px",
                    height: "240px",
                }, handleError);

            session.on("streamCreated", function(event) {
                session.subscribe(event.stream, "subscriber", {
                    insertMode: "append",
                    width: "360px",
                    height: "240px",
                }, handleError);
            });

            session.on("sessionDisconnected", function sessionDisconnected(event) {
                console.error("You were disconnected from the session.", event.reason);
            });


            session.connect(params.token, function(error) {
                error ? handleError(error) : session.publish(publisher, handleError);
            });

            // ▼ チャット用に追加した部分 ▼ 
            const form = document.querySelector('.message-form');
            const formTxt = document.querySelector('.message-form input');

            form.addEventListener('submit', function(event) {
                event.preventDefault();
                session.signal({type: 'msg', data: formTxt.value}, function(error) {});
            });

            session.on('signal:msg', function(event) {
                var msg = document.createElement('p');
                msg.innerText = event.data;
                msg.className = event.from.connectionId === session.connection.connectionId
                    ? 'message-list-item mine' : 'message-list-item theirs';
                const messagelist = document.querySelector('.message-list');
                messagelist.appendChild(msg);
                msg.scrollIntoView();
            });
            // ▲ チャット用に追加した部分 ▲ 
        }
    </script>
</body>
</html>

まとめ

いかがだったでしょうか? Opentokを使って、ビデオチャットが簡単に実装できました。
ビデオチャットを使ったサービスの開発に対し、かなり技術的ハードルが下がったんじゃないでしょうか?

最後に、今回動作確認で使ったソースコードを置いときます。 github.com

【話題沸騰中】軽量でお洒落なコマンドプロンプト拡張ライブラリ「Starship」を導入した話

こんにちは。 分からないことに出会ったら、ワクワクする inteeの 中野 です。

会社から新型MacBook Proを支給して貰ったので、 ターミナルのプロンプトをカスタマイズした話をしようと思います!

Starshipとは

f:id:channaka0531:20200825141702p:plain

Starshipとは、ターミナルのプロンプトをカスタマイズするプラグインです。 日本語翻訳が充実しており、可愛いデザインに惚れて、導入を決めました!

Starshipの特徴
  • Rust言語で開発されており、動作が高速
  • 設定ファイルで細かくカスタマイズ可能
  • bash / Zsh / Fish で使用可能
  • Git管理のプロジェクトであれば、ブランチ名や言語バージョン等が表示可能
  • ポップなカラースキーム・絵文字がカワイイ(超重要)

個人的に辛い開発時にエモい気持ちになりながら、開発できる体験を重要視しております🙇‍♀️

https://raw.githubusercontent.com/starship/starship/master/media/demo.gif

インストール

今回はMacで導入した為、Homebrew(パッケージマネージャー)経由でインストールしました。 他の導入の仕方に関しては、日本語翻訳された公式サイト を見た方が良いです!

brew install starship

次に初期化のためのスクリプトをシェルの設定ファイルに追加します。

Bashの場合、 ~/.bashrc に

# ~/.bashrc
eval "$(starship init bash)"

Zshの場合、 ~/.zshrc に

# ~/.zshrc
eval "$(starship init zsh)"

Fishの場合、 ~/.config/fish/config.fish に

# ~/.config/fish/config.fish
starship init fish | source

上記の設定を反映後、ターミナルが下記画像になっていたら、インストール完了です!

f:id:channaka0531:20200825112852p:plain

カスタマイズ

各項目の表示を設定したいので、~/.config/starship.toml ファイルを作成します。

$ touch ~/.config/starship.toml

各項目の設定内容については、 日本語翻訳された公式サイト を確認するのが手っ取り早いです💪

個人設定

f:id:channaka0531:20200825140624p:plain

まだまだカスタマイズ途中ではありますが、設定ファイルの内容をアップロードしておきます! 皆さんもStarshipを導入して、カワイイ開発体験を試してみてください^^

自然言語処理を使って弊社エンジニアメンバーの特徴を言語化してみた

こんにちは! Hajimariの新卒2年目の栗岡です!

普段は受託開発を行いながら、採用担当をしています! 開発と採用の2足草鞋は内定者インターンから含めるとかれこれ2年になります。

今回は、プログラミングの力を使って、エンジニア採用でちょっと課題と感じていることを少しでも解消してみようと思います! (結論、そんなに簡単じゃなかったです、、、)

感じている課題

弊社のエンジニア組織/メンバーの特徴を言語化できていない故に、3年後・5年後にエンジニア組織の強みが分からなくなりそう、、、、

今は、会社自体が若く、小規模であるため、業務の幅の広さや裁量権の大きさを打ち出せるけれど、もし数年後従業員数が100人を超えたときに、どんな風に候補者さんに弊社のエンジニア組織を魅力的に思ってもらえる のか結構悩んでいます。

やること

組織は人の集まりなので、とりあえず、エンジニア組織のメンバーの特徴を改めて理解し、言語化したいと思います。 ざっくり言うと、「弊社のエンジニア組織の特徴としては、〜な人と・・・な人にカテゴライズできて〜」ってことを言えるようになりたい。

具体的な方法

wantedlyの入社ブログから、それぞれメンバーの特徴を抽出・分類する。 ちゃんと考えて書いた言葉は、人の特徴を表すので、とりあえずやってみるにはちょうど良いかなと思いました。

実装方法としては、自然言語処理でおなじみtf-idfでブログからメンバーの特徴を抽出しました。

www.sejuku.net

いろんな人が書いた文章を比較した際に、その人っぽい単語・言葉を見つけるって感じですかね。

自然言語処理や統計分析は大学の頃にほんの少しだけ触った程度ですので、初心者中の初心者です

やってみた

普段データ分析で遊ぶときは、jupyter notebookを使うのですが、今回、色々と探していると、Streamlitと言うデータ分析の結果を凄くwebっぽく表示してくれるフレームワーク があったので使ってみました。 なので、言語もpythonです。 www.streamlit.io

形態素解析 ブログの内容を単語ごとに区切る

前段階として、アナログにwantedlyのブログをコピペし、メンバーごとのテキストファイルを作りました。

 # nameにはメンバーの名前がloopで入ります
 def sprit_sentence_to_word(name):
  # 辞書登録(mecab-ipadic-neologd
  option = "-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd"
  tagger = MeCab.Tagger("-Ochasen " + option)
  # アナログに作ったテキストデータリンク
  open_link = "/text_data/wantedly/" + name + ".txt"
  f = open(open_link)
  text = f.read()
  f.close()
  # パースする 
  res = tagger.parseToNode(text)
  #  ストップワードリスト取得
  path = "stop_words.txt"
  #  日本語のストップワードを取得し、リスト化します
  stop_words = create_stopwords(path)
  words = []
  while res:
        # ストップワード除去
         if res.surface not in stop_words:
              word = res.surface
              part_of_speech = res.feature.split(",")[0]
              sub_part_of_speech = res.feature.split(",")[1]
              if part_of_speech in ['名詞', '動詞', '形容詞']:
                  if sub_part_of_speech not in ['空白', '*']:
                      words.append(word)
           res = res.next
      return words
形態素解析した結果をファイル保存
  data = sprit_sentence_to_word(name)
     open_link = "/output/wantedly/" + name + ".txt"
      with open(open_link, 'w', encoding='utf-8') as f:
         f.write(' '.join(data))


 # こんな感じ単語区切りになり保存されます
  石川県 生まれ 札幌 住み 現在 千葉県 住ん 5歳 高校 卒業 サッカー 漬け 日々 大好き サッカー 辞め 
  休日 1 ミリ 出 多い ん インドア ん 笑 大学 青山学院大学 進学
TF-IDFの計算
 def analysis(name):
  contents = []
  eng_names = []
  open_link = "/output/wantedly/" + name + ".txt"
  
  # ファイルをオープンする
  test_data = open(open_link, "r")
  # データを書き込む
  contents.append(test_data.read())
  # ファイルクローズ
  test_data.close()
  # エンジニアの名前も詰める
  eng_names.append(name)

  # データフレームにする
  df = pd.DataFrame({'name': eng_names,
                    'text': contents})
   
  # TF-IDFの計算
  # TF-IDFの上位200単語・単語頻出度10%~90%を取得
  tfidf_vectorizer = TfidfVectorizer(use_idf=True, min_df=0.1, max_df=0.90, max_features=200)
 
  # Tfidf値を取得
  tfidf_matrix = tfidf_vectorizer.fit_transform(df['text'])
  
  # index 順の単語リスト
  terms = tfidf_vectorizer.get_feature_names()
  tfidfs = tfidf_matrix.toarray()

  # TF-IDF表示
  show_tfidf(terms, tfidfs, eng_names)
 
 def show_tfidf(terms, tfidfs, eng_names):
  df = pd.DataFrame(
    tfidfs,
    columns=terms,
    index=eng_names)
 #表で出力
 st.write(df)

こんな感じに出力されました。 数値が1に近いほど特徴的な単語になります。200単語あるので全てお見せすることは難しいですが、、 f:id:marron-web-engineer:20200811091345p:plain

ちょっと考察ができそうな単語をいくつかみてみます。

f:id:marron-web-engineer:20200811234346p:plain

事業という単語からは、エンジニアとして事業を作っていきたいと普段から発信している中野が高い数値となっています。

仲間という単語では、CTOの柳澤と新卒1年目の保坂に高い数値が出てます。 仲間(一緒に働く人)が軸に入っている候補者の方との面談にアサインしたいです。

f:id:marron-web-engineer:20200811234401p:plain 成長・技術という単語では、板橋に高い数値が出ています。 ブログを読んでもらってから面接を行い、入社からの成長を技術周りの事を交えて話してもらえると候補者体験として効果的かもしれません。

それぞれの単語をみていくだけでもたくさんの考察や気づきがあるのですが、やりたいことはエンジニアメンバーを近い特徴同士でグルーピングすることなので、次元圧縮を行いメンバーそれぞれの特徴をまとめて数値にします。 今回は、次元圧縮をちょっと話題になっているらしいのでt-SNEを使ってみます。 詳しくはこちらをご覧ください。

qiita.com

次元圧縮
 # tsne次元圧縮 
 # tfidf_matrix:上部で表示したtfidfデータ
 # df.name:エンジニアメンバー名前データ
 do_tsne(tfidf_matrix, df.name)

 def do_tsne(tfidf_matrix, name):
  # 2次元に変換 回転数1200
  tsne = TSNE(n_components=2, perplexity=50, n_iter=1200)
  tsne_tfidf = tsne.fit_transform(tfidf_matrix)

  # データフレームにセット
  df_tsne = pd.DataFrame(tsne.embedding_[:, 0],columns = ["x"])
  df_tsne["y"] = pd.DataFrame(tsne.embedding_[:, 1])
  # プロットする際に名前で表示させる
  df_tsne["name"] = df.name
  # テーブルで表示 
  st.write(df_tsne)

  # プロットグラフで表示
  c = alt.Chart(df_tsne).mark_point().encode(
    x='x',
    y='y',
   ).mark_text(
    align='left',
    baseline='middle',
    dx=10
   ).encode(
    text='name',
   )
  st.altair_chart(c, use_container_width=True)

結果

f:id:marron-web-engineer:20200812002537p:plain

微妙。。。散らばっているので、特徴ごとにグルーピングもできず、、、、 一箇所くらいまとまりあれば面白かったのですが、、、、 そんなに簡単にはいかないそうです。リベンジします。

最後に、ちょっと流行ってるword2vecで〜に〜を聞いてみたシリーズに乗っかり、

*word2vecとは、文章中の単語や単語間の意味を数値化(ベクトル化)する、googleの偉い方が考えたニューラルネットワークモデルです。

Hajimariのエンジニア組織にビジョンである「自立」を聞いてみました。 tf-idf同様wantedlyの入社ブログが元データです。

参考にさせて頂きました。

note.com

ソースコードのメインは以下の通り

 def filler_word2vec():
  # sample.txtには、ブログデータが単語区切りで入っています。
  sentences = word2vec.LineSentence('/output/sample.txt')
  # ブログデータベクトル化
  # 出現5単語未満切り捨て
  model = word2vec.Word2Vec(
   sentences,
   sg=1,
   size=100
   min_count=5,
   window=8,
   hs=1,
   iter=5
  )
  
  #学習モデル作成
  model.save('/model/sample.model')
 
 def analysis():
  load_model_path = '/model/sample.model'
  #学習モデル読み込み
  model = word2vec.Word2Vec.load(load_model_path)
  
  #自立に近い意味合いの単語を出力
  results = model.wv.most_similar(positive=['自立'])
  return results
 
 model = filler_word2vec()
 results = analysis()

f:id:marron-web-engineer:20200812011225p:plain

*1に近いほど類似しています

データ量が少ない関係で、数値が固まってしまいましたが、

「夢のために、新しいことに挑戦し、働く」

といった弊社らしい「自立」を解釈できるアウトプットになりました。 (少し無理やり感もありますが、、笑)

まとめ

  • 元となるデータがwantedlyでよかったのか?

  • データ数少なすぎじゃないか?

  • 分析の方法が本当に適切なのか?

などなど突っ込みどころ&反省もある内容であったと思いますが、採用という定性的で感覚が大きいテーマにおいて、言葉を数値化することで分かることも一定ありそうだなと思いました。 ちょっと大学で自然言語っぽいこと齧った程度では歯が立たないことは実感させられましたが、せっかくエンジニアと他の職務も兼務できる環境なので、プログラミングをいろんな分野に使っていけたらなと思います!

夢のために、新しいことに挑戦し、働く エンジニア組織ですが、まだまだ仲間がたりません!事業や組織作りに興味がある・モチベーション高い仲間と働きたいエンジニアさんがいらっしゃいましたら、是非お話しましょう!

www.wantedly.com

www.wantedly.com

speakerdeck.com

【Laravel】画像の直アクセスを禁止して、特定の条件を突破した場合のみ画像を表示する方法

こんにちは!Hajimariの新卒エンジニアの稲葉です。
2020年4月1日に新卒エンジニア2期生として入社しました!

普段は、自社プロダクトであるスタートアップ向けマッチングサイト構築パッケージPIECE(https://crowd.itpropartners.com/piece/)の開発や受託開発を行っています!

今回はログインしていない場合に画像の直アクセスを禁止する方法について書いていきます。
htaccessで直アクセスを制限する方法も考えられますが、ログインしている場合は直アクセスの許可するため、Laravel、Nginxで実装していきます。

直アクセス

storage/files配下に設置した画像が、 /storage/files/画像名でアクセスした際に画像が表示されます。
特定の条件を突破した場合のみ表示させたい画像がある場合は、直アクセスを禁止してみてください。
万が一直アクセスをされた際に、画像が表示されるの防ぐことができます。

実装

まずはstorage配下にimagesディレクトリを作成。
今回直リンクを禁止する画像をstorage/images配下に設置します。

画像の設置後、Nginxでlocationの設定をします。 /imagesにアクセスした際、内部リダイレクトされます。

location /images {
    try_files /index.php?$uri /index.php?$query_string;
}


次にルーティングの定義です。
whereメソッドはパラメーターのフォーマットを制約します。
下記ではパラメーター(path)を0文字以上の任意の文字列に制約。

Route::get('/images/{path?}', 'FileAccessController@index')->where(['path' => '.*']);


ルーティングの定義後、traitの作成です。
traitの作成後は必要なメソッドを定義していきます。
作成したtraitは任意のクラスで継承すれば、継承したtraitに定義されているメソッドをそのクラスが利用することができます。

use Illuminate\Support\Facades\Storage;

Trait ImageTrait
{
  public function getDiskInstance()
   {
       return Storage::disk('local');
   }

  public function storageExist($fileName)
   {
       return $this->getDiskInstance()->exists($fileName);
   }
}

getDiskInstance()メソッドでは、Storageファサードのdiskメソッドを使用してディスクインスタンスの取得。
storageExist()メソッドでは、existsメソッドを使用してファイルが存在しているか判定します。
存在する場合はtrueを返します。


trait作成後、filesystems.php で local のベースディレクトリがどこになるかを指定します。
今回はstorage/images配下に画像を設置したため、imagesディレクトリを指定。
デフォルトではappディレクトリが指定されています。

'disks' => [

        'local' => [
            'driver' => 'local',
            // 変更前
            // 'root' => storage_path('app'),
            'root' => storage_path('images'),
        ],


最後にコントローラの作成、indexメソッドを定義をします。
先ほど作成したtraitを下記で継承することが可能です。

use トレイト名;

ログインしている場合のみ直リンクを許可します。

class FileAccessController extends Controller
{
    use ImageTrait;

    public function index($path=null, Request $request)
    {
        $exist = $this->storageExist($path);

        if (!$exist) {
            abort(404);
        }

        if(Auth::check()){
            return $this->getDiskInstance()->response($path);
        }else {
            abort(404);
        }
        
    }
}

実装は以上です。


現在は家族の事情もあり、リモートワークを行っています。
内定者インターンから数えると約10ヶ月リモートワークを継続中です。
今後は長期間のリモートワークについても書いていこうと思います。


最後に

Hajimariでは、Laravel、vue.js、Nuxt.jsで開発に挑戦したいエンジニア・デザイナーを絶賛募集中です!

そして、22卒新卒エンジニアのエントリーも心よりお待ちしております!

www.wantedly.com

www.wantedly.com

GAS と Slack App でデータ集計用のボットを作りました

こんにちは!株式会社Hajimariの新卒エンジニアの濵田です。

今年の4月に新卒として入社致しました。 普段はGraspyの開発業務を行う傍ら

最近は会社のHPの管理を行ったりしています。 今回はGASとSlack Appで作った「360度レビューくん」がどう動いているのかを紹介します。

※このbotはエンジニアチームの中で走っている施策「360˚ソースコードレビュー」で使うために作成しました。 上記の施策に関しては責任者をされているエンジニアの先輩が解説してくださったこちらの記事でかなり詳しく解説されているのでぜひご一読ください。

bot の概要

slack の特定のチャンネルに配置しておき、メンションをつけてGitHubのプルリクのURLを送ると プルリクについた会話(conversation)を数えてシートに記録します。 また、メンションつけて「データをみたい」とメッセージを送信すると集計された内容が チャンネルに投稿されます。

f:id:hajimari_hamada:20200715092206j:plain 上記の写真ではプルリクのURLをbotにメンションつけて送る事で集計してもらっているシーンです。

今回はこのbotがどのようにして集計して、シートに記録・チャンネルに返信しているのか、ついて書いていきます。

全体的なbotの構成

Botが動く流れとしては以下の通りです。

① Slack でbotにメンションつけてイベント発生 => GASのAPI叩く ② GAS側では届いたURLを元にGitHub API を叩いて会話(今後コメントと表記)を取得。処理してシートに書き出し、レスポンスを返す。 ③ 戻ってきたレスポンスにあるメッセージをbotがチャンネルに投稿する

こちらのbotはGASプログラム(スプレッドシート)、Slack App(bot)を使って動いています。 それぞれ具体的にどのようになっているのか見ていきます。

GAS (スプレッドシート)

GASのプログラムは主に以下のような項目のコードで動いています。

  1. slack との最初の連携をする部分
  2. GitHub API を叩く
  3. 計算処理
  4. シートに書き込む
  5. Slack チャンネルにメッセージを送信

以下で順番に見ていきます。

1. slack との最初の連携をする部分

f:id:hajimari_hamada:20200714155903p:plain
GAS に来たデータを取得する部分です

上記写真の①では、このGASのプログラムに来たデータを取得しています。 後述するSlack 側の設定をしておくと、postData.event.text でメンションつけて送信したメッセージの内容が来ています。 今回は別箇所にプルリクURLだけ切り出す関数を用意してURLのみ使っていますが、 受け取ったメッセージはお好きなようにご利用ください。

そして②では、Slack側から来たデータが、認証用のリクエストだった場合に返してあげる内容を書いています。 このコードでSlack 側の期待したレスポンスが返ってくるかのチェックを通ることができます。 この記事を参考に(というかそのまま)用意しました。

Slack App の設定の部分で改めて説明します。

2. GitHub API を叩く

f:id:hajimari_hamada:20200714155214p:plain
GitHubAPI を叩いてレスポンスを返すメソッドです

このメソッドはGitHubAPIを叩くメソッドで、書いてる通りではあるのですが、 引数に来た GitHub API の url を叩いてレスポンスを返してくれるメソッドです。

今回はGitHubのプルリクについた会話を集計したいので、 引数に入ってくる apiUrl には

https://api.github.com/repos/ { レポジトリ名 }/pulls/{ プルリクの数値(#XXXXみたいに表示されてるやつ) }/comments が入ってきます。

また、今回は会社のレポジトリのプルリク情報を取得しているのでトークンをheaders に指定しています。

PropertiesService.getScriptProperties().getProperty('GITHUB_TOKEN')

上記に関して少し触れておくと、GASには今回のようなトークンなど、ベタ書きは避けたい情報を設定する時 別に置いておける場所があり、それをプロパティストアといいます。

そしてそこに置いておいた情報にアクセスしたりするGASの機能の事をプロパティサービス(ProperiiesService)といい、 上記のコードでは、それを使って予め保管しておいたGitHubトークンを呼び出しています。

今回は飛んできたプルリクURLをこのメソッドで使えるAPIURLに変換する部分の解説は割愛します。

3. 計算処理

f:id:hajimari_hamada:20200714162009p:plain
そのプルリクにどのメンバーがどれだけコメントしたかを計算するメソッド

上記のメソッドは、そのプルリクについたコメント数をカウントするいくつかのメソッドの中の一つです。 ここではコメントの通り、そのプルリクについたコメントを自分以外の誰が何回したかを算出しています。

引数について解説すると、このGASのプログラムではそもそもエンジニアチームメンバー全員の GitHubの名前とSlack のメンバーID を50音順で配列にして管理しています。 ここではGitHubの名前の配列を受け取っています。

また reviews は上記のGitHub API を叩いた戻りを受け取っています。 reviews にはそのプルリクについた全てのコメントが入っており、それぞれのコメントオブジェクトのuser.loginに入っています。 そして第3引数のuserGithubNameには、このプルリクをチャンネルで共有したメンバーのGitHub 上の名前を受け取っています。

上記のメソッドはそのプルリクに自分以外の人がどれだけコメントしているのかを集計しようとしていますが これらのGitHub の名前を使ってそれを実現しています。

ちなみに reviewCounts は配列で(名前どうにかするべきかも)、後々シートにデータを入れる際の事を考慮して用意しています。 それ専用のメソッドを読んで先にメンバー分の中身が入った配列を用意しています。

4. シートに書き込む

f:id:hajimari_hamada:20200714164605p:plain
今回書き込むシートを取得しています

f:id:hajimari_hamada:20200714164408p:plain
上記のメソッドを使って他のメンバーからもらったコメント数を使ってシートに書いています

1枚目の写真のように SpreadsheetAppクラスのopenByIdメソッドを使って操作したいシートを取得します。 openByIdメソッドではその名の通り、スプレッドシートのIDを渡してやる必要があるのですが、 今回も、先ほど出てきたようにプロパティサービスで、プロパティストアで管理してあるシートIDを取得して渡しています。

2枚目の写真では、 先ほど取得したシートの中で操作の対象にしたい範囲をgetRangeメソッドで指定したいます。(変数 pullRequestInfoRange)

また、シート上の書き込みたいセルを取得するために getCell メソッドを使ってそれぞれ具体的に書き込むセルの絞り込みまでしています。(変数 ~~Cell)

実際にこのプルリクで他のメンバーからもらったコメントの合計を取得するために 先ほどgiveCommentCount関数で集計して戻ってきた各メンバーのコメント数が入った配列をgiveCommentCountSum関数に渡しています。 こうして用意したもらったコメントの合計をothersCommentCellにsetValueメソッドで書き込んでいます。

5. Slack チャンネルにメッセージを送信

f:id:hajimari_hamada:20200714165751p:plain
メッセージ送信用関数の一部

f:id:hajimari_hamada:20200714170029p:plain
処理の結果で返すメッセージを切り替えている部分

最後に処理の結果を知らせるメッセージを対象のSlackチャンネルに返します。 とりあえず、sendMessageという受け取ったメッセージを返す関数を用意し、 処理の結果毎に異なったメッセージを返すようそれぞれのパターンに対してsendMessageを使う関数を用意しています。 2枚目の写真は実際にtry ~ catch でどのメッセージを返す関数を使うか切り分けている部分です。

Slack にメッセージを送るために必要な設定は後ほど説明しますが、 送信したいチャンネルにメッセージを送ることができるURLをまたプロパティサービスで取得しています。

ここまでがGAS で行われている処理の一連の流れです。

Slack App

1. slack app を作成

このページの画面上部のCreate New Appを押す。 名前と入れるworkspace を選ぶダイアログが表示されるので、それぞれ入力し次に進むとひとまずは作成できます。

アイコンや名前等の基本的は情報の設定はお好きにどうぞ。

2. bot の設定
  1. Incoming Webhook を設定

GASプログラムからbotを通じてチャンネルにメッセージを送るためにはIncoming Webhook を設定する必要があります。 Features/Incoming Webhook を選択し、Activate Incoming Webhooks を On にします。

f:id:hajimari_hamada:20200714175356p:plain
Features/Incoming Webhook の選択画面下部

f:id:hajimari_hamada:20200714175842p:plain
Add New Webhook to Workspace 押下後この画面に

そして、画面下部(上記の1枚目の写真)のAdd New Webhook to Workspace を押すと2枚目の写真の画面に遷移します。 追加したい チャンネルを選択し進むと、画面が戻りURLが発行されている事がわかります。

f:id:hajimari_hamada:20200714175903p:plain
追加された専用URL

このURLをGASのプロパティストアに設定して、上記のsendMessage 部分でプロパティサービスを使って取得して使っています。

  1. Event Subscriptions の設定

f:id:hajimari_hamada:20200714180027p:plain
Features/Event Subscriptions をOn にしています

Incoming Webhook と同じように Event Subscriptions の設定も On にします。 Request URL の部分にAPI公開されているGASのプログラムのURLを入力してテストします。 上で用意した通りにしていれば問題なく設定されます。

f:id:hajimari_hamada:20200714180129p:plain
ここでbotに関するイベントを設定することができます

そしてこの写真の部分で、bot に関係するイベントの設定ができます。 今回はbotにメンションをつけるイベントが発生すると、上で設定したURL(GAS)にデータを飛ばしてやりたいので app_mension を選択します。

以上で、今回botを用意したチャンネルでbotにメンションをつけた状態でプルリクのURLをメッセージとして送信すると、 GAS側で飛んできたURLを受け取り・集計しシートに反映・返信までできる部分をそれぞれ解説しました。

具体的にどういう計算をしたりや、どう言った文言(URL等)が来たら処理を行うのかはお好みでカスタマイズしてみてください。

ボットに関しては以上です。

また、僕の開発している Graspy は若手で活躍されている優秀な方々がキャリアアップできるようなプラットフォームを目指して日々成長しておりますので、ぜひ一度ご覧になってみてください(求人も沢山あります!)

graspy.jp

株式会社Hajimariでは、自社開発・受託開発を行っています。
一緒にLaravelやVue.jsを使って開発してくれるエンジニア・デザイナーを絶賛募集しています!

新卒・中途どちらも募集しておりますので、興味のある方は以下の記事をぜひ御覧ください!
みなさまとお会いできるのを心からお待ちしております。

www.wantedly.com

www.wantedly.com

新卒エンジニアがNoCode触って感動した話

自己紹介

はじめまして!株式会社Hajimariでエンジニアを担当している保坂(@michael_progs)と申します。
今年の4月に新卒として入社しました。
普段は大きく分けて次の3つの業務を行っています。

  1. inteeという学生のファーストキャリアをサポートするサービスの開発
  2. ソースコードリファクタリングを行うプロジェクトのリーダー
  3. リアルカレッジというプログラミングスクールの企画・運用・講師

今回はプログラミングなしで、サービスを作ることが出来るNoCodeについて書いていきます。
(NoCodeはエンジニアから見てもかなり感動するツールでした)

NoCodeとは

読んで字の如く、コードを書かずにサービスを開発できるツールのことです。

例えば何かサービスを作るときに、エンジニアがプログラミングをする必要があると思います。
しかしNoCodeを使えば、プログラミングをせずにサービスを作ることが出来ます!
今回僕も初めてNoCodeを使ってみたのですが、本当に感動しました。こんなにも短時間でクオリティの高いサービスが出来てしまうのかと。。。

ただ現状のNoCodeのツールでは、やはりエンジニアリングの知識が合ったほうがスムーズに開発できるように感じました。
なので、非エンジニアの方がNoCodeを使ってサービスを作るのには、現段階では少しだけハードルが高い気がしました。

しかし本当に革新的なツールなので、今後はNoCodeの需要が伸びていきそうです。NoCodeのこれからが楽しみです。

僕は今回bubbleというサービスを使いました。 bubble を選択した理由は以下の2つです。(気軽に選びました)

  • リアルカレッジ受講生がbubbleを使って、サービスを作ろうとしていたため
  • ネットで検索したときに、bubbleをおすすめしているサイトが多かったため

bubble.io

なぜNoCodeを触ってみようと思ったか

リアルカレッジ受講生が以下のNoCodeハッカソンに出場することになり、僕もその子達を応援したいと思ったからです。 ただ今までNoCodeを一度も触ったことがなかったので、この機会に触ってみようと思い挑戦してみました。

ncc01.nocodecamp.co.jp

NoCode触ってみた

実際にNoCodeを使って、ツイートを投稿出来るサービスを作ってみました。開発にかかった時間は60分くらいで、画面を見ながら直感的にサービスを作ることが出来ました。NoCode本当にすごいです!僕が60分で作ったサービスはこちらになります。

f:id:hosaka555:20200620190217p:plain
NoCodeで作ったツイッター

ツイートを投稿するだけの簡単なサービスですが、サイトのレスポンスも非常に速く、作っていてとても楽しかったです。 慣れてしまえば10分くらいで、同じようなものが出来てしまいそうです。

今回作ったツイートを投稿できるサービスの作り方の手順を簡単に載せておきます。
まずbubbleを開き、NEW APPよりアプリを新規で作成します。

f:id:hosaka555:20200622171320p:plain
NEW APPをクリックしてアプリを作成

次にアプリ名を設定しますが、このアプリ名がサブドメインになるので、他で使われていない名前にする必要があります。

f:id:hosaka555:20200622171346p:plain
アプリネームを設定

早速投稿フォームを作っていきます。(もともとあったエレメント(要素)やコンテナ(ブロック)などは削除しておきました)
左のメニューからInput forms > Inputを選択して、入力フォームを配置します。

f:id:hosaka555:20200622174540p:plain
投稿フォームを配置する

次にボタンを配します。先ほどと同様に左のメニューからVisual elements > Button を選択してボタンを配置します。

f:id:hosaka555:20200622174910p:plain
ボタンを配置する

ボタンの中のedit meをクリックして、投稿に書き換えます。

f:id:hosaka555:20200623093706p:plain
edit meを投稿に書き換え

次にDBにTweetテーブルを作成します。左のメニューから、Data を選択し、New TypeのところにTweetと入れてCreateをクリックします。

f:id:hosaka555:20200623094243p:plain
Tweetテーブルの作成

Tweetテーブルが作成されるので、続いてmessageというツイートを保存するカラムを追加していきます。 Create a new fieldをクリックして、以下画像のように設定します。

f:id:hosaka555:20200623094549p:plain
messageカラムを追加

これでTweetテーブルの作成は完了です!

次に投稿ボタンをクリックしたときにツイートをTweetテーブルに保存するようにしていきます。 左のメニューからWorkflowを選択し、以下画像のようにElements > An element is clickedを選択します。

f:id:hosaka555:20200623094825p:plain
ボタンをクリックしたときのイベントを追加

選択後は以下の画像のような感じになります。

f:id:hosaka555:20200623141849p:plain
選択後の状態

ここで上記画像のWhenをクリックして、出てきたポップアップウィンドウのElementのところに先程追加したボタンを選択します。

f:id:hosaka555:20200623104834p:plain
ElementにButton 投稿を選択

これで投稿ボタンをクリックしたときに何かしらのイベントを実行できるようになります。早速イベントを登録していきます。
Click here to add actionをクリックして、Data > Create a new thingを選択してください。

その後出てきたポップアップウィンドウに以下のように設定してください。

f:id:hosaka555:20200623110416p:plain
ボタンクリック時に登録するデータを設定する

これでツイートを投稿ボタンをクリックしたときに、ツイートをDBに登録できるようになりました!
左のメニューのData > App data > All Tweetsからデータが登録できているか確認できます。

f:id:hosaka555:20200623110723p:plain
ツイートを投稿できているか確認

いよいよ投稿したツイートを表示していきましょう! (実は投稿したツイートを画面に表示出来ずに40分くらい悩みました。。。)

まず投稿したツイートを表示するボックスを容易します。
左のメニューからContainer > Repeating Groupをクリックして以下画像のように設定してください。

f:id:hosaka555:20200623135532p:plain
ツイートを表示するためのボックスを用意する

次にRepeating Group の上に Text というエレメントを配置して次のように設定します。

f:id:hosaka555:20200623140518p:plain
ツイートを表示するためにTextエレメントを配置

これで右上のPreviewをクリックするとツイートが表示されていると思います!

f:id:hosaka555:20200623141534p:plain
投稿したツイートが表示される

めでたしめでたし

最後に

株式会社Hajimariでは、自社開発・受託開発を行っています。
一緒にLaravelやVue.jsを使って開発してくれるエンジニア・デザイナーを絶賛募集しています!

新卒・中途どちらも募集しておりますので、興味のある方は以下の記事をぜひ御覧ください!
みなさまとお会いできるのを心からお待ちしております。

www.wantedly.com

www.wantedly.com

躓いたところ共有しておきます

f:id:hosaka555:20200620190350p:plain
躓いたところ

その原因は次の2つでした。

  1. データを表示するエレメント(要素)を配置していなかったこと
  2. データ型を間違えていたこと

まず1つ目についてですが、データベースに存在する値を表示するときは、Repeating Group というエレメント(以下画像の赤色部分)を配置するだけで表示できると思いこんでしまっていました。
実際にはRepeating Group の上に Text というエレメント(以下画像の青色部分)を配置する必要がありました。 ここに気づくまでにかなり時間がかかってしまいました。。。

f:id:hosaka555:20200620191250p:plain
データを表示するエレメントを配置していなかった

2つ目がデータ型を間違えていたことです。
最初僕はTweetオブジェクトに存在するmessageというプロパティを表示したかったので、以下画像のようにData sourceを設定してしまっていました。。。

f:id:hosaka555:20200620192312p:plain
データ型を間違えていた

正しくは以下画像のようにData sourceに設定する値の型をType of contentに合わせて、Textエレメント上でTweetオブジェクトのmessageプロパティを表示するように設定することで上手く表示することができました。

f:id:hosaka555:20200620192520p:plain
Data sourceに設定する値の型をType of contentに合わせる

f:id:hosaka555:20200620192822p:plain
TextエレメントにTweetオブジェクトのmessageプロパティを表示するように設定する

でめたしでめたし