エンジニア歴1年の僕がドメイン駆動設計(DDD)を参考にLaravelのプロジェクトをフルリニューアルした話

こんにちは!
はじめまして!
2020年7月からPIECE事業部でエンジニアをさせてもらっています。
野澤です。


今回、PIECEというサービスのリニューアルを担当させてもらったのでその時のことについて書きたいと思います!
まだ若輩者なので至らない点が多々あると思いますが


フルリニューアルってどんな事したんだろう〜?
Hajimariのエンジニアはどんな仕事をしてるんだろう〜?
って思った人はぜひ読んで見てください!

ドメイン駆動設計の説明も書いたのですがボリュームが多くなってしまいました…
ドメイン駆動設計について概要知りたいという方は是非読んでみてください。
クリーンアーキテクチャの説明やモデリングのやり方などは説明していません。
ご了承ください。

PIECEリファクタリングプロジェクトの概要

PIECEとはどのようなサービスなのか

PIECEとはスタートアップ向けマッチングサイト構築パッケージです。 特徴としては

  1. すでにマッチングサービスを運営するための機能が揃っているためスピード感があるビジネス展開が可能
  2. 弊社サービスのITプロパートナーズで培ったノウハウがあるためマッチングビジネスに寄り添った開発、保守が可能

です。 興味がある人はPIECEのホームページをぜひ見てみてください!


リニューアルの目的
  • 今後の保守、運用をしやすいようにソースコードリファクタリングする
  • テストについて考えられていないのでテストできるように整える
  • PIECEパッケージをもっと良くする
    の3点が主にありました。


リニューアル施策

ドメイン駆動設計適用

ドメイン駆動設計の考えを用いて層の責務をわけ、コントローラの肥大化を抑えるようにしました。
適切な責務わけをしたことにより、ビジネスロジックの再利用性も上がりました。
それとリポジトリという概念を用いてデータベース接続をORMに依存しないようにして、リポジトリのインタフェースを用意することでテストのしやすさも改善することができました。
今後はテストコードも書いていけるようにしていけたらいいなと思っています。

技術的負債を返す、既存のバグを改修

bladeを直接使わずに、フロント側のソースコードをサーバーサイドで作っていた部分があったのでそこの分離や既存で発覚していたバグの改修をしました。

DB制約の整備

本来NOT NULLのところがNULL許容されていたり、外部キー制約が設定されていなかったり整っていませんでした。
カラムに対して一つずつ精査をして正しい制約を設定しました。

型を明示的に指定する

PIECEではPHPのメソッドの引数、戻り値、プロパティの型を指定するようにしました。
型指定をするメリットは

  • 想定外の引数を受け取った瞬間にエラーになるため、不具合を検知するタイミングが早くなる
  • 関数の意図がわかりやすくなる
  • 型が保証されるので関数の引数チェックが簡単になる
    などがあります。
    安全なソースコードを書くためには方が必要だと思ったのと、僕自身HajimariでエンジニアになるまではJavaC#など型があったほうが安心感もあったので明示的に型を指定するようにしました。

コメントの強制

今まではメソッドコメントやクラスコメントが無かったのですがそれを書くようにしました。
それとコードの段落でのコメントを意識するようにしました。
理想を言うとソースコードを見ただけですぐコードを理解できればよいのですがそうはいかないときもあります。
そういうときにコメントがあれば早く理解でき、コードの改修がはかどります。

命名にこだわる

三者が見てすぐに分かるかどうかを重要視しました。
良い命名のメソッドはメソッドの中身を見なくてもどんな処理をしているかわかります。


実際にリニューアルをしてみて

今までいかにも自分がやってきた感を出しながら記事を書いていたのですが、実はそうではなく僕含め4人で対応していました。
見積もりやスケジューリング、タスクのチケットの割り振りなど経験がなく苦労しました。
ドメイン駆動設計も実務でやったことはほぼなく自分で本を読み、ネットで記事を読みPIECEに対してどのように適用したら良いのかを考え、フォルダ構成を考え色々試行錯誤しながら導入しました。
リニューアル中は上手くいかない部分がたくさんありましたがチームのみんなに協力してもらったり支えてもらったりで最終的にやり遂げることができました。
良い製品を作るためにはスケジューリング能力であったり、チームでのコミュニケーション能力だったり「技術」以外の要素も多分に必要になってくるということを実感させられました。


最後に

このPIECEプロジェクトのリニューアルに携わることになったのは入社して1週間後くらいのことでした。
まだエンジニア組織としては未熟でたくさんの課題があると思っていますが未熟な分、積極的に行動すれば改善できることがたくさんあり、挑戦できることもたくさんあります。
Hajimariの理念に共感でき、能動的に仕事をしていきたい!というエンジニアさん是非一緒に仕事をしましょう!
Hajimariではエンジニアの採用も行っているので興味がある人は見てみてください。
※下にドメイン駆動設計のちょっとした解説もあるので興味を持っていただいた方は見てみてください!

www.wantedly.com




ドメイン駆動編〜

ドメイン駆動設計を勉強するのに利用した本

www.shoeisha.co.jp ドメイン駆動設計について記事を調べたりすると抽象的な話が多い中、この本は具体的な実装例がたくさんありどのように実装していけばいいのかを知ることができる本です! 層の責務や概念はわかったけど具体的にどう書けばいいんだ!っていう人におすすめです。


gihyo.jp この本はプログラムを書くときに常に意識しておきたいことについてのことがたくさん書いてあり、かつドメインモデルの考え方や画面遷移やweb APIについてにも触れられており、
本当に現場で役に立つことが書いてありました。
ドメイン駆動設計に問わずおすすめの本です。


www.shoeisha.co.jp この本も具体的な実装例が書いてありとても参考にしました。


gihyo.jp 雑誌なのですがそこに書いてあるドメイン駆動設計がとても参考にしました。 「集約」という言葉について「そういうことだったのか!」となった本です。 ページ数は少ないですが体系的にまとめられておりその少ないページにドメイン駆動設計の情報がかなり凝縮されていて腑に落ちる部分がたくさんありとても良かったです。

ドメイン駆動設計とは

僕自身うまく言葉にできなかったのでWikipediaで調べてみたのですが
【ソフトウェアの設計手法であり、「複雑なドメインの設計は、モデルベースで行うべき」であり、また「大半のソフトウェアプロジェクトでは、システムを実装するための特定の技術ではなく、ドメインそのものとドメインのロジックに焦点を置くべき」であるとする】 と書いてありました…
抽象的すぎて分かりづらいですよね…
僕もまだ勉強の途中なので理解不足な面は多大にありますが僕自身の感覚だと「ビジネスロジックの開発の効率やビジネスロジックの再利用性を最大限上げることができる設計手法、業務(ビジネス)を中心にそえて開発を進めていくための設計手法」となります。
機能を開発するときも、改修するときもビジネスロジックに注力します。
そこでSQLAPIを意識させないようにします。
そのようにすることで業務の発展に合わせてソフトウェアを成長させることに集中することができます。


層の責務について

この記事を読んで概念がなんとなくわかるようになっていただけたら嬉しいです。 具体的な実装、コードについては別途また他の記事で書いたりしようと思います。

プレゼンテーション層

MVCのコントローラがこの層に属していると考えます。
この層は下記アプリケーション層に依存し、ユースケースを実現します。
その他http系の処理やCookie、セッション関連の処理が責務となります。
実装例.

class UserController
{
    /**
     * コンストラクタ
     *
     * @param \Application\User\UserApplicationService $service
     */
    public function __construct(UserApplicationService $service)
    {
        $this->service = $service;
    }

    /**
     * ユーザー情報参照
     *
     * @param string $id
     * @return \Illuminate\View\View
     */
    public function show(string $id): View
    {
        $user = $this->service->show($id);

        return view('user.show')->with([
            'user' => $user
        ]);
    }

}



アプリケーション層

アプリケーション層はトランザクション管理やドメイン層、リポジトリを使ったユースケースの実現が責務です。
ここにビジネスロジックは書きません。
ビジネスロジックはmodelかDomainServiceにまとめたあと、そのメソッドを呼ぶようにします。
ビジネスロジックを組み立ててユースケースを実現します。
それとドメイン駆動特有のルールではないのですがPIECEではこの層で直接ORMを利用してDB接続に関する処理を書いてはいけないという決まりにしました。
DB接続に関してはただ保存するだけ、ただ更新するだけであればリポジトリのupdateOrInsertメソッドを直接呼び出して良い、そこにビジネスロジックが入ってきた場合(データの加工をしたあとにデータ保存など)はDomainServiceを通じてDB接続をするようにします。
これらはApplicationServiceクラスに記述します。
実装例.

class UserApplicationService
{
    /**
     * ユーザー情報参照
     *
     * @param string $id
     * @param integer $companyId
     * @return \Domain\Models\User\User
     */
    public function show(string $id): User
    {
        $userRepository = new UserRepository();
        $user = $userRepository->get($id);

        return $user;
    }
}
ドメイン
ドメイン(model)

基本ビジネスロジックはここに書くことになります。
本来ドメイン駆動設計においてドメインのモデルとMVCのモデルは意味合いが違うのですが、今回PIECEでは同一のものととして考えることにしました。
ですのでドメイン=モデル=Entityといった感じで捉えるようにしています。
実装例.

class User extends Model
{
    /**
     * フルネーム取得
     *
     * @return void
     */
    public function getFullNameAttribute(): string
    {
        return $this->last_name. " " .$this->first_name;
    }
    
    // Userクラスが持つデータに対する業務ロジックを実装
}



ドメインサービス

ドメイン(model)で書くべきではないビジネスロジックを書くときにここに書きます。
modelにビジネスロジックを書くのに違和感があるとき(クラスのプロパティに対するロジックではない)や複数のドメインが関わってくるロジックのときなどに記述します。
例えば「ログイン可能かどうか」というメソッドはドメインに置くべきではないと考えます。
1ユーザーが自分がログイン可能かどうか自分自身で確認していくのは違和感を感じます。。
ですので「ログイン可能かどうか」というメソッドはドメインサービスに置くのが良いと思います。
実装例.

class UserDomainService
{
    /**
     * ユーザー重複チェック
     *
     * @param User $user
     * @return void
     */
    public function exists(User $user) : User
    {
        $userRepository = new UserRepository();
        $user = userRepository->get($user->id);

        return empty($user->id) === false;

    }
}



リポジトリ

データのやり取りの際は必ずこのリポジトリが呼ばれます。 ここのメソッドはDBの接続に関するものだったら内部でORMを利用します。
DBへの接続など意識することなく、データの入出力を実現します。
ここはDBへの接続だけではなく、例えばNoSQLを利用するようにしたりすることもできます。
ドメイン駆動設計においてリポジトリのインタフェースをドメイン層、実装クラスをインフラストラクチャ層と分類したりするのですが、フォルダの構成上の事を考えてリポジトリドメイン層の要素とみなしています。
実装例.

class UserRepository
{
    /**
     * データ取得
     *
     * @param string $primaryKey
     * @return void
     */
    public function get(string $id): User
    {
        $user = User::find($id);
        return $user ?? new User();
    }

    /**
     * データ更新or追加
     *
     * @param User $user
     * @return void
     */
    public function updateOrInsert(User $user): void
    {
        $user->save();
    }
}




依存関係について

PIECEではプレゼンテーション層がアプリケーション層に依存し、アプリケーション層がドメイン層に依存する形にしました。
もっとわかりやすく言い換えるとControlllerがApplicationServiceのメソッドを呼びます。


フォルダ構成について

ドメイン

package
│   ├── Domain
│   │   └── Models
│   │       ├── Company
│   │       ├── User

ドメイン層に関しては上記のようにModelsの下にmodelの名前のフォルダを作り、その中にドメイン(model)、ドメインサービス、リポジトリインタフェース、リポジトリを格納しています。
フォルダ構成についてはいろいろな考え方があると思いますがPIECEでは各要素を一つのフォルダに加えることで、ソースコードを見つけやすくなると考えてそのような構成にしました。
例えばUserに関する機能を作るときはUserの下のドメインドメインサービスを見れば再開発しなくても済むかもしれないと予想することが用意になります。

アプリケーション層

packages
│   ├── Application
│   │       ├── Company
│   │       ├── User

アプリケーション層に関しては上記のように構成しています。
CompanyやUserというフォルダにはApplicationServiceとDTOのファイルが格納されています。
DTOとは【Data Transfer Object】のことで、関連するデータを一つにまとめた、データを運ぶだけのオブジェクトです。
PIECEではApplicationServiceのメソッドではドメイン(model)かDTOインスタンスをControllerに返すようにしました。

ドメイン駆動設計の考え方を用いてリファクタリングした感想

ドメイン駆動設計は短期間でのリファクタリングや新規開発などスピード感を求められる場面ではあまり向いていないと思いました。
実際にドメイン駆動設計の考え方を取り入れたときにネックだったなと思ったのは

  • 責務わけをしたことによりファイルの数が多くなり対応に時間がかかった。
  • ドメイン駆動設計にどこまで乗っ取るのか決めるのに時間がかかった。(「entitiy」、「valueobject」、「仕様」、「集約」、「ファクトリー」などドメイン駆動設計で重要な概念があるがスケジュールのことや今後のことを考えて使用しなかった)
  • チームでドメイン駆動の概念の理解やドメイン駆動設計に基づくルールに則ってちゃんとコードに起こせるようになるまでのコストが高いと感じた。
  • 集約の概念が難しい。「リポジトリでのやり取りの基本単位が集約」、「不変条件を維持する単位」など頭でわかっていてもモデリングを時間をかけてやらないと上手くいかない部分が多々出てきた。

という4点です。
プロジェクトの規模にもよりますが、リファクタリングをするために時間をたくさんかけることができる状態であり、
入念にドメインエキスパートの人に話を聞いてモデリングして実装に起こしていくというふうにすすめていかないとレベルの高いドメイン駆動設計は難しいと思いました。
ただし軽量ドメイン駆動設計でも多分にコードのみやすさであったりビジネスロジックの再利用性は上がったので良かったと思っています。


レスポンス・ヘッダフィールドから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