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

【話題沸騰中】軽量でお洒落なコマンドプロンプト拡張ライブラリ「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