TECH LEAD Blog

IT・WEBエンジニアのキャリア相談/転職支援サービス【TECH LEAD(テックリード)】が、エンジニアキャリアに関する記事やtwitterアンケート企画「エンジニア世論調査」の結果を投稿しています。

【インフラ編】TECH LEADの技術を惜しげも無く公開します!React × PHP Laravel × BEAR.Sunday × SSR

こんにちは、@TECH LEADです。

今回、「TECH LEADの技術を惜しげも無く公開します!」シリーズの最後となるインフラ編になります。

  1. アーキテクチャー編
  2. フロントエンド編
  3. サーバーサイド(アプリケーション層)編
  4. サーバーサイド(内部API層)編
  5. インフラ編 ←いまここ

目次

主な技術 

Amazon EC2

Amazon Aurora

Amazon S3

Elasticsearch 6.4

nginx

PHP-FPM

インフラツール

Terraform

Ansible  

インフラ構築

TECH LEADでは、インフラをTerraformというオーケストレーションツールとAnsibleという構成管理ツールを使用してAWS上に構築しています。

もちろんAWSのコンソールからインフラを作ることもできますが、Terraform・Ansibleを使用している理由は下記にあります。

  • 構成したい要素を書くだけでいい
  • 設定内容がすぐにわかる
  • 再現性がある(同じ環境をすぐ作れる)

Terraformを使って、サーバ・DNSAmazon Auroraなどの設定を全て行い、AnsibleでサーバにnginxやPHP、Elasticsearchなどのインストール、設定をしています。

インフラ構成

TECH LEADのインフラ構成
TECH LEADのインフラ構成

TECH LEADサービスは、上図のようなインフラ構成になっています。

ネットワークに関して

TECH LEADでは、VPCのサブネットを4分割し、パブリックなものを2つ、プライベートなものを2つ用意し、それぞれでアベイラビリティーゾーンを分け、片方のサーバで障害が起きたとしても片方は稼働できるようにゾーンを分けています。

また、ALB(Application Load Balancer)を介して、パブリックに配置したアプリケーションのEC2インスタンスへアクセスさせ負荷を分散させます。

しかし、現状はサービスの規模が小さく、コストがかかるためEC2インスタンスを1つしか用意していません。ただ、今後のサービスが大きくなることを見越してALBで負荷を分散できる様な構成にしています。

ミドルウェアに関して

TECH LEADサービスでは、求人検索などにElasticsearchを使用しています。

当初Amazon Elasticsearch Serviceを使うこと検討しましたが、Kuromojiのユーザー定義辞書を使うことができないため見送り、EC2インスタンスにElasticsearchをインストールして使用しています。

本来はプライベートサブネットに配置すべきものですが、NATの設定などをする必要があるため、一旦パブリックに配置しています。将来的にはプライベートに移す予定です。

Amazon Auroraはプライベートなサブネットグループに配置し、VPCからしかアクセスできないようにしています。

メール送信に関して

メール送信は、SendGridというメール配信サービスを利用しています。

SendGridを利用することによって、メールサーバを管理する必要がなくなり、メールの送信状況や開封率などの情報を簡単に取得することができます。

SendGridは、簡単に利用開始することができ、100通/日まで無料で利用できます。

インフラモニタリング

最近、TECH LEADサービスの稼働状況をモニタリングするためにDATADOGというインフラモニタリングツールを導入しました。

アプリケーションの稼働状況やディスク使用量などをモニタリングし、アラートをslackに通知するようにしています。

DATADOGは、カスタマイズ性が高く、管理画面上で簡単に独自のメトリクスを作れ、表示方法を豊富なテンプレートから選ぶことができます。

最近導入したばかりなのでカスタマイズできていませんが、色々な数値を可視化できるようにし、TECH LEADサービスをモニタリングしていきたいと思います。

DATADOGダッシュボード
DATADOGダッシュボード

まとめ

インフラを構築する際は、TerraformやAnsibleなどのツールを活用して行なった方がいいと思います。

これらのツールを使用しないで複数インスタンスなどを作成しなければならない場合、差分ができてしまったり、漏れが生じる可能性が非常に高いです。1台だけならコンソール上でやったほうが簡単かもしれませんが、スケールすることを考えるのなら最初からTerraformやAnsibleなどのツールを使いましょう。

TECH LEADのインフラは、まだサービス規模が小さいので最小限の構成にしていますが、将来を見越してスケールできる様にしています。

みなさんTECH LEADサービスの応援よろしくお願いします!

エンジニアの皆さんへお願い

5回に渡って、TECH LEADサービスの技術を紹介してきましたが、いかがでしたでしょうか。すこしでもみなさんが開発する際の参考になればと思います。

もし、「ここをもっと聞きたい」「うちではこうやっている」と言ったご意見・アドバイスなどありましたら、@TECH LEADまでリプライやDMにてお気軽にお知らせください!

よろしくお願いします!

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

PR

「今は具体的な転職を考えていないよ」と言う方も、是非一度TECH LEADの各サービスを触ってみていただけると嬉しいです。

TECH LEAD Job

IT/WEBエンジニア専門の求人サービス|TECH LEAD Job

TECH LEAD Resume

TECH LEAD Resume|IT・WEBエンジニア向けレジュメ管理サービス

TECH LEAD Agent

IT/WEBエンジニア専門の転職支援サービス|TECH LEAD Agent

 

【サーバーサイド(内部API層)編】TECH LEADの技術を惜しげも無く公開します!React × PHP Laravel × BEAR.Sunday × SSR

こんにちは、@TECH LEADです。

第4回となる今回は、TECH LEADのサーバーサイド(内部API層)の技術をご紹介します。

  1. アーキテクチャー編
  2. フロントエンド編
  3. サーバーサイド(アプリケーション層)編
  4. サーバーサイド(内部API層)編 ←いまここ
  5. インフラ編

目次

はじめに

内部API層は、データベース・Elasticsearchの操作、メール送信処理などに直接アクセスし、それらの処理をラップ(抽象化)しています。

各アプリケーションからのリクエストを元に、データベースなどを操作し、データを作成・取得・更新をしています。

TECH LEADのアーキテクチャー
TECH LEADのアーキテクチャ

内部API層では、Bear.Sundayというフレームワークを使用しています。あまり聞いたことがないという方もいると思いますので、Bear.SundayというPHPフレームワークの紹介と内部API層で実装されているコードを紹介したいと思います。

主な技術 

PHP 7.2

フレームワーク

BEAR.Sunday

BEAR.Sundayの紹介

BEAR.Sundayは、リソース指向のPHPアプリケーションフレームワークです。

リソース指向とは、RESTリソースを元にした考え方です。

MVCのようにControllerがModelからデータを受け取り、Viewへ渡すのではなく、各リソースがレンダラーを持ちリソースを表現します。

Resource-Method-Representation
Resource-Method-Representation

BEAR.Sundayは3つのオブジェクトフレームワークで構成されています。

今回は、一番特徴的なBEAR.Resourceについて少し紹介したいと思います

BEAR.Resouceは、REST(Hypermedia)のような振る舞いを可能にするハイパーメディアフレームワークです。

1つのURIのリソースは、1つのクラス(リソースオブジェクト)でマッピングされます。 リクエストによってリソースの状態をつくり、リソース自身が持っているレンダラーによって、表現、アウトプットします。

また、<a>タグの様にリソースに関連するリソースを紐づけたり、<img>タグの様にリソースに別のリソースを埋め込むが事ができます。

これからTECH LEADのコードを見ながら説明していきたいと思います。

参考

https://github.com/bearsunday/BEAR.Sunday

http://bearsunday.github.io/manuals/1.0/ja/index.html

https://ja.wikipedia.org/wiki/Representational_State_Transfer

※今回、説明から省かせていただきましたが、Ray.DIと Ray.AOPも興味のある方は、是非調べてみてください!

内部API層の実装

ディレクトリ構成

.api/
├── bootstrap/ 
├── src/Resource/
|  └── App/
|    |                     # データベース
|    ├── Common/           # サービス共通のリソース(ユーザー情報・マスターデータなど)
|    ├── Agent/            # TECH LEAD Agentのリソース
|    ├── Job/              # TECH LEAD Jobのリソース
|    ├── Resume/           # TECH LEAD Resumeのリソース
|    |
|    ├── Searcher/         # Elasticsearchのリソース
|    └── Mail/             # メール送信のリソース
├── var/
├── vender/
├── api.php                # アプリケーション層で読込用のエントリポイント
|                          # 内部API層をインスタンス化しものを返す
├── autoload.php
├── composer.json
└── composer.lock

今回は、データベースのリソースを説明します。

TECH LEADサービスでは、1つのデータベース上に全てのサービスのデータが入っています。 サービス固有のデータに関しては、テーブル名にuser_resume_(TECH LEAD Resume)のようなプレフィックスをつけ、どのサービスで使用しているテーブルか識別しやすくしています。 また、リソースはテーブル名と対応させ、テーブル毎にネームスペースを用意してリソースオブジェクト(クラス)を作成しています。

TECH LEAD Resumeのプロジェクト取得

今回は例として、TECH LEAD Resumeのプロジェクト(user_resume_projectsテーブル)取得の部分を紹介します。

<?php
// .api/src/Resource/App/Resume/UserResumeProject/Index.php
namespace GlobalShift\TechLead\Api\Resource\App\Resume\UserResumeProject;

use BEAR\Resource\ResourceObject;
use BEAR\Sunday\Inject\ResourceInject;
use Koriym\HttpConstants\StatusCode;
use Ray\AuraSqlModule\AuraSqlDeleteInject;
use Ray\AuraSqlModule\AuraSqlInject;
use Ray\AuraSqlModule\AuraSqlInsertInject;
use Ray\AuraSqlModule\AuraSqlSelectInject;
use Ray\AuraSqlModule\AuraSqlUpdateInject;
use Ray\Validation\Annotation\OnFailure;
use Ray\Validation\Annotation\OnValidate;
use Ray\Validation\Annotation\Valid;
use Ray\Validation\FailureInterface;
use Ray\Validation\Validation;

class Index extends ResourceObject
{
    use AuraSqlInject;
    use AuraSqlDeleteInject;
    use AuraSqlInsertInject;
    use AuraSqlSelectInject;
    use AuraSqlUpdateInject;
    use ResourceInject;

    public function onGet(int $user_account_id) : ResourceObject
    {...}

    /**
     * @Valid("put")
     */
    public function onPut(
        int $id,
        int $user_account_id,
        string $name,
        string $start_date,
        int $user_resume_office_position_id,
        int $user_resume_business_work_id = null,
        string $company_name = null,
        bool $is_secret = null,
        string $end_date = null,
        string $note = null,
        int $headcount = null,
        string $reference_url = null,
        int $user_resume_business_field_id = null
    ) {...}

    /**
     * @OnValidate("put")
     */
    public function onPutValidate(
        int $id,
        int $user_account_id,
        string $name,
        string $start_date,
        int $user_resume_office_position_id,
        int $user_resume_business_work_id = null,
        string $company_name = null,
        bool $is_secret = null,
        string $end_date = null,
        string $note = null,
        int $headcount = null,
        string $reference_url = null,
        int $user_resume_business_field_id = null
    ) {....}

    /**
     * @OnFailure("put")
     */
    public function onPutFailure(FailureInterface $failure)
    {...}

    /**
     * @Valid("post")
     */
    public function onPost(
        int $user_account_id,
        string $name,
        string $start_date,
        int $user_resume_office_position_id,
        int $user_resume_business_work_id = null,
        string $company_name = null,
        bool $is_secret = null,
        string $end_date = null,
        string $note = null,
        int $headcount = null,
        string $reference_url = null,
        int $user_resume_business_field_id = null
    ) {...}

    /**
     * @OnValidate("post")
     */
    public function onValidate(
        int $user_account_id,
        string $name,
        string $start_date,
        int $user_resume_office_position_id,
        int $user_resume_business_work_id = null,
        string $company_name = null,
        bool $is_secret = null,
        string $end_date = null,
        string $note = null,
        int $headcount = null,
        string $reference_url = null,
        int $user_resume_business_field_id = null
    ) {...}

    /**
     * @OnFailure("post")
     */
    public function onFailure(FailureInterface $failure)
    {...}

    /**
     * @Valid("delete")
     */
    public function onDelete(int $id, int $user_account_id)
    {...}

    /**
     * @OnValidate("delete")
     */
    public function onDeleteValidate(
        int $id,
        int $user_account_id
    ) {...}

    /**
     * @OnFailure("delete")
     */
    public function onDeleteFailure(FailureInterface $failure)
    {...}

    private function deleteProjectsTags(int $user_resume_project_id, int $user_account_id)
    {...}

    private function deleteProjectsSkills(int $user_resume_project_id, int $user_account_id)
    {...}
}

リソースオブジェクトのonGet() onPost() onPut() onDelete()の各メソッドは、それぞれHTTPメソッドに対応しています。

/resume/user-resume-project/indexにGETリクエストをするとIndexクラスのonGet()メソッドが呼び出されます。ここでは、引数に$user_account_idが必要なので、/resume/user-resume-project/index?user_account_id=1の様にパラメータをつけてリクエストします。

IndexクラスのonGet()内を詳しく見ていきます。

<?php

public function onGet(int $user_account_id) : ResourceObject
    {
        $this->select->cols(['*'])
            ->from('user_resume_projects');
            ->where('user_account_id = ?', $user_account_id)
            ->orderBy(['start_date DESC']);

        // ユーザーに紐づく全プロジェクトを取得
        $userResumeProjects = $this->pdo->fetchAll($this->select->getStatement(), $this->select->getBindValues());

        $data = [];

        // プロジェクトの関連情報も取得する
        foreach ($userResumeProjects as $key => $project) {
            $data[$key] = [];

            $request = $this
                ->resource
                ->get
                ->uri('app://self/resume/user-resume-project/detail')
                ->withQuery(['user_account_id' => $user_account_id, 'id' => $project['id']]);

            $request->linkCrawl('user_resume_projects_tags');

            // 外部キーがない場合は、クロールさせない
            // キーがない状態でクロールすると落ちる
            if ($project['user_resume_office_position_id']) {
                $request->linkCrawl('user_resume_office_position');
            }
            if ($project['user_resume_business_work_id']) {
                $request->linkCrawl('user_resume_business_work');
            }
            if ($project['user_resume_business_field_id']) {
                $request->linkCrawl('user_resume_business_field');
            }

            $request->linkCrawl('user_resume_projects_skills');

            $request->request();

            $data[$key] = $request->body;
        }

        $this->body = $data;

        return $this;
    }

IndexクラスのonGet()では、まずユーザーに紐づく全てのプロジェクト情報を取得しています。

ユーザーに紐づくプロジェクトを取得したら、プロジェクト情報に紐づくポジションや企業情報などを取得するために、/resume/user-resume-project/detailへGETリクエストし、プロジェクトのリレーションデータを取得しています。

linkCrawl()は、リソースオブジェクトに設定されている@Linkアノテーションクロールを実行し、リレーションデータを収集してくれます。

DetailクラスのonGet()をみて見ましょう。

<?php
// .api/src/Resource/App/Resume/UserResumeProject/Detail.php
namespace GlobalShift\TechLead\Api\Resource\App\Resume\UserResumeProject;

use BEAR\Resource\Annotation\Link;
use BEAR\Resource\ResourceObject;
use BEAR\Sunday\Inject\ResourceInject;
use Koriym\HttpConstants\StatusCode;
use Ray\AuraSqlModule\AuraSqlInject;
use Ray\AuraSqlModule\AuraSqlSelectInject;

class Detail extends ResourceObject
{
    use AuraSqlInject;
    use AuraSqlSelectInject;
    use ResourceInject;

    /**
     * @Link(crawl="user_resume_projects_tags", rel="user_resume_projects_tags", href="app://self/resume/user-resume-projects-tag/index?user_resume_project_id={id}&user_account_id={user_account_id}")
     * @Link(crawl="user_resume_projects_skills", rel="user_resume_projects_skills", href="app://self/resume/user-resume-projects-skill/index?user_resume_project_id={id}&user_account_id={user_account_id}")
     * @Link(crawl="user_resume_business_work", rel="user_resume_business_work", href="app://self/resume/user-resume-business-work/detail?id={user_resume_business_work_id}&user_account_id={user_account_id}")
     * @Link(crawl="user_resume_business_field", rel="user_resume_business_field", href="app://self/resume/user-resume-business-field/detail?id={user_resume_business_field_id}")
     * @Link(crawl="user_resume_office_position", rel="user_resume_office_position", href="app://self/resume/user-resume-office-position/detail?id={user_resume_office_position_id}")
     */
    public function onGet(int $id, int $user_account_id) : ResourceObject
    {
        $this->select->cols(['*'])
            ->from('user_resume_projects')
            ->where('id = ?', $id)
            ->where('user_account_id = ?', $user_account_id);

        $userResumeProject = $this->pdo->fetchOne($this->select->getStatement(), $this->select->getBindValues());

        if (!$userResumeProject) {
            $this->code = StatusCode::BAD_REQUEST;
            $this->body = [];

            return $this;
        }

        $this->body = $userResumeProject;

        return $this;
    }
}

@Link(crawl="クロール名", rel="クロール結果がここで指定したkeyでbodyに入る", href="リクエストするリソースを指定")

Detailクラスでは、@Linkアノテーションのクロールを設定していて、プロジェクトのリレーションデータを収集できる様になっています。

クロールは、リソースの$body内の値を使用して、hrefに指定しているリソースへリクエストします。クロールした結果は、リクエストしたリソースオブジェクトのbody内にrelで指定したキーで入ります。

例:@Link(crawl="user_resume_projects_tags", rel="user_resume_projects_tags", href="app://self/resume/user-resume-projects-tag/index?user_resume_project_id={id}&user_account_id={user_account_id}")の場合、{id}{user_account_id}それぞれに$this->body['id']$this->body['user_account_id']の値が入ります。クロール結果は、$this->body['user_resume_projects_tags']に入ります。

/resume/user-resume-project/index?user_account_id=1にGETリクエストをした場合、レスポンスは下記の様なツリー構造のデータになります。

200 OK
content-type: application/hal+json
{
    "0": {
        "id": "1",
        "user_account_id": "1",
        "user_resume_business_work_id": "1",
        "name": "新規プロジェクト",
        "start_date": "2019-04-01",
        "end_date": null,
        "headcount": 10,
        "user_resume_business_field_id": 1,
        "reference_url": null,
        "note": null,
        "created_at": "2019-04-01 00:00:00",
        "updated_at": "2019-04-01 00:00:00",
        "user_resume_office_position_id": "1",
        "company_name": null,
        "is_secret": "0",
        "user_resume_projects_tags": [
            {
                "user_resume_project_id": "1",
                "user_resume_project_tag_id": "1"
            }
        ],
        "user_resume_office_position": {
            "id": "1",
            "name": "サーバーサイド",
            "sort_no": "1",
            "url_word": "sever-side"
        },
        "user_resume_business_work": {
            "id": "27",
            "name": "人材・HR",
            "sort_no": "5",
        },
        "user_resume_business_work": {
            "id": "1",
            "user_account_id": "1",
            "name": "グローバルシフト株式会社",
            "start_date": "2019-04-01",
            "end_date": null,
            "employment_pattern": "regular",
            "note": "",
            "created_at": "2019-04-01 00:00:00",
            "updated_at": "2019-04-01 00:00:00"
        },
        "user_resume_projects_skills": [
            {
                "id": "1156",
                "user_resume_project_id": "59",
                "skill_category_id": "1",
                "skill_id": "6",
                "name": null,
                "version": null,
                "skill_name": "PHP",
                "start_date": "2014-04-01",
                "end_date": null,
                "skill_category": {
                    "id": "1",
                    "name": "言語",
                    "sort_no": "1"
                },
                "skill": {
                    "id": "6",
                    "skill_category_id": "1",
                    "name": "PHP",
                    "sort_no": "6",
                    "group_type": null
                }
            }
        ]
    },
    "_links": {
        "self": {
            "href": "/resume/user-resume-project/index?user_account_id=1"
        }
    }
}

実装時に悩んだ事

開発時、内部API層はなるべくシンプルにデータの取得・更新をだけを行うように実装しようと考えていました。

しかし、実際にコーディングしていくとリレーションデータを含んだリストデータが必要になりましたが、クロールリンクはリストデータに対して、それぞれのリレーションデータを取得する事ができませんでした。

アプリケーション側で、まずリストデータを取得してからループでそれぞれのデータの関連データを取得するのが綺麗に書けると思いましたが、データが多いと何度も内部API層にリクエストをしなければならなくなり、重くなってしまいます。

上記のプロジェクト取得の場合、アプリケーション側で必ずリレーションデータを使用してページを描画していたため、onGet()内でlinkCrawl()を使ってリレーションデータを取得することにしました。※この方法だとリレーションデータが必要なくても収集してしまうデメリットもあります。

現状、データによって内部API層でリレーションデータを含んだリストデータを作成するパターンとアプリケーション側でリレーションデータを再度取得しているパターンの両方を場合によって使い分けています。

まとめ

ここまでBEAR.Sundayの紹介とTECH LEADでの実装を紹介しました。

BEAR.Sundayは、RESTをアプリケーションのフレームワークとしてコンポーネントを作成し、HTTPをアプリケーションプロトコルとして扱う新しいパターンのフレームワークです。

今までMVCパターンのフレームワークしか使った事がなく、初めてBEAR.Sundayを知った時は、「こんな考え方があるんだ!」「すごい!」「面白い!」と感じました。

DI(依存性の注入)やAOP(アスペクト指向プログラミング)もBEAR.Sundayを使って初めて知った技術だったのでエンジニアとして、とても勉強になるフレームワークだと思います。

機会があればAPIサーバなどに使ってみてください!

エンジニアの皆さんへお願い

もし、「ここをもっと聞きたい」「うちではこうやっている」と言ったご意見・アドバイスなどありましたら、@TECH LEADまでリプライやDMにてお気軽にお知らせください!

よろしくお願いします!

次回予告

次回は【インフラ編】で、AWSの設定・Terraform・Ansibleついて全体的に話したいと思います。

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

PR

「今は具体的な転職を考えていないよ」と言う方も、是非一度TECH LEADの各サービスを触ってみていただけると嬉しいです。

TECH LEAD Job

IT/WEBエンジニア専門の求人サービス|TECH LEAD Job

TECH LEAD Resume

TECH LEAD Resume|IT・WEBエンジニア向けレジュメ管理サービス

TECH LEAD Agent

IT/WEBエンジニア専門の転職支援サービス|TECH LEAD Agent

 

【サーバーサイド(アプリケーション層)編】TECH LEADの技術を惜しげも無く公開します!React × PHP Laravel × BEAR.Sunday × SSR

こんにちは、@TECH LEADです。

第3回となる今回は、TECH LEADのサーバーサイド(アプリケーション層)の技術をご紹介します。

  1. アーキテクチャー編
  2. フロントエンド編
  3. サーバーサイド(アプリケーション層)編 ←いまここ
  4. サーバーサイド(内部API層)編
  5. インフラ編

目次

はじめに

アーキテクチャー編でも少し説明させて頂きましたが、 TECH LEADサービスのサーバーサイドプログラム(PHP)は、大きく内部API層とアプリケーション層の2つに分かれています。

アプリケーション層は、フロントからのリクエストされたデータや処理を内部API層へリクエストし、結果をフロントへ返却する役割をしています。

TECH LEADのアーキテクチャー
TECH LEADのアーキテクチャ

今回は、前回の続きでSSR(サーバーサイドレンダリング)の実装部分と内部API層との連携部分に関して説明していきたいと思います。

主な技術 

PHP 7.2

フレームワーク

Laravel 5.6

ディレクトリ構成

.
|                   # 内部API層
├── api/            # [BEAR.Sunday]ミドルウェアの操作・メール送信
├── db/             # [Laravel] データベースのマイグレーション・シードを管理
└── web/                  # アプリケーション層
    ├── site-common/      # 共通で利用できるものを配置
    |                     # シンボリックリンクで各アプリケーションで使用
    ├── site-mypage/      # [Laravel] TECH LEAD共通部分
    |                     # アカウント設定・パスワード変更など
    ├── site-agent/       # [Laravel]TECH LEAD Agent
    ├── site-job/         # [Laravel]TECH LEAD Job
    └── site-resume/      # [Laravel]TECH LEAD Resume

サーバーサイドレンダリングに関して

TECH LEADでは、PHPのエクステンションのV8jsを使用してSSRを行なっています。

TECH LEAD Jobのトップページを例に説明していきたいと思います。

※フロントエンドの実装は、前回の記事をご確認ください。

<?php

namespace App\Http\Controllers\Site;

use App\Models\Job\Posting;
use App\Models\UserAccount;
use GlobalShift\TechLead\Models\Api\Searcher\JobPosting as JobPostingSearcher;

class IndexController extends Controller
{
    public function index()
    {
        /* @var UserAccount $userAccount */
        $userAccount = $this->loginUserAccount();

        if ($userAccount) {
            return redirect()->route('site/postings/index');
        }

        // 内部API層へリクエスト
        $result = (new JobPostingSearcher())->cachedSearch(
            ['public_status' => Posting::PUBLIC_STATUS_OPEN],
            1,
            Posting::NEWER_SORT
        );

        if ($result->code !== 200) {
            abort(404);
        }

        $searchResult = collect($result->body);

         // SSR用のHTML作成
        $html = $this->getSsrHtml('index', [
            'postings' => $searchResult->get('sources', []),
        ]);

        return view('site.ssr', [
            'html' => $html,
        ]);
    }
}
<?php

namespace App\Http\Controllers\Site;

use App\Http\Controllers\Controller as BaseController;
use App\Models\UserJob\Information;
use GlobalShift\TechLead\Models\Api\UserJob\Information as ApiInformation;

class Controller extends BaseController
{

   // 省略

    protected function getSsrHtml(string $page, array $payload = [])
    {
        $v8js = new \V8Js();

        // 共通して必要な環境変数・パスなど
        $variables = [
            'env' => config('app.env'),
            'flash_message' => htmlspecialchars(json_encode($this->flashMessage())),
            'path' => [
                'index_js_path' => route('/js/site_csr.js'),
                'index_css_path' => route('/css/site.css'),
            ],
        ];


        // V8jsで実行させるJavascript(文字列)
        $code = <<<EOT
%s
VARIABLES=%s
global.render(%s, %s);
EOT;

        $code = sprintf(
            $code,
            file_get_contents(__DIR__.'/../../../../public/js/site_ssr.js'),
            json_encode($variables),
            '"'.$page.'"',
            json_encode($payload)
        );

        return $v8js->executeString($code);
    }

    private function flashMessage()
    {
        return [
            'success' => implode(',', session()->pull('success', [])),
            'error' => implode(',', session()->pull('error', [])),
        ];
    }
}
<!-- web/site-job/resources/views/site/ssr.twig -->
{{ html|raw }}

まず、必要なデータを内部API層へリクエストし、収集します。次にgetSsrHtmlメソッドにレンダリングしたいページ名とページの生成に必要なデータを渡し、HTMLを生成します。

getSsrHtmlメソッドでは、フロントエンドでSSR用に作成したエントリポイント(site_ssr.js)を読み込み、global.renderを実行するJavascriptのコードを文字列で作成します。

V8jsのexecuteStringを使用して、上記のJavascriptコードを実行し、結果(HTML)を返します。

ssr.twigは、PHPのviewテンプレートでSSRするページ共通で使用していて、コントローラから渡されたHTMLをそのまま表示しています。

SSRには、まだ課題があり対応を検討中です。(参照:フロントエンド編)

内部APIとの連携に関して

アーキテクチャー編で少し説明させて頂きましたが、現状、同一サーバ上で全てのサービスが動いてHTTPでリスエスト処理をするには、まだサービスの規模が小さく、HTTP通信にすることによるオーバーヘッドが大きくなってしまうので、現在は内部API層(BEAR.Sunday)をインスタンス化したものをアプリケーション層で読み込み、内部API層の機能を利用してデータの取得などを行なっています。

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot()
    {
        // 内部API層のインスタンスを注入
        $this->app['api'] = require dirname(__DIR__, 4).'/api/api.php';
    }

    /**
     * Register any application services.
     */
    public function register()
    {
        require dirname(__DIR__).'/helpers.php';
    }
}
<?php

namespace App\Traits;

trait DBApiTrait
{
    /**
     * @return \BEAR\Sunday\Extension\Application\AbstractApp
     */
    protected function dbApi()
    {
        // 内部API層のインスタンスを呼び出す
        return app()['api'];
    }
}

アプリケーション層の実際にデータを取得するための部分は、./web/site-common/app/Models/Api/以下に定義し、composerのautoload機能を利用してアプリケーション層(各サービス)で利用できるようにしています。

    "autoload": {
        "psr-4": {
            "App\\": "app/",
            "GlobalShift\\TechLead\\": "../site-common/app"
        }
    },

実際に都道府県データを取得するためのコードを紹介します。

<?php

use GlobalShift\TechLead\Models\Api\Prefecture;

$result = (new Prefecture())->all();

// body内に内部API層で取得したデータ入っている
$result->body;
// ./web/site-common/app/Models/Api/Prefecture.php
<?php

namespace GlobalShift\TechLead\Models\Api;

class Prefecture extends Base
{
    public function all()
    {
        // 内部API層の都道府県取得部分にリクエスト
        $result = $this->get('app://self/common/prefecture/index');

        return $result;
    }

    public function findByCode(string $code)
    {
        $result = $this->get('app://self/common/prefecture/detail', ['code' => $code]);

        return $result;
    }

    public function findByUrlWord(string $urlWord)
    {
        $result = $this->get('app://self/common/prefecture/detail', ['url_word' => $urlWord]);

        return $result;
    }
}
// ./web/site-common/app/Models/Api/Base.php
<?php

namespace GlobalShift\TechLead\Models\Api;

use App\Traits\DBApiTrait;

abstract class Base
{
    use DBApiTrait;

    /**
     * @return \BEAR\Resource\ResourceObject | \BEAR\Resource\Request
     */
    protected function cloneGet($uri, $queries = [], $crawls = [])
    {
        // `$this->dbApi()`は内部API層のインスタンス
        $resource = $this->dbApi()->resource;
        $request = $resource->get->uri($uri)->withQuery($queries);

        foreach ($crawls as $crawl) {
            $request->linkCrawl($crawl);
        }

        return $request->request();
    }

    /**
     * @return \BEAR\Resource\ResourceObject | \BEAR\Resource\Request
     */
    protected function get($uri, $queries = [], $crawls = [])
    {
        $request = $this->dbApi()->resource->get->uri($uri)->withQuery($queries);

        foreach ($crawls as $crawl) {
            $request->linkCrawl($crawl);
        }

        return $request->request();
    }

    /**
     * @return \BEAR\Resource\ResourceObject | \BEAR\Resource\Request
     */
    protected function post($uri, $data)
    {
        return $this->dbApi()->resource->post->uri($uri)->withQuery($data)->request();
    }

    /**
     * @return \BEAR\Resource\ResourceObject | \BEAR\Resource\Request
     */
    protected function put($uri, $data)
    {
        return $this->dbApi()->resource->put->uri($uri)->withQuery($data)->request();
    }

    /**
     * @return \BEAR\Resource\ResourceObject | \BEAR\Resource\Request
     */
    protected function delete($uri, $data)
    {
        return $this->dbApi()->resource->delete->uri($uri)->withQuery($data)->request();
    }
}

※内部API層(BEAR.Sunday)の詳細は次回のブログで紹介します。

まとめ

ここまで、サーバーサイドのアプリケーション層に関して、一部になりますが説明させて頂きました。

弊社のエンジニアチームでは、このような複数サービスを横断的に利用できるWEBサービスの開発をした経験がなかったため、どのように実装するかとても悩みました。いざ実装していくと想定外の問題にぶつかったり、改善すべき点が見つかったりまだまだ試行錯誤しながら開発しています。

是非、同じようなサービスを開発している方や同じような課題を持っている方とお話ししてみたいです。興味がある方は是非お声がけください!

エンジニアの皆さんへお願い

もし、「ここをもっと聞きたい」「うちではこうやっている」と言ったご意見・アドバイスなどありましたら、@TECH LEADまでリプライやDMにてお気軽にお知らせください!

よろしくお願いします!

次回予告

次回は【サーバーサイド(内部API層)編】で、BEAR.Sundayの紹介と内部API層の実装ついて話したいと思います。

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

PR

「今は具体的な転職を考えていないよ」と言う方も、是非一度TECH LEADの各サービスを触ってみていただけると嬉しいです。

TECH LEAD Job

IT/WEBエンジニア専門の求人サービス|TECH LEAD Job

TECH LEAD Resume

TECH LEAD Resume|IT・WEBエンジニア向けレジュメ管理サービス

TECH LEAD Agent

IT/WEBエンジニア専門の転職支援サービス|TECH LEAD Agent

 

【フロントエンド編】TECH LEADの技術を惜しげも無く公開します!React × PHP Laravel × BEAR.Sunday × SSR

こんにちは、@TECH LEADです。

前回の「【アーキテクチャー編】TECH LEADの技術を惜しげも無く公開します!」は、たくさんの方に読んでいただきありがとうございました。

第二回となる今回は、TECH LEADサービスでのフロントエンドの実装をご紹介します。

  1. アーキテクチャー編
  2. フロントエンド編 ←いまここ
  3. サーバーサイド(アプリケーション層)編
  4. サーバーサイド(内部API層)編
  5. インフラ編

目次

はじめに

最近、ReactやVueなどのJavaScriptフレームワークを使い、フロントエンドの開発を進める企業やサービスが増えてきていると思います。どんどん進化して行くフロントエンドの技術をどのように実装して行くか悩む方がたくさんいると思います。TECH LEADサービスでもフロントエンドの開発でとても悩みました。

弊社では約1年半くらい前からReactを使用したアプリケーションを開発してきました。

最初に使用したサービスではReact × React Router × ReduxでSPA(シングルページアプリケーション)を作成し、次のサービスではReact × React Router × Typescript × ReduxでSPAを作成しました。

TECH LEADサービスではReact × TypescriptでSSR(サーバーサイドレンダリング)+CSR(クライアントサイドレンダリング)で作成しています。

「なんでTECH LEADはSPAじゃないのか?」と疑問に思った方もいると思いますので、その理由を説明したいと思います。

SPAを辞めたわけ

2つのサービスをSPAで作成しましたが、開発中にSPAである必要があったのかと考えさせられることがあったので他の手段や方法を検討するためにもTECH LEADでは、一旦SPAをやめて開発をすることにしました。

SPAを開発する上で大きく3つの課題がありました。

  • セッション管理が大変
  • Reduxの処理を書くのが大変
  • SEOやOGPなどの対応が難しい

SPAを使った場合、ルーティングの設定、ログイン認証処理などフロントエンドでやることが増えます。 SSR/CSRの場合、ルーティングの設定もセッション管理も完全にサーバーサイドに任せてしまえばいいので、ページを描画することだけに専念させることができ、SPAをやめたことでReduxを使う必要もなくシンプルにコードを書くことができました。

※Reduxはとても便利ですが、コードを書く量が多く、保存されたデータの初期化のタイミングなど考えることが多く大変でした。。。

また、パブリックなページに関しては、SEOやOGPなどの設定や処理を入れるのが大変そうだったり、きちんと評価されるか不安だったため、確実に対応できそうなSSRで対応することにしました。

※以前の2つのサービスは、プライベートなページのみだったのでSEOを気にする必要はなかった

Reactを勉強している方や使用を検討している方に少しでも参考になるよう、一部にはなりますが詳しく公開したいと思いますので、よろしくお願いします。

TECH LEADのフロントエンド

主な技術 

React 16.4

Typescript 2.6

Sass

webpack 4

ディレクトリ構成

.
├── common/             # `web/site-common/js`へのシンボリックリンク
|                       # 各サービス共通で使用する関数やコンポーネントを配置
├── components/         # ステートレスコンポーネントを配置
├── containers/         # renderされるメインのステートフルコンポーネントを配置
├── modules/            # api通信用の関数を配置
├── pages/              # SSR時に使用するコンポーネントを配置
├── const.d.ts          # Typescriptの型定義ファイル
├── index.tsx           # CSR用のエントリーポイント
├── index_csr.tsx       # SSR後にCSRする用のエントリーポイント
└── index_ssr.tsx       # SSR用のエントリーポイント

サーバーサイドレンダリングに関して

今回はTECH LEAD JobがどのようにSSRされ、ページを作っているか実際のコードを元に説明します。

※参考:TECH LEAD Jobトップページ

※サーバーサイドの実装の詳細は、次回「サーバーサイド(アプリケーション層)編」で公開します。

実際に使用するファイル(一部抜粋)

.
├── pages/ 
│  ├── Index.tsx        # トップページのコンポーネント
│  ├── layout.tsx       # レイアウトファイル
│  └── variables.tsx    # コンポーネントをまとめたもの
├── index_csr.tsx       # SSR後にCSRする用のエントリーポイント
└── index_ssr.tsx       # SSR用のエントリーポイント
# index_ssr.tsx
import * as React from 'react';
import {renderToString} from 'react-dom/server';
import Helmet from 'react-helmet';
import {layout} from './pages/layout';
import {pages} from './pages/variables';

const render = (page: string, payload: object) => {
  const element = pages[page];

  const component = renderToString(
    <React.Fragment>
      <Helmet>
        <title>IT/WEBエンジニア専門の求人サービス|TECH LEAD Job</title>
        <meta name="description" content="TECH LEAD Job(テックリードジョブ)は、IT・WEBエンジニア専門の求人サービスです。"/>
        <meta property="og:title" content="IT/WEBエンジニア専門の求人サービス|TECH LEAD Job"/>
        <meta property="og:type" content="website"/>
        <meta property="og:url" content="https://job.techlead.jp/"/>
        <meta property="og:image" content="https://job.techlead.jp/img/sns-ogp.png"/>
        <meta property="og:site_name" content="TECH LEAD Job"/>
        <meta property="og:description" content="TECH LEAD Job(テックリードジョブ)は、IT・WEBエンジニア専門の求人サービスです。"/>
        <meta name="twitter:card" content="summary_large_image"/>
        <meta property="fb:app_id" content="762141787490152"/>
      </Helmet>
      {React.cloneElement(element, {...payload})}
    </React.Fragment>
  );

  return layout(component);
};

declare const global: {render: any};

global.render = render;

index_ssr.tsxSSR用のエントリーポイントになります。

サーバーサイドではV8jsを利用して、「SSR用のエントリーポイント」を読み込み、グローバルに用意したrender関数を実行・HTMLを生成・HTMLを返します。

render関数には、呼び出すコンポーネント(ページ)のキーとPropsの値を渡します。

React.cloneElement(element, {...payload})の部分で対象のコンポーネントを渡されたPropsで生成し、renderToString関数でコンテンツ部分のHTMLを取得します。

コンテンツ部分のHTMLができたらlayout関数で、ページに必要なheadの情報や共通部分を追加したHTMLを生成し、HTMLを返します。

サーバーサイドは、この生成されたHTMLをレンダーしてページを表示します。

# variables.tsx
import * as React from 'react';
import {defaultValues as indexValues, Index} from './Index';

export const pages: {
  [key: string]: JSX.Element;
} = {
  index: <Index {...indexValues}/>,
};

※呼び出すコンポーネント(ページ)とキーの組み合わせをまとめている

# layout.tsx
import Helmet from 'react-helmet';

export const layout = (body: string) => {
  const helmet = Helmet.renderStatic();

  return (
    `
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
  <meta name="format-detection" content="telephone=no">
  <link rel="shortcut icon" href="/img/favicon/favicon.ico">
  <link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-touch-icon.png">
  <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png">
  <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
  <meta name="msapplication-TileColor" content="#da532c">
  <meta name="theme-color" content="#ffffff">

  ${helmet.title.toString()}
  ${helmet.meta.toString()}
  ${helmet.link.toString()}
  ${helmet.script.toString()}

  <link href="https://use.fontawesome.com/releases/v5.1.0/css/all.css" rel="stylesheet">
  <link href="${VARIABLES.path.index_css_path}" rel="stylesheet" type="text/css">
</head>
<body>
  <div id="flashMessage" data-payload="${VARIABLES.flash_message}">
  </div>
  ${body}
  <script src="${VARIABLES.path.index_js_path}"></script>
</body>
</html>
    `
  );
};

※ページheadの情報は、React Helmetを使用してページごとに設定

※VARIABLESはサーバーサイドで設定した定数

# Index.tsx
import * as React from 'react';
import Helmet from 'react-helmet';
import SimpleSearchForm from '../containers/SimpleSearchForm';
import {LpJobCard} from '../components/LpJobCard';
import {agentUrl, jobUrl, resumeUrl, mypageUrl} from '../../common/util';
import {PostingModel} from '../../common/lib/models/JobInterface';

export const defaultValues = {
  postings: [],
};

interface Props {
  postings: PostingModel[];
}

export const Index: React.SFC<Props> = ({postings}) => {
  return (
    <React.Fragment>
      <Helmet>
        <script type="text/javascript" src="delighters.js"/>
      </Helmet>
      <header className="head head--lp">
        <div className="head__inner">
          <a href={jobUrl()} className="head__logo">
            <img alt="" className="card-lp__service--logo" src="/common/logo/TLJ.svg" height="25"/>
          </a>
          <div id={'headerSignArea'}/>
        </div>
      </header>
      <div className="lp-top">
        <div className="lp-top__content">
          <h1 className="lp-top__title">
            IT/WEBエンジニアに<br/>
            理想的な求人情報と
            <br/>スカウトを
          </h1>
          <p className="lp-top__txt">テックリードジョブは、エンジニアの得意な技術や仕事への志向性でマッチングする、IT/WEB業界に特化した新しい求人・転職サービスです。</p>
          <div className={'signupButton'} data-payload={JSON.stringify({label: '会員登録 (完全無料)'})}/>
        </div>
        <img className="lp-top__visual--job--main" alt="" src="/common/lp/job__keyvisual.png"/>
        <img className="lp-top__visual--job--bg1" alt="" src="/common/lp/lp-top__bg1--job.svg"/>
        <img className="lp-top__visual--job--bg2" alt="" src="/common/lp/lp-top__bg2--job.svg"/>
      </div>
      <div id={'simpleSearchForm'}>
        <SimpleSearchForm/>
      </div>
      <section className="lpj_sec" data-delighter="">
        <div className="container is-small">
          <div className="lpj_sec-title">
            <h2>
              フィット感の高さが伝わる、厳選された求人情報
            </h2>
          </div>
        </div>
        <ul className="lpj_job-list">
          {postings.map((posting, i) => (
            <LpJobCard
              key={i}
              posting={posting}
              jobPostingContent={posting.job_posting_content || {}}
              jobClient={posting.job_client || {}}
            />
          ))}
        </ul>
        <div className="has-text-center">
          <div className={'signupButton'} data-payload={JSON.stringify({label: '会員登録 (完全無料)'})}/>
        </div>
        <div className="container is-small mt-6">
          <div id={'campaignBanner'}/>
        </div>
      </section>

      <!-- 省略 -->

注意点

静的なHTMLが生成される

V8jsからは、静的なHTMLが生成されるのでコンポーネント内でonChangeイベントなどの処理があっても実行することができません。動的なコンポーネントはレンダーされた後に実行する必要があるので、「SSR後にCSRする用のエントリーポイント」を使い、マウントします。

例として、Index.tsxの下記の部分について説明します。

# Index.tsx
<!-- 省略 -->
  <div id={'simpleSearchForm'}>
    <SimpleSearchForm/>
  </div>
<!-- 省略 -->
# index_csr.tsx
import '@babel/polyfill';
import * as React from 'react';
import {render as reactRender} from 'react-dom';
import ErrorBoundary from '../common/components/common/ErrorBoundary';
import SimpleSearchForm from './containers/SimpleSearchForm';
import SignupButton from '../common/containers/common/SignupButton';

const renders: {
  [key: string]: JSX.Element;
} = {
  simpleSearchForm: <SimpleSearchForm/>,
  signupButton: <SignupButton/>,
};

const getElementData = (element: HTMLElement): object => {
  const data = element.dataset.payload;

  if (!data) {
    return {};
  }

  element.dataset.payload = '';

  return JSON.parse(data);
};

Object.keys(renders).forEach((name) => {
  const element = document.getElementById(name);

  if (element) {
    const data = getElementData(element);
    const component = React.cloneElement(renders[name], {...data});

    reactRender(
      <ErrorBoundary>
        {component}
      </ErrorBoundary>
      , element
    );
  }

  const elements = document.getElementsByClassName(name) as HTMLCollectionOf<HTMLElement>;

  for (const ele of Array.from(elements)) {
    const data = getElementData(ele);
    const component = React.cloneElement(renders[name], {...data});

    reactRender(
      <ErrorBoundary>
        {component}
      </ErrorBoundary>
      , ele
    );
  }
});

index_csr.tsxは、SSR後にCSRする用のエントリーポイントです。

index_csr.tsxでは、rendersに定義されたキーから対応する要素を探し、対応する要素があればコンポーネントをレンダーしています。また、対応する要素のdata属性を使用し、data-payloadがあれば格納されたJSONを初期描画に必要なPropsとして注入しています。

※最初は、HTMLのid属性でコンポーネントを指定いましたが、1ページに複数回出てくるようなコンポーネント(signupButtonコンポーネントなど)があったためclass属性でもレンダーさせています。

※マウントさせる要素の命名規則をきちんと作らないと意図せぬ場所へマウントされてしまうので気をつけてください。

※data属性を使う理由は、何度もサーバーサイドにリクエストされるのを防ぎ、高速化させるため

<SimpleSearchForm/><div id="simpleSearchForm"/>でラップしている理由は、<div id="simpleSearchForm"/>だけでレンダーしてしまうとindex_csr.tsxが実行されるまでは要素が空になってしまうからです。

ラップすることによって、初期描画時に<SimpleSearchForm/>で仮の要素を作り(動かない)、マウント時に動く要素と入れ替えることができます。これを行うことによって、初期描画からマウントされるまでの間に、要素が空になってしまうことやDOMが書き換わることによる画面のブレを防いでいます。

V8jsの罠

V8jsPHPのエクステンションで、インストールするとV8 Javascript EnginePHPに組み込まれJavascriptが実行できるようになります。

ただV8jsでは、setTimeoutなど使えない関数があるのでSSR時に使用するコンポーネントでは使わないように気をつけてください。

また、layout.tsxのhead部分にGoogle タグマネージャを埋め込み、アナリティクスなどの計測をさせていたのですが、V8jsのChromium上でも読み込まれ、実行されてしまっていたようで、計測の数値が倍くらいになってしまう問題が発生しました。

暫定的に、V8jsのChromium上にはwindowがないようだったので、windowの存在判定をして、実行されないように回避しました。

V8js上で、何が起こっているか確認する方法やデバッグがわからなかったため、暫定的な対応になってしまいたした。。。

詳しい方がいたらご教授いただきたいです。 よろしくお願いします。

クライアントサイドレンダリングに関して

SSR後にCSRする用のエントリーポイント」の動きとほぼ同じになります。

違いは、初期描画をPHPのビューテンプレートで行なっているのでメタ情報などはサーバーサイドで設定しているということと、使用するコンポーネントが異なることくらいです。

TECH LEADを作ってみて

「もう一度SPAを作ってみたい!」と思っています。

やはりSSRCSRだとサーバーサイドとフロントエンドの両方でviewの管理をしなければならなくなり役割が分散してしまうので、SPAにしてviewは全てReactで行うようにし、サーバーサイドは完全にAPIサーバのような形をとったほうがいいのではないかなと思ってきています。

また、今まで経験を生かしReduxの使いどころに注意しながらSPAを作っていきたいと思っています。

以前SPAを作った時は、Reduxに全てのデータを入れ、Stateの管理をまとめたり、コンポーネントにPropsを注入するのに使っていたため、ちょっと使いたいデータなどがあった時でも大量のコードを書かなければならなくて大変でした。

しかし、最近ReactにContext APIHooksといった機能が追加されたため、簡単なことや限定的はことだけどStateを使いたい・Propsを注入したいという時は、Reduxではなく簡単に使えるContextやHooksで実装したら楽にSPAがつくれそうだなと思っています。

まとめ

サーバーサイドレンダリング

  • メリット

    • 初期描画が早い
    • SEO対策・メタ情報の設定が楽
    • セッション管理が簡単
  • デメリット

    • 実装が難しい
    • サーバーサイドが大変

クライアントサイドレンダリング

  • メリット

    • 実装が簡単
    • セッション管理が簡単
  • デメリット

    • SEO対策・メタ情報の設定が大変・不安
    • DOMが空の状態でレンダーされる
    • 初期描画が遅い

シングルページアプリケーション

  • メリット

    • 実装が簡単
    • サーバーサイドが簡単
    • 一度読み込むと早い
  • デメリット

    • SEO対策・メタ情報の設定が大変・不安
    • 初期描画が遅い
    • セッション管理が難しい

SSRCSR・SPA、どれにもメリット・デメリットがあると思いますが、 フロントエンドの技術は、すごいスピードで進化していて「これがスタンダード」というような形は、まだまだ決まっていないと思います。なので開発する際は、アプリケーションやページに対して最適な方法を見極める必要があります。(それが難しいと思いますが。。。)

また、進化が早い分、常に新しい情報をチェックし、どんどん取り入れていく必要があると思います。

弊社では以前作ったSPAが大変だったことからSSRCSRでTECH LEADサービスを作りましたが、Reactへの理解も深まり、新たな機能が増えたことからSPAに変更することを検討しています。

おそらく、ログイン後のプライベートなページなどからの変えていくことになると思いますが、SPAを実装した際は、また皆さんに共有します!

エンジニアの皆さんへお願い

今回はTECH LEADのフロントエンドの実装方法を公開しましたが、いかがでしたでしょうか。すこしでも開発の参考になればと思います。

もし、「ここをもっと聞きたい」「うちではこうやっている」と言ったご意見・アドバイスなどありましたら、@TECH LEADまでリプライやDMにてお気軽にお知らせください!

よろしくお願いします!

次回予告

次回は【サーバーサイド(アプリケーション層)】の実装について話したいと思います。

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

PR

「今は具体的な転職を考えていないよ」と言う方も、是非一度TECH LEADの各サービスを触ってみていただけると嬉しいです。

TECH LEAD Job

IT/WEBエンジニア専門の求人サービス|TECH LEAD Job

TECH LEAD Resume

TECH LEAD Resume|IT・WEBエンジニア向けレジュメ管理サービス

TECH LEAD Agent

IT/WEBエンジニア専門の転職支援サービス|TECH LEAD Agent

 

【アーキテクチャー編】TECH LEADの技術を惜しげも無く公開します!React × PHP Laravel × BEAR.Sunday × SSR

こんにちは、@TECH LEADです。

現在TECH LEADでは、IT/WEBエンジニアのキャリアアップに関する3つのサービスを開発・運営しています。

IT/WEBエンジニア専門の求人サービス|TECH LEAD Job

IT/WEBエンジニア専門の転職支援サービス|TECH LEAD Agent

TECH LEAD Resume|IT・WEBエンジニア向けレジュメ管理サービス

現在はキャリア・転職関連のサービスを中心に展開していますが、今後は全てのIT/WEBエンジニアの皆さんに利用されるようなエンジニアリングのスキル・キャリアプラットフォームへと成長させていく事を目指しています。

2018年3月から本格的に自社サービスであるTECH LEADの開発がスタートしたのですが、スタート時にはサービスをどのように設計し、開発していくかについてとても悩みました。(今も悩んでます)

そんな時に他のサービスを参考にしたいと思い、いろいろなサービスを調べていく中で下記のようなことを強く感じました。

  • どのように設計されているのだろうか?

  • どのように実装されているのだろうか?

  • 中身を見てみたい

色々な会社のテックブログなどで使われている技術などの紹介をしていることは多いものの、詳細なアーキテクチャーや実装が書いてあるサービスはとても少ない印象でした。もちろん、セキュリティー上見せられないということもあるかと思いますが、もう少し詳細なところを知りたいな・参考にしたいなと思いました。

そこで「人がどうやっているのか教えて欲しい!」と言う前に、まず自社のサービスがどのように実装されているのか公開したいと思います。なるべく全体像や詳細な実装がわかるように詳しく書いていきたいと思いますので、よろしくお願いします。

今回を第一回として、今後以下の順番でブログを連載していきます。

  1. アーキテクチャー編 ←いまここ
  2. フロントエンド編
  3. サーバーサイド(アプリケーション層)編
  4. サーバーサイド(内部API層)編
  5. インフラ編

新規でサービスを作ろうとしている方も既存サイトがある方も、少しでもこの記事が参考になればと思っています。

第1回となる今回は、TECH LEADサービス全体のアーキテクチャについて説明したいと思います。

開発の基本コンセプト

プラットフォームサービスを作っていくにあたって以下のようなことを基本コンセプトとして設定し、設計・開発を行っています。

  • 1つのアカウントですべてのサービスを利用できる

  • 登録されたリソースを無駄なく全サービスで活用できる

  • スケールしやすく作る

  • メンテナビリティが高い

主な技術 

インフラ・ミドルウェア

Amazon EC2

Amazon Aurora

Amazon S3

Elasticsearch 6.4

nginx

PHP-FPM

デプロイ

Deployer

インフラツール

Terraform

Ansible  

サーバーサイド

PHP 7.2

フレームワーク

Laravel 5.6

BEAR.Sunday

フロントエンド

React 16.4

Typescript 2.6

Sass

webpack 4

TECH LEADのアーキテクチャー
TECH LEADのアーキテクチャ

インフラに関して

  • EC2:アプリケーション用とElasticsearch用の2つ

  • データベース:Amazon Aurora

  • ストレージ:Amazon S3

Amazon Elasticsearch Serviceを使うこと検討しましたが、Kuromojiのユーザー定義辞書を使うことができないため見送りました。

オーケストレーションツールとしてTerraformを用い、サーバ・DNSなどの設定を全て行なっています。

Ansibleでミドルウェアのインストール・nginxの設定などのサーバ構成管理を全て行なっています。

現状は、全サービス同一サーバに乗っているので、1台のNGINXで全てのアプリケーションを動かしています。

サーバーサイドに関して

ディレクトリ構成

.
|                   # 内部API層
├── api/            # [BEAR.Sunday]ミドルウェアの操作・メール送信
├── db/             # [Laravel] データベースのマイグレーション・シードを管理
└── web/                  # アプリケーション層
    ├── site-common/      # 共通で利用できるものを配置
    |                     # シンボリックリンクで各アプリケーションで使用
    ├── site-mypage/      # [Laravel] TECH LEAD共通部分
    |                     # アカウント設定・パスワード変更など
    ├── site-agent/       # [Laravel]TECH LEAD Agent
    ├── site-job/         # [Laravel]TECH LEAD Job
    └── site-resume/      # [Laravel]TECH LEAD Resume

TECH LEADサービスのサーバーサイドプログラム(PHP)は、大きく内部API層とアプリケーション層の2つに分かれています

内部API層では、データベース・Elasticsearchの操作、メール送信処理などに直接アクセスし、それらの処理をラップ(抽象化)しています。

各アプリケーションは、直接データベースなどを操作することはなく、内部API層へリクエストすることでデータを作成・取得・更新をしています。データベースなどを考える必要がなく、基本的にはフロントからのリクエストを内部API層にリクエストし、結果を受け取り、フロントに返すことに専念できます。

また、アプリケーション層と内部API層を分けることで、サービスが追加された時や変更があった時でも、ほかのサービスへの影響を最小限に抑え、対応ができるようこのような構成になっています。

データベースのマイグレーションとシードに関しては、dbディレクトリ配下に配置しているLaravelのマイグレーション機能を利用しています。 ここでは、マイグレーションとシード以外のことは行なっていません。

※現状、同一サーバ上で全てのサービスが動いていますが、今後サービスが成長していくにつれて各サービスを分離していこうと考えています。

フロントエンドに関して

ディレクトリ構成

.
├── common/             # `web/site-common/js`へのシンボリックリンク
|                       # 各サービス共通で使用する関数やコンポーネントを配置
├── components/         # ステートレスコンポーネントを配置
├── containers/         # renderされるメインのステートフルコンポーネントを配置
├── modules/            # api通信用の関数を配置
├── pages/              # SSR時に使用するコンポーネントを配置
├── const.d.ts          # Typescriptの型定義ファイル
├── index.tsx           # CSR用のエントリーポイント
├── index_csr.tsx       # SSR後にCSRする用のエントリーポイント
└── index_ssr.tsx       # SSR用のエントリーポイント

PHPのviewテンプレートはTwig、フロントエンドはReact × Typescriptを使っています。

TECH LEADサービスでは、ほとんどのページがCSR(クライアントサイドレンダリング)でコンテンツを描画しています。

PHPからはheadやjs・cssエントリポイント、利用するコンポーネントの指定をしたHTMLだけを返し、CSRでページを描画しています。

しかし、SEO的にページへアクセスした時点でDOMが作られていないのはよくないので、ログイン前に見れるページはSSR(サーバーサイドレンダリング)で作っています。

SSRPHPのエクステンションのV8jsを使用して行なっています。

弊社では以前、別プロジェクトでSPA(シングルページアプリケーション)を作成した経験があります。 「ReactでSPAを作ろう!(作ってみたい!)」と意気込んだものの、開発中に下記のようなこと思いました。

  • 本当にSPAである必要があったのか?

  • Reduxを使う必要があったのか?

  • もっと簡単に書けるのでは?

そんなことを思った経験からTECH LEADサービスでは、まずは最小限で実装し、フロントエンドをどう実装するのがいいのかを考えながら開発をしていこうと思っています。

※最近、「モダンなフロントエンドはSPAがベストプラクティス」というような記事をみかけ、やはりSPAにしようかなと検討中です。

まとめ

ここまでインフラからフロントエンドまで、非常にざっくりとではありますが全体の構成を説明させていただきました。

TECH LEADサービスのアーキテクチャーで一番特徴的なところをあげると、フロントエンド、アプリケーション層(プログラム)・内部API層(プログラム)でやることを切り分け・限定しているところになると思います。各層をAPIでやり取りすることによって、共通のリソースを使うことができ、それぞれの依存関係を低くすることでメンテナビリティを高められていると思います。

今後の課題として、アプリケーション側で共通部分をシンボリックリンク使って各サービスで使用しているコードを、サーバを分ける際にどのようにまとめるかなどの検討していかなければならないですが、基本的には上記のような形を維持しつつうまくパッケージ化して各サービスで使用することができたらなと思っています。(結構大変だと思いますが。。。)

エンジニアの皆さんへお願い

今回は、TECH LEADサービスのアーキテクチャや全体像の説明をさせて頂きました。 次回以降のブログでは、フロントエンドからサーバーサイド、インフラまでもう少し具体的な実装や、開発時の課題などを公開していこうと考えていますが、もし、「ここをもっと聞きたい」「うちではこうやっている」と言ったご意見・アドバイスなどありましたら、@TECH LEADまでリプライやDMにてお気軽にお知らせください!

よろしくお願いします!

次回予告

次回は【フロントエンド編】で、フロントエンドの実装ついてもう少し深く話したいと思います。

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

PR

「今は具体的な転職を考えていないよ」と言う方も、是非一度TECH LEADの各サービスを触ってみていただけると嬉しいです。

TECH LEAD Job

IT/WEBエンジニア専門の求人サービス|TECH LEAD Job

TECH LEAD Resume

TECH LEAD Resume|IT・WEBエンジニア向けレジュメ管理サービス

TECH LEAD Agent

IT/WEBエンジニア専門の転職支援サービス|TECH LEAD Agent

 

フリーランスエンジニアを目指す人に伝えたい大切な事

こんにちは、TECH LEADです。

先週公開した記事が、なぜかsmartnewsさんに掲載されたみたいで、瞬間的に見たこともないアクセスの伸び方に驚いてしまいました。 ブックマークいただいた皆様、SNSでシェアしていただいた皆様、ありがとうございました。

blog.techlead.jp

今日は、twitterなどでよく見かける、フリーランスエンジニアを目指す方で、特に経験の浅い方に向けてブログを書きたいと思います。

最近よく見かける未経験からフリーランスエンジニアを目指す人

TwitterやTECH LEADの登録者でも、未経験や経験の浅いエンジニアの方で「フリーランスエンジニアを目指している」と言った声を聞くことが、以前と比べて本当に増えました様に感じます。

背景には、フリーランスエンジニアとして高額収入をアピールするインフルエンサーへの憧れや、IT/WEBエンジニアの人手不足、終身雇用の崩壊や新しい働き方の広がりなど様々な要因があるのではないでしょうか。

フリーランスエンジニアを目指す動機

フリーランスエンジニアを目指す一番の理由は「報酬の高さ」にあるのではなないかでしょうか。 実際、TECH LEAD Agentの若手の求職者の方でも、転職と並行してフリーランスエンジニアへのキャリアパスを模索されている方がおり、理由を尋ねると一番は「手取り報酬の高さ」を理由に挙げられていました。

また、報酬の高さに加えて「会社に縛られない自由」を挙げる方も多いように感じます。 会社に入社すると、始業時間や煩雑な社内手続きなど堅苦しいルールが多く、また気の合わない上司や同僚と仕事をしなければならない場合には、毎日が憂鬱なものになるため、そういった様々なしがらみから解放される事を求めている方も少なくないように感じます。

フリーランスと会社員の比較

【前提】

  • 発注元企業との契約金額がボリュームゾーンである80万円/月で、エージェント経由で契約するケースを前提

  • エージェント側が受け取る手数料は15%と設定 (*諸説あります)

  • 想定するエンジニアは、数年のエンジニア経験でプログラミング以外も含め自立的に開発業務を進めていける方(開発規模にもよる)

  • そのクラスのエンジニアを自社採用した場合の年収として480万円を設定

【報酬面】

  • 額面報酬 フリー:68万円(80-(80x15%)) |社員:40万円(480/12ヶ月)

  • 正味報酬 フリー:62万円(68-(68x 9%)) |社員:48万円(40+20%(社会保険会社負担 15% + 交通費・PC等労働装備費5%))

フリーランスの場合、プロジェクトの切れ間や案件開始までの非稼動期間を加味し、年に1ヶ月(9%)の非稼動期間を加味して「正味報酬」を計算

【福利厚生や事務作業面】

  • フリーランス:契約期間外は好きに休めるが、有給などの法定休暇はない。また、経理作業や仲介会社とのやり取り、税金の支払いや確定申告など、エンジニアリング以外の雑務も発生する。

  • 社員:有給休暇以外にも会社によっては外部研修まで業務時間内で実施。また、雑務についてはバックオフィスや外部業者が担当してくれる。

【キャリア面】

  • フリーランス:基本的には、必要となる作業を遂行してもらう外部プロフェッショナルであるため、開発業務における付加価値業務に対して企業側が積極的に関与させるインセンティブは存在しない。契約時間を最大限作業遂行に充ててもらうようにする。

  • 社員:中長期的な育成の観点から、成長の機会や(技術的な)リーダーシップを養う機会を多く与えるインセンティブが発生する。また、業務上必要となる新しい技術やチャレンジについては、給与・経費の枠組みの中で作業以外にも投資を行うことも多い。

※最近ではスタートアップ企業を中心に、フリーランスエンジニアも社員と分け隔てなく扱う企業も増えてきている印象です。

【総評】

報酬面ではフリーランスの方が150万円~200万円ほど正味の報酬金額では高く、現状の給与が実力よりも低かったり将来年金には期待していない方には、この金額以上に報酬面でのメリットが大きくなりそうです。 一方、見えない費用として認識しておいた方が良いのが、会社のバックオフィス部門が個人の代わりに対応してくれている様々な雑務です。毎月の税金や保険料などの支払いから、年度末の税金の計算・確定申告(会社員は年末調整のみ)など、エンジニアとしての仕事以外も自分でやらなければならない、といった事をしっかりと把握しておくべきかと思います。

また、フリーランスを考慮する際に最も重要となる視点はキャリア面ではないでしょうか。 私自身も、過去多くのフリーランスエンジニアや法人のエンジニア業務委託サービスを利用してきましたが、前述したように残念ながら外部のエンジニアの方の成長を意識しながら高い報酬を払ってまで仕事を依頼した記憶は無く、今でも継続的に仕事を依頼したいと思う外部のエンジニアの方は片手で数えても余る程度しかおりません。

もちろん、フリーランスエンジニアの方の成長志向性とアサインされた開発プロジェクトの状況がマッチする事もあると思います。 しかし、その様な成長を促進させてくれる恵まれた仕事に出会い続ける事は稀なのではないでしょうか。

本当に目指すべきなのは「求められ続けるエンジニア」なのかも知れない

少し「フリーランスエンジニアになる」ことにネガティブな印象を与えてしまったかも知れませんが、大前提としてフリーランスも会社員もどちらが絶対的に良いと言うことは無く、また個人の価値観に照らし合わせて選択する働き方の一つです。

ただ一つだけ言えるのは、経験の浅いエンジニアの方がフリーランスエンジニアになった場合、前述した様々な視点からメリット・デメリットを検討していないと、多くの場合において理想と現実のギャップに悩まれると思います。後戻りができるタイミングなら良いのですが、エンジニアリングスキルの成長に寄与しない仕事で長い時間を費やしてしまった場合、その方のキャリア自体を台無しにしてしまいかねないのです。

先日、TECH LEADサービスの開発アドバイザーでもあり、TECH LEAD Agentのメンターも務めていただいていたフリーランスエンジニアの方が、とあるスタートアップ企業に社員としてコミットする事を決められました。 様々な会社・プロジェクトから引っ張りだこな状況で、いくつもの会社からCTOとしての誘いもあった状況なのですが、なぜ一つの会社に社員としてコミットするのか尋ねたところ、「複数社に外部のフリーランスとして関与しているだけでは、十分なやりがいや成長、そしてキャリアの形成に対して不十分」と感じたのがその理由だそうです。

本当に大切なのは、フリーランスか社員かと言った細かい雇用形態の話では無く、常に周りの人達から頼りにされるエンジニアになる事では無いでしょうか。そして、周りから求められるエンジニアになってから、ゆっくりとフリーランスが良いのか社員が良いのかを考えても、遅すぎるという事は無いと思います。

皆さんも、是非自分のキャリアをよく考えて、後悔の無いエンジニアライフをお過ごしください。 自分一人で考えるのに悩んだら、【テックリードエージェント】にお気軽にご相談ください。

agent.techlead.jp

【テックリードジョブ】も登録キャンペーンやってます!

job.techlead.jp

ちょっと丁寧にGitを説明する

こんにちは、TECH LEADです。

新しいサービスの開発が忙しく、久しぶりの投稿となってしまいました。

TECH LEAD Job|テックリードジョブ

TECH LEAD Job|テックリードジョブ

 

少し前にはてブで以下の記事が多くのブックマークを集めてましたね。

皆さんもご覧になりましたか?

 

anond.hatelabo.jp

 

この記事を書く私も、システム開発やプログラミングについての知識はある程度持っているものプログラマーではないため、社内で飛び交うgitやGitHub用語にもやもやした感情を持つ一人でした。

 

また、WEBデザイナーもフロントのコーディングまでを担当しているため、git・GitHubを利用しているのですが、結構頻繁に「コンフリクトしてマージできません」と言った発言が聞こえていたので、多分みんなgitやGitHubの分かりづらさに苦労しているんだろうなと日々感じてもいました。

 

そんな中、前述の「クソ簡単にgitを説明する」に出会い、一気に読み終えたわけです。

 

読んだ感想は「シンプルな説明で分かりやすい(気がする)!」だったのですが、それ以上に湧き上がった感情は「バッサリと切り捨てる物言いがカッコイイ」と言う、嫉妬にも似た感情でした。。。

「fetch - お前にはまだ早い」「コンフリクト - できてねーじゃねーか」などなど、多分gitを使い慣れたエンジニアの人が、初心者やノンエンジニアでgitに対する基本的な理解が無い人達に対して、心の中で抱くリアルな感情なのではないかと思います。

 

自社のエンジニアに「クソ簡単にgitを説明する」を読んで理解した内容が正しいか聞いてみた

 

「fetchって初心者にはやっぱり早いんですかね〜」という投げかけを皮切りに、記事に書かれていた内容について、自分なりに理解した内容やgitコマンドを使うケースについて色々と質問していきました。

やはり前提となる知識が無いためか、色々と誤認識をしている部分も多々あるようで、結局ホワイトボードを使ってローカルとリモート、リポジトリーや様々なgitコマンドなどを整理して説明をしてもらいました。

説明に対しても、色々と細かい点や実際の業務ではローカルで操作するケースやリモート側で操作するケースの違いなど、もやもやした点をぶつけたため、自分としては「もう完全に理解したな」状態になったため、自分や他の人のために理解内容を資料にまとめてみました。

 

資料に対するエンジニアからの想定外な反応

 

意気揚々と資料を作り上げ、「これは分かりやすい!」といったエンジニアや他のメンバーの反応を期待しつつ、早速Slackに資料をアップロードしてみました。

ところが、返ってきた反応は「これは違うと認識です」「これは別のコマンドが適切なのでは?」と言う感じのリアクションで、正直心が折れかけながら「え、でもそれって昨日こう言う風に説明してなかったっけ?」とか「あれリモートの差分はこのコマンドって書いてあったけど」などと、自分の理解の正当性を守るために、必死に言い訳を探していました...。

ただ、業務で毎日使いこなしているエンジニアの理解と一致していないと言うことは、gitについて自分が理解できていない部分があることは事実であり、エンジニアからの説明だけでは埋められなかったことは明白でした。

 

実際に自分でgit/GitHubを使ってやってみる事に

と言う事で、説明聞いても理解できない部分があるならば、実際にgit/GitHubを使って「クソ簡単にgitを説明する」に記載されていたgitコマンドを使ってみる事にしました。

 

【参考にさせていただいたのは以下のページ】

今日からはじめるGitHub 〜 初心者がGitをインストールして、プルリクできるようになるまでを解説 - エンジニアHub|若手Webエンジニアのキャリアを考える!

 

【実際に行った作業】

  • gitのインストール *既に入ってましたが最新版を入れてみました
  • SSH鍵の生成 
  • GitHubの登録。リポジトリー作成と公開鍵の設置
  • ローカルからリモートリポジトリーを "git clone"
  • ローカルでマスターブランチから"git branch -b" でブランチをコピー
  • マスタブランチから "git chechout work" でワークブランチに移動
  • ディベロップブランチにファイル追加。
  • "git add" でステージし、"git commit (-a)"でワークブランチに反映
  • "git push (origin work)" でリモートリポジトリーに反映
  • GitHub上で "Pull request" の作成とマスターブランチへのマージ、ワークブランチの削除
  • ローカルからマスターブランチを "git pull" して最新版を反映し、"git branch -d (work)"でローカルのワークブランチも削除

 

 操作してみて理解できたのか?

 

結論から言うと「かなり理解できた」のではないかと思います!

参考サイトの説明がかなり丁寧に記載されており、順を追って操作をしていけばほぼ迷う事なく一通りの操作ができたので、参考にする情報の精度も重要だと感じました。

また、頭で理解した内容をただ図で整理していた時と比べて、実際にファイルを操作したりgit/GitHub上で変化・操作をする事により、イメージしていた流れが実際に目に見えて形になるため、より深く理解した状態で資料が作成できるようになったと思います。

一方で、やはり順を追った一つ一つの説明は、かなりの情報量で縦長のページとなってしまい、今がローカルなのかリモートなのか、どのブランチの話をしているのかなど、整理をするフレームが無いと知識の定着化は難しいなと感じました。

 

前置きが長くなりましたが「ちょっと丁寧にgitを説明する」資料です

 

※資料を作成しておいてなんですが、そもそものバージョン管理の必要性などについては、改めて「クソ簡単にgitを説明する」をご参照ください。

 

最後に

「fetch」コマンドは、本当に「お前にはまだ早い」と言うレベルだったのかを思い返したのですが、「git pull は fetch と mergeを足したものだよ」と優しく教えてくれればいいような気がしてきました。。。

 

【TECH LEADの新しいサービスです】

IT/WEBエンジニア専門の求人スカウトサービス|TECH LEAD Job

job.techlead.jp