PHPStan導入のすすめ

こんにちは!
株式会社Hajimari21卒エンジニアの古田 鏡です。

普段は、TUKURUS事業部(旧PIECE事業部)で
自社プロダクトであるスタートアップ向けマッチングサイト構築パッケージPIECE
https://crowd.itpropartners.com/piece/)の開発や受託開発を行っています!
※2022年4月から事業部の方針変更により事業名が変更となりました!

crowd.itpropartners.com

現在ジョインさせていただいている案件では、
組織のオンボーディングシステムの新規機能開発・システム統合等を担当させていただいております。

開発言語に関してはバックエンドでPHPフレームワークはLaravel)を使っていて、
静的解析ツールPHPStanを導入しているのですが、
このツールが便利だったので今回はご紹介していきたいと思います!

PHPの特徴

そもそもPHPは「動的型付け言語」に分類され、
基本的にはデータ型の宣言がいらないプログラミング言語です。

※参考 動的言語と静的言語の違い

型の宣言がいらないことで得られるメリットとしては、

  • どのような型の値でも代入でき、型(文字列・数値・配列・オブジェクト等)を意識する必要がない
  • (型の宣言がいらない分)記述量が少なくすむ
  • ソースコードの変更(修正)に強い

この辺が挙げられるのではないかと思います。

私もこれまで実際PHPの案件をやっている中で、
型を意識せずに面倒から解放される点でPHP最高ー!!と思っていました。

ですが、現在携わっている案件で、複数人での開発・システム統合をする中で、
コードの統合・不要コード削除に伴い、影響範囲の大きな修正が増えたことで、
静的解析の重要性を身に染みて感じました、、、
(修正がバグを生む可能性大、、、テストコードを全て書くのは時間がかかりますし、、、)

そこでPHPStanの出番です!!

●PHPStanとは?

PHPStan は、
PHP コードの静的解析ツールです。 (PHP Static Analysis Toolの略)
https://phpstan.org/

MIT ライセンスで公開されており、
Composer でインストールして利用できます。
また、Docker イメージも公式で公開されております。

github.com

phpstan.org

概要
  • 動作にはPHP 7.2以降が必要
  • 未定義の変数・メソッド・Class・プロパティなどを検出
  • PHPDocの構文チェックが可能

●ルールレベル設定

PHPStanはルールレベル設定があり、
レベルは0~9の10段階で設定できます!
レベル9ともなると結構厳しめのチェックとなるようです、、、!

phpstan.org

Lev 内容
0 基本的なチェック、未知のクラス、未知の関数、$this上で呼び出された未知のメソッド、それらのメソッドや関数に渡された引数の数が間違っている、常に未定義の変数をチェック
1 未定義の変数、call と get を持つクラスの未知のマジックメソッドとプロパティがある可能性がある
2 ($this だけでなく)すべての式で未知のメソッドをチェックし、PHPDocs を検証する
3 戻り値の型、プロパティに割り当てられた型の確認
4 基本的なデッドコードチェック - instanceofやその他の型チェックが常にfalse、到達しないelse文、return後の到達不能コードなど
5 メソッドや関数に渡される引数の型チェック
6 タイプヒントの欠落を報告する
7 部分的に間違っている論理和型の報告 - 論理和型の一部の型にしか存在しないメソッドを呼び出した場合、レベル7はそのことを報告し始めます(その他の不正確な状況も)
8 null可能な型に対するメソッド呼び出しとプロパティへのアクセスを報告する
9 混合型に厳密であること - この型で唯一許される操作は、この型を別の混合型に渡すことである

下記に例題が載っているのでチェックしてみてください!

phpstan.org

●インストール方法

インストールする方法としては

phpstan.org

公式にインストールする方法が記載してあるので、それに従いインストールします。

Composerを使い下記のコマンドを実行します。

composer require --dev phpstan/phpstan

基本的に開発環境で使用するものだと思いますので、
--devオプションをつけるのが良いと思います。

インストールできているか確認

./vendor/bin/phpstan analyse --version
PHPStan - PHP Static Analysis Tool 0.12.99⇦インストールされているバージョンを表示

●実行方法

実行コマンド
./vendor/bin/phpstan analyse

ルールレベルを変更する場合には、 レベルを変更するには --level (-l )オプションで実行します。
↓ではレベル4で設定しています。

.\vendor\bin\phpstan analyse -l 4
設定ファイル(phpstan.neon 一部抜粋)
includes:
    - ./vendor/nunomaduro/larastan/extension.neon

parameters:

    paths:
        - app

    level: 4
実行結果

エラーありの場合

エラーなしの場合

こんな感じで結果が出ます。

●実際に使ってみて得られたこと

  • 導入のしやすさ
    基本的に公式通りでインストールでき、導入のハードルが低いこと。

  • 解析が高速にできる
    状況に応じて、ルールレベル・実行範囲を変えられ、必要部分に対して解析が高速にできること

  • レビューの負担軽減
    システム統合など大きな改修を行う場合、コードレビューの量が多くなりがちですが、
    事前に静的解析を行うことでレビューの量を減らせること
    (個人的にはこの効果が一番大きいと思いました。)

  • ある程度システムの安全性・整合性を担保できる
    型レベルの安全性・整合性が保たれること
    しかしながら、無限ループや関数の型がmixed・null許容の場合、解析をすり抜けてしまうことがあること

●まとめ

大前提として、別途ユニットテスト等は必要だと思いますが
導入と実行の容易さ、時間短縮の観点から非常にコスパの良いツールだと思いました!
プロジェクトが大きくなればなるほど、効果を発揮するツールだと思いますので、是非導入してみてはいかがでしょうか!


株式会社Hajimariでは、Laravelをメイン言語として自社開発・受託開発を行なっており、
一緒に開発を行なっていただけるエンジニア募集しています! 長野拠点の立ち上げメンバーも大募集しています!

興味のある方は以下の記事をぜひご覧ください!
みなさまとお会いできるのを心からお待ちしております!

www.wantedly.com www.wantedly.com www.wantedly.com

Notionで新卒採用管理システムを作ってみた!

こんにちは!
4月に入社した、株式会社Hajimari22卒エンジニアの神野 凌太郎です。

普段は、人事プロパートナーズの開発業務や事業部内でリリースする新規サービスの開発業務を担当しています。

本業はエンジニアですが、内定者時代から23卒エンジニア採用の兼務をしていて、
約1年前、23卒採用が本格的に始まる前にNotionで新卒採用管理システムを作成しました。

今回は、実際に1年間運用してみてよかったことや、改善できたなと思うことを まとめてお伝えできればと思います!

■そもそもNotionとは

www.notion.so

Notionは、ドキュメントやデータベース、TODO管理など
あらゆる情報やデータを1つに集約して管理できるSaaS型のツールです。

弊社では、Notionを社内wikiとして全社で導入しています。
他にも各事業部で、チームのタスク管理やTODO管理で活用していたり、
採用募集のページを公開したりしています。

また約半年前に、弊社のイベントスペースで
NotionTokyoのオフラインイベントが行われたりもしました!

https://notiontokyo14.splashthat.com

■Notionを導入した経緯

Notionシステムを導入する前は、 Slackでチャンネルを作成し、そのチャンネルで候補者ごとにスレッドを立てて申し送りや引き継ぎが行われていました。

課題

Slackを使って申し送りを行うことで、スピーディーに情報共有ができる一方で、 下記の問題点がありました。

  • 候補者の情報を見るために、過去のスレッドを辿る必要がある(ピーク時にはすぐに上に流れていってしまうので、スレッドを見つけるのは困難です)
  • ステータス管理を別でスプシで行っていたが、 更新漏れがたびたび発生しており、リアルタイムでのステータス管理ができていなかった
  • 情報が点在しているため、データ分析をすることが難しく定性的な判断で採用活動を行なっていた

実装する手間を最小限にリアルタイムにデータを集約しようということで、 Notionで新卒採用管理システムを作成することにしました。

■全体の流れ

Notionを使った一連のフローを下記の図にまとめました。
Notionを使うのは、23卒採用メンバー + 面接を担当する社員です。

初回接触の場合は、学生テーブルにレコードを追加し、
基本情報(名前や次回面接日)や関連情報(大学、応募経由など)を記入します。

面接後は評価や所感を記入し、社内の専用Slackに情報共有、エージェントや学生に結果連絡を行います。

2回目以降の面接に関しては、基本的に23卒採用メンバー以外の社員が担当します。
担当社員は、面接終了後にNotionのステータスの更新、結果・所感の記入を行い、
リクルーター(該当学生を担当している23卒採用メンバー)にSlackで連絡を行います。

Notionを活用したときの一連のフロー

■テーブル構成

テーブル構成は以下の通りです。 学生テーブルを中心として、それぞれ「採用メンバー」「大学」「エージェント」「リクルーター」のテーブルと連携しています。「リクルーター」は23卒採用メンバーを指し、 「採用メンバー」は2次面接以降を担当する社員を指します。

Notionのリレーション機能を使った実装を行い、リレーションを利用したのはそれぞれのテーブル別でデータを蓄積するためです。

テーブル構成

■作成にあたって意識したこと

大前提として、23卒採用メンバーにNotionを活用してもらわなければ意味がありません。 導入目的は、リアルタイムにステータス、情報更新してもらうことだからです。

ゴールは、メンバーに「使ってもらえるかどうか」。 そのため、多少データベースの構成が複雑になっても、UIを優先しました。

トップページの親しみ

メンバーはかなりの頻度でNotionを開くので、トップページをこだわろうと思いました。 特にカバー画像は目に優しく、可愛くしようと思っていた時に、たまたま近くに同期の女の子がいたので、作成を依頼しました。めちゃくちゃ可愛い!

トップページ

使い方ページの作成

当時、全社でNotionが導入されたばかりだったこともあり、 Notionの操作に慣れないメンバーがほとんどでした。

また、Notionを使うのは23卒採用メンバーだけではなかったので、
多くの人に等しく操作に慣れてもらう必要がありました。
そこで、使い方ページの作成を行いました。

  • 文字だけではなく動画を使って説明すること
  • リクルーターと面接担当者別のフローに沿って、必要な情報だけ提示すること

意識したことは上記の2つです。
使い方ページを導入したおかげで、
導入障壁があまりない状態で、運用をスタートさせることができました。

記入コストを下げること

冒頭でも記述した通り、ゴールは使ってもらえるかどうかです。
そのため、記入するコストを下げることを意識しました。
具体的には、考えなくても直感的に記述できること。

まず、Notionのテンプレート機能を利用し、
極力労力をかけないで良いように、学生ページのテンプレートを用意しました。

学生ページのテンプレート利用方法
基本情報は、一覧画面で情報の記入や更新ができるようにし、
詳細な情報(学生情報、面談メモ)は学生ページで記述できるようにしました。

見やすさも意識しつつ、記入に思考体力を奪わないように設計を心がけました。

■問題点

実際に1年間運用してみた結果、いくつか問題点が出てきました。

ページが重く、表示速度が遅い

最もクリティカルだったのは、ページが重く、表示速度が遅いことです。
原因は、学生レコードの急激な増加リレーションを貼りすぎたことだと推測できます。

年度の途中で採用予定人数が大幅に増えたことも相まって
学生レコードが増加し、現在700レコード近くデータが入っています。

その上、そのレコードに各関連テーブルのリレーションが貼られている状態なので、
さらにページを重くしています。

学生ページ内、面談評価の面談担当者と採用メンバーテーブルがリレーションを貼っており、特に最も重くしている原因として考えられます。
学生テーブルの学生ページ内でリレーションを貼っているので、採用メンバー全員が面談担当者と紐付いた状態になっているからです。
(しかも、採用メンバーテーブルであまりデータを貯める必要がないことも後からわかったので、リレーションを貼る必要性もありません。)

学生ページ内の面談評価テーブル
この後に、シンプルテーブルの機能がリリースされました。
採用メンバーのテーブルはこれがベストだと感じました。(zoomのリンクやWantedlyの記事の管理は行いたいからです)

www.notion.so

データ集計が難しい

残念ながら、Notionにはスプレッドシートやエクセルのように便利な関数がいくつも用意されていません。
当初はNotion内でデータ分析がリアルタイムに見れる状態を作ろうと思い、
用意されている関数を駆使して、無理やり集計ができるようにしましたが、
あまり活用できていません。

データ分析自体は、スプレッドシートをNotionに埋め込んで同期できるように運用した方が良さそうです。

■まとめ

採用管理システムをNotionで作成したことによって、 データを一元に管理できるようになったので非常に良かったです!
実際に運用してみて問題点が浮き彫りになったので、 24卒以降の管理システムは、より良いシステム構築ができれば良いなと思っています。
1年前に作成した時よりも、機能がかなり充実しているので、 うまく活用していきたいと思います。

Notion大好き!


株式会社Hajimariでは、Laravelをメイン言語として自社開発・受託開発を行なっており、
一緒に開発を行なっていただけるエンジニア募集しています! 長野拠点の立ち上げメンバーも大募集しています!

興味のある方は以下の記事をぜひご覧ください!
みなさまとお会いできるのを心からお待ちしております!

www.wantedly.com www.wantedly.com www.wantedly.com

22卒内定者が『フリーランス人事と案件のマッチングスコア算出』機能をリリースしました!

皆さん初めまして、
22卒内定者で人事プロパートナーズで開発を担当している、神野凌太郎と申します!

今回、人事プロパートナーズ内でフリーランス人事と案件のマッチングスコア算出」機能をリリースしたので、リリースに至った経緯と機能詳細についてご紹介したいと思います!

■人事プロサービス概要

f:id:r_kamino:20211122124847p:plain

各社が抱える組織課題の解決に強みを持った即戦力人事の業務委託人材と
人事のリソース不足でお困りの成長企業をマッチングするサービスです。

興味のある方は、
ホームページからぜひお問い合わせください!

itpropartners.com

■機能開発に至った背景

弊サービスでは、案件をご紹介する前に専属CA(キャリアーエージェント)がご登録いただいたフリーランス人事と面談を行います。

面談では、

  • どういう経験をされてきたか
  • 案件にはどのくらいの日数や、週何時間くらい稼働できるか

などをお聞きして、管理画面に面談情報を登録していきます。

その後、登録した情報をもとにフリーランス人事に案件をご紹介しています。
ただ、弊サービスには多くのフリーランス人事の方にご登録いただいているため、企業担当が案件にマッチするフリーランス人事をピックアップするのが困難です。
そのため、企業担当からCAに案件概要を説明した後、
CAが案件にマッチするフリーランス人事ピックアップしています。

f:id:r_kamino:20211122124958p:plain

問題点としては、

  • 企業担当とCAのコミュニケーションコストが大きいこと
  • CAの属人的な情報で、案件とフリーランス人材のマッチングを行なっていること

が挙げられます。

そこで、「人と案件のマッチングスコア算出」機能をリリースすることで、
少しでも企業担当とCAの工数を削減し、より最適なマッチングを実現しようと考えたわけです。

■マッチングスコア算出のロジック

マッチングスコアは、以下の8項目それぞれの入力内容を突き合わせて算出します。
具体的には、それぞれの項目で0~1の間で評価を行い、設定した重みをかけ合わせて
スコアを算出しています。

f:id:r_kamino:20211122130155p:plain

企業担当とCAのヒアリング結果から、
「月間稼働時間」「勤務形態」「タグ」「稼働可能時間帯」がマッチングにおいて特に大事な項目だということがわかったので、他の項目に比べて重みが大きくなっています。

また、案件・フリーランス人事のどちらか未入力の項目があった場合は、
自動的にその項目は0として評価をします。
項目によっては未入力のほうが入力していた時よりもスコアが大きくなる場合があるからです。

 ■実装手順

 ①仮の評価方法を作成

まず、フリーランス人事と案件の入力項目を見比べて、マッチングに必要な項目を洗い出しました。
項目を洗い出すなかで、どちらか一方にしか項目でも、マッチングに使える項目と判断したものに関しては、新たに項目を追加する想定でピックアップをしました。
ここで、既存のデータにこだわるのではなく、あくまでマッチングに必要な要素は何かという判断軸でピックアップを行うことを意識しました。

②過去データをもとに、手動で評価値を算出

過去のフリーランス人事とその方が稼働している案件情報を用いて、評価値を算出しました。
実際にマッチングが成立した情報を使った時に、評価値に問題がないかを確認するためです。

結果として、全ての項目を0、1だけで評価してしまうと、明らかに他の項目より評価が高くなってしまう項目があったので、それぞれの項目に重みをつける方法を取ることにしました。
データベースに重みの値を保存できるようにして、いつでも値を変えられるようにします。

③CAと企業担当にヒアリング

②までに作成した叩きを用いて、CAと企業担当と要件のアップデートを行いました。
マッチングに用いる項目と重みに問題がないかを確認してもらい、評価方法の改善をするのが目的です。

ヒアリングにおいてCAと企業担当どちらかに偏った意思決定にならないように、
中立に意見を取りまとめるように心がけました。

④入力フォームの設置

要件のアップデートで新たに出てきたマッチング項目を管理画面で登録できるように、入力フォームの設置を行いました。(システム設計上、複数のデータベースと入力ページの改修が必要で、想定以上の工数がかかりかなり大変でした。。)

⑤自動マッチングの実装

マッチングに必要な項目を入力できるようにしたので、最後に自動マッチングの実装に取り掛かりました。
アップデートした要件をもとに、それぞれの項目でスコア算出のコードを書きました。(全部で200行くらいのボリュームです、、)

また、算出したスコアを用いて「フリーランス人事→案件」「案件→フリーランス人事」でマッチング検索できるページを実装しました。

下記の図は、「案件→フリーランス人事」で検索したページの例です。
算出したスコアの高い順でユーザー表示を行うことで、フリーランス人事と案件の最適なマッチングを提案します。

 

f:id:r_kamino:20211122130552p:plain

■開発を通して大変だったこと

最も大変だったことは、自動マッチングの抽象的な要件を具体に落とし込む作業です。

もともと、「フリーランス人事と案件を自動マッチングできたら良いよね」と開発チームでの何気ない会話からスタートしているので、「やりたいことは決まっているけど、具体的には何も決まっていない」状態でした。

そのため、具体的な実装要件を詰める前に、ビジネスロジックをプログラムに置き換える作業が必要です。

まず、スラック上で企業担当とCAがやり取りしているテキストからマッチングに必要な項目を推測し、開発チームで叩きを作成することから始めました。(叩きを作成するのに半日以上かかりました、、)

「叩きがある状態だと効率的に要件を詰めることができる」と上長から教えてもらったという背景から、叩きを先に作成することにしました。

実際に、叩きがある状態で議論をすると、より本質的なマッチング要件を決めることができ、
実装もスムーズに行うことができました。

学んだこととしては、「実装前の要件整理に時間をかけること。」
時間をかけた分だけ、ビジネスに寄り添った機能開発ができることを実感しました。

■まとめ

実際にこの機能をリリースすることで、

  • 月間12.5時間の削減
  • 客観的な情報で誰でも人選が可能な状態

を実現することができました。

実際にCAと企業担当からは、「最適なマッチングが実現できて、リソースが余った分、さらに別のところで価値提供が行えるようになった」というお声をいただきました!

今後、この機能を活用してフリーランス人事の方に直接案件をレコメンドできる機能のリリースも考えています。

他にもまだ活かしきれていないデータは数多くあるので、うまくデータ活用を行なってサービス向上に寄与していきたいと思います!

最後までお読みいただきありがとうございました!

------------------------------------------------------------------------------------

株式会社Hajimariでは、Laravelをメイン言語として自社開発・受託開発を行なっており、一緒に開発を行なっていただけるエンジニア募集しています!

23新卒エンジニアの募集も開始しておりますので、少しでも興味ある方はカジュアルにお話ししましょう!

興味のある方は以下の記事をぜひご覧ください!
みなさまとお会いできるのを心からお待ちしております!

www.wantedly.com

www.wantedly.com

www.wantedly.com

www.wantedly.com

www.wantedly.com

www.wantedly.com

www.wantedly.com

www.wantedly.com

一泊二日開発合宿!〜思わず移住したくなる森のオフィス〜

こんにちは!

株式会社Hajimari21卒内定者エンジニアの古田です。

今年の6月より内定者インターンとしてHajimariで働いております!

 

普段は、企業と優秀な人事のマッチングサービス

人事プロパートナーズ」の開発業務を行っております!

 

毎日学ぶ事だらけですが、会社の良いメンバーにも恵まれ、

非常に充実した日々を送っております!!!

やっぱり、仕事をする上で誰と働くかが非常に重要だな

最近は、そんなことを実感しております!


さて、今回は社内のエンジニアメンバーと共に一泊二日で開発合宿に行ってきました!

その様子をこのブログで紹介させていただきます!

 

 

開発合宿を行った「富士見 森のオフィス」 

f:id:kyoK:20201109144216p:plain

f:id:kyoK:20201111121752p:plain

(森のオフィス ホームページより)

今回、開発合宿は長野県にある

富士見 森のオフィス」という施設に行ってきました!

 

大自然の中にあるこの施設は、コワーキングスペース宿泊棟が併設されており、

普段はサテライトオフィステレワーク拠点

地域住民の方々にとっての“公民館”的スペースとして使われている施設で、

Hajimariのオフィスがある渋谷からは車で2時間半程のところにあります!

 

 

 

 

コワーキングスペースの様子

f:id:kyoK:20201109145440j:plain

f:id:kyoK:20201109145358j:plain

 

施設内はとても静かで、ストレスを微塵も感じさせない

コワーキングスペースでの作業はめちゃめちゃ集中できました!

少し疲れてきた時にパソコンから目を逸らし、

窓の外に目を向けると辺りには木々が広がっていて癒されました!

開放感が半端なかったです!!

 

施設の外のベンチで作業をしているメンバーもいましたが、

周りに木々の音しか聞こえない中での作業は

都心で働いていては味わう事ができないですし、

そんなストレスフリーな環境がこの施設の一番の魅力だと思いました!

 

 

施設周辺の様子 

f:id:kyoK:20201109144914j:plain

森のオフィスというだけあって、辺りは木々に囲まれていました!

紅葉シーズンだった事もあり、とても綺麗で癒されました!

(僕の語彙力では表現し切れない事が非常に悔しいです、、、)

 

昼食

f:id:kyoK:20201111114316j:plain

f:id:kyoK:20201111114337j:plain

昼食はコワーキングスペースの外にキッチンカーが来ており、

僕はココナッツチキンカレーを頼みました!

美味しかったです、そして健康的!!!

食べ終わった後、

施設内のキッチンで自分の使った食器を洗うと50円引きしてくれます!!!

 

食器洗いをしている時に、

〜日より移住して来ました」という声がちらほら聞こえていたのですが、

これだけ居心地の良い場所なら移住したくなる気持ちが分かるなと思いました。

 

宿泊施設の様子

f:id:kyoK:20201109144846j:plain

f:id:kyoK:20201109163629p:plain

(森のオフィス ホームページより)

 

 

コワーキングスペースの隣にある宿泊施設はとても綺麗でした!!!

今回は、偶然にも他の団体のお客さんがいなかったので貸し切り状態でした!

将来はこんな家に住みたい、、、思わずそう思ってしまいました!

 

f:id:kyoK:20201109163046j:plain

 

夕食は施設の近くにある

生産者直売所の「たてしな自由農村」で

猪や鹿のジビエ、野菜、地酒などを購入し、

宿泊施設のキッチンで調理して食べました!!

 

f:id:kyoK:20201109161807j:plain

特にジビエが最高でした!!!

f:id:kyoK:20201109163033j:plain

 

美味しいご飯を食べながら、

普段はしないような話をできましたし、

よりコミュニケーションが深まりました!!

これもまた、開発合宿の良さなのではないかと思います!

 

 

途中から登山合宿に・・・ 

初日にコワーキングスペースの管理者の方から、

今の時期(11月初旬)は宿泊施設の近くにある

富士見パノラマリゾートという施設のゴンドラで山を登ると

日の出と共に雲海見えるよ!というお話を聞いたので、

メンバー皆で早朝4時に起きて車でゴンドラに乗りに行きました!

 

ところが、、、

 

その日に限って、ゴンドラは営業しておらず雲海を見ることはできませんでした、、

(雲海みたかった、、、、)

実際の雲海はこんな感じだそうです。

 

https://www.fujimipanorama.com/

 

せっかく早起きしたのに、このまま帰るのも勿体なかったので、

せめて高台で日の出でも見ようか!という話になり、

歩いて山道を登り、高台を目指しました!

 

f:id:kyoK:20201109180008j:plain

 

少し歩いたら高台に着くだろうと安易な考えで登り始めましたが、

そんなに甘くはありませんでした、、

 

予定より時間がかかった事もあり、

登っている途中で日の出を迎えることになりました。

完全に予定外でしたが、これはこれで綺麗でした!!

 

f:id:kyoK:20201109181538j:plain

 

結果的に、山道を往復で2時間程歩いてきました!

良い運動になったと言えばなりましたが、

流石に少しだけ疲れましたね、、、

 

 

最後に

2日間通して、普段では体験できない事だらけの開発合宿になりました!!

今回合宿に参加したメンバーの全員、

開発合宿に行ってよかったと思っていましたし、

もし次回行く事があれば、次は場所をどこにしようか!

次の合宿までにサービス改善案を出して、合宿内で開発しきろう!

などと行った案が出ています!

 

ストレスフリーで作業効率がめちゃめちゃ上がりメンバーとも仲が深まる!

開発合宿は良い事づくめです!

普段、都内で開発業務に勤しんでいる方、

ストレスフリーで開発をしたい方、

今まで以上にメンバーとの仲を深めたい方は

是非、開発合宿に行ってみてはいかがでしょうか?

 

エンジニア歴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