SymfonyとReactでシングルページアプリケーションを構築する

July 31, 2019
執筆者
Oluyemi Olususi
寄稿者
Twilio の寄稿者によって表明された意見は彼ら自身のものです

SymfonyとReactでシングルページアプリケーションを構築する

この記事はOluyemi Olususiこちらで公開した記事(英語)を日本語化したものです。

はじめに

PHPで構築されたWebアプリケーションは、優れたユーザー体験を提供するために、フロントエンドで処理される膨大な量のロジックを必要とします。LaravelなどのPHPフレームワークには、Vue.jsを使用してクライアント側のロジックを作成するためのサポートが含まれています。これにより、開発者はこれらの技術を組み合わせることで、アプリケーションを迅速に構築できます。

Laravelの構造とは逆に、再利用可能なPHPコンポーネントを提供するSymfonyは、特定のライブラリやフロントエンドフレームワークを選びません。開発者は、アプリケーションのフロントエンドのロジックを担うツールを柔軟に選ぶことができます。このチュートリアルでは、SymfonyとReactを使ってシングルページのアプリケーションをシームレスに構築する方法をご紹介します。

このチュートリアルを最後まで進めると、ReactとSymfonyで作られた再利用可能なユーザーインターフェースコンポーネントを作る方法を学べます。

前提条件

このチュートリアルを最後まで進めるには、ReactやSymfonyでアプリケーションを構築するための基本的な知識と、PHPによるオブジェクト指向プログラミングの適度な知識が必要です。

また、開発するマシンにはNode.jsYarnパッケージマネージャがインストールされていることが必要となります。最後に、依存関係を管理するために、Composerをインストールする必要があります。

Symfonyとは?

PHPコンポーネントのを提供するSymfonyは、多くのエンタープライズWebアプリケーションの開発を支えており、高いパフォーマンスのアプリケーションを作成するには最良の選択肢となっています。Symfonyは、ロジカルな構造を持つWebフレームワークで、規模や複雑さに関係なく、あらゆるWebアプリケーションのプロジェクトの作成に適しています。その素晴らしい機能とコンセプトについて詳しくは、公式ドキュメントをご確認ください。

Reactとは?

Reactは、Webアプリケーションのフロントエンドロジックを構築するために使用されるJavaScriptフレームワークです。Reactはオープンソースのライブラリで、多くのJavascript開発者に人気があります。Reactは、クリエイティブなユーザーインターフェースの構築を容易にし、アプリケーションの状態(ステート)を容易に管理できます。すべてのReactアプリケーションの中心には、コンポーネントと呼ばれる自己充足型のモジュールがあります。コンポーネントは、コードの再利用を可能にし、アプリケーションにモジュール化され、組織化された構造を提供します。

ReactとSymfonyを組み合わせる

数年前までは、Webアプリケーションのフロントエンドのロジックは、「バニラ」(ライブラリもフレームワークも使わないで書く)JavaScriptかJQueryのどちらかで処理するのが主流でした。一部の市場では今でもこの手法が使われていますが、現在のアプリケーションはより大きく、より複雑になっています。そのため、直感的で堅牢な構造で、記述するコードの量を減らすことができるライブラリが求められています。

PHPはサーバーサイドの言語なので、最新のフレームワークはJavaScriptライブラリをバックエンドとシームレスに統合するためのサポートを含むことが期待されます。Symfony Webpack Encoreなどの純粋なJavaScriptライブラリの導入は、SymfonyアプリケーションでCSSとJavaScriptの両方を扱うための簡素なプロセスを提供するため、画期的です。

Reactはそのシンプルさと大きな開発者コミュニティの存在から、ユーザーインターフェースを構築するための最も広く使われているJavaScriptフロントエンドライブラリです。本稿では、Reactに焦点を当てますが、チュートリアルを進めると、ReactとSymfonyを1つのプロジェクトにうまく組み合わせる方法がお分かりいただけます。

ReactとSymfonyで構築するアプリケーション

まず始めに、APIからユーザーと投稿の一覧を取得するためのアプリケーションを作成します。2ページで構成されたアプリケーションで、以下のように2つの異なるページを行き来して、それに応じてレンダリングされたコンテンツを見ることができます。

ユーザーリスト

このアプリケーションは、一般的なSymfonyの動作より少し逸脱しています。本稿では、データのステート管理、ページのレンダリング、ページのルーティングなどの処理を、React Routerで行います。React Routerは、Reactの再利用可能なコンポーネント内のローカルステートオブジェクトを使用して履歴を保存することで、リアルタイムな応答性を提供します。

ユーザー一覧を取得するために、Symfonyを使用してダミーデータでバックエンドAPIを構築します。投稿のリストを取得するために、JSONPlaceholderというテストとプロトタイプのためのダミーのREST APIを使用します。

Symfonyのアプリケーションのひな型を作成する

まず始めに、Composerを使って、新しいSymfonyアプリケーションを作成します。または、Symfony installerを使って、こちらの説明に従ってプロジェクトをセットアップすることも可能です。まず、お使いのOSのターミナルで、開発ディレクトリに移動します。次に、以下のコマンドを実行して、symfony-react-projectという名前のプロジェクトを作成します。

composer create-project symfony/website-skeleton symfony-react-project

上記のコマンドを実行すると、新しいSymfonyアプリケーションがコンピューターに正常にインストールされます。

アプリケーションを起動する

新しく作成したプロジェクトにディレクトリを変更し、組み込みのSymfony PHP Webサーバーを使用してアプリケーションを開始します。アプリケーションを起動させるために次のコマンドを実行します。

// ディレクトリを変更
$ cd symfony-react-project
// サーバーを起動
$ php bin/console server:run

ブラウザを開いて、ウェルカムページを表示するためにhttp://localhost:8000に移動してください。以下のスクリーンショットのSymfonyのバージョンは執筆時のものであり、表示が異なる可能性があります。

スタート画面

DefaultControllerを作成する

Symfonyプロジェクトがインストールされたので、コンテンツのレンダリングと、ユーザー一覧を取得するためのバックエンドAPIを構築するための新しいコントローラーを生成する必要があります。開発サーバーの実行をCTRL+Cで停止して、次のコマンドを実行します。

$ php bin/console make:controller DefaultController

この作業により、2つの新しいファイルが作成されます。コントローラーは src/Controller/DefaultController.php に、テンプレートはtemplates/default/index.html.twigに作成されます。まず、DefaultController.phpを開き、内容を以下のように変更します。

// ./src/Controller/DefaultController
      
<?php
    
namespace App\Controller;
    
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
    
class DefaultController extends AbstractController
{
    /**
    * @Route("/{reactRouting}", name="home", defaults={"reactRouting": null})
    */
    public function index()
    {
        return $this->render('default/index.html.twig');
    }
}

Symfonyのコントローラは、アプリケーションに送信されたすべてのHTTPリクエストを処理し、適切なビューまたはレスポンスを返します。今回は、その動作を変更して、コントローラのrouteアノテーション内に別のパラメーター{reactRouting}を含めています。このアノテーションを設定すると、ホームページへのすべてのルートがReactによって処理されるようになります。

ユーザー一覧の取得

DefaultController内に、ユーザー一覧を取得するためのメソッドをもう一つ追加します。下記のgetUsers()メソッドをindex()メソッドのすぐ後に追加します。

// ./src/Controller/DefaultController
    
<?php
...
class DefaultController extends AbstractController
{
    ...
    
    /**
    * @Route("/api/users", name="users")
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    */
    public function getUsers()
    {
        $users = [
            [
                'id' => 1,
                'name' => 'Olususi Oluyemi',
                'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation',
                'imageURL' => 'https://randomuser.me/api/portraits/women/50.jpg'
            ],
            [
                'id' => 2,
                'name' => 'Camila Terry',
                'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation',
                'imageURL' => 'https://randomuser.me/api/portraits/men/42.jpg'
            ],
            [
                'id' => 3,
                'name' => 'Joel Williamson',
                'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation',
                'imageURL' => 'https://randomuser.me/api/portraits/women/67.jpg'
            ],
            [
                'id' => 4,
                'name' => 'Deann Payne',
                'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation',
                'imageURL' => 'https://randomuser.me/api/portraits/women/50.jpg'
            ],
            [
                'id' => 5,
                'name' => 'Donald Perkins',
                'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation',
                'imageURL' => 'https://randomuser.me/api/portraits/men/89.jpg'
            ]
        ];
    
        $response = new Response();

        $response->headers->set('Content-Type', 'application/json');
        $response->headers->set('Access-Control-Allow-Origin', '*');

        $response->setContent(json_encode($users));
        
        return $response;
    }
}

ここでは、JSON形式のユーザー一覧がgetUsers()メソッドから返されます。このレスポンスにより、Reactアプリケーションが返されたデータをベースにビューを更新することが非常に簡単になります。また、上記のようにダミーデータを使用する代わりに、アプリケーションのデータベースからユーザー一覧を取得し、コントローラからJSONレスポンスを返すこともできます。

次に、templates/default/index.html.twigを開き、以下を貼り付けます。

{# ./templates/default/index.html.twig #}
    
{% extends 'base.html.twig' %}
    
{% block title %} Symfony React Project {% endblock %}
    
{% block body %}
    
    <div id="root"></div>
    
{% endblock %}

このテンプレートは、Reactアプリケーションをidがrootdivにバインドし、Symfony内でReactアプリケーションをレンダリングします。

次に、Postmanを使用してバックエンドAPIをテストします。開発用サーバーを使用して、ターミナルからphp bin/console server:runを実行し、再度アプリケーションを起動します。次に、http://localhost:8000/api/usersのユーザー一覧のエンドポイントにアクセスしてみてください。このようにユーザー一覧が表示されます。

postman画面

Symfony アプリケーションのセットアップに成功しました。次の章では、Reactを使ってフロントエンドの構築を開始します。

Reactでフロントエンドアプリを構築する

Reactアプリケーションをセットアップするために、Symfony Webpack Encoreをインストールします。はじめに、別のターミナルのウィンドウを開いて、以下のコマンドを実行し、Composerを使用してWebpack Encoreをインストールします。プロジェクトディレクトリにいることを確認してください。

$ composer require symfony/webpack-encore-bundle

次に、以下を実行します。

$ yarn install

上記のコマンドを実行すると、webpack.config.jsassetsフォルダが作成され、.gitignorenode_modulesフォルダが追加されます。

インストール作業が完了したら、Yarnを使ってReact、React-router、Axiosなどの依存関係をインストールします。

$ yarn add @babel/preset-react --dev
$ yarn add react-router-dom
$ yarn add --dev react react-dom prop-types axios
$ yarn add @babel/plugin-proposal-class-properties @babel/plugin-transform-runtime

Webpack Encoreを設定する

次に、Reactを有効にしてWebpack Encoreを設定し、次のようにプロジェクトのルートにあるwebpack.config.jsファイル内にエントリーポイントを設定します。

var Encore = require('@symfony/webpack-encore');
    
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
    Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}
    
Encore
    // directory where compiled assets will be stored
    .setOutputPath('public/build/')
    // public path used by the web server to access the output path
    .setPublicPath('/build')
    .enableReactPreset()
    // only needed for CDN's or sub-directory deploy
    //.setManifestKeyPrefix('build/')
    
    /*
    * ENTRY CONFIG
    *
    * Add 1 entry for each "page" of your app
    * (including one that's included on every page - e.g. "app")
    *
    * Each entry will result in one JavaScript file (e.g. app.js)
    * and one CSS file (e.g. app.css) if you JavaScript imports CSS.
    */
    .addEntry('app', './assets/js/app.js')
    
    // When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
    .splitEntryChunks()
    
    // will require an extra script tag for runtime.js
    // but, you probably want this, unless you're building a single-page app
    .enableSingleRuntimeChunk()
    
    /*
    * FEATURE CONFIG
    *
    * Enable & configure other features below. For a full
    * list of features, see:
    * https://symfony.com/doc/current/frontend.html#adding-more-features
    */
    .cleanupOutputBeforeBuild()
    .enableBuildNotifications()
    .enableSourceMaps(!Encore.isProduction())
    // enables hashed filenames (e.g. app.abc123.css)
    .enableVersioning(Encore.isProduction())
    
    // enables @babel/preset-env polyfills
    .configureBabel(() => {}, {
        useBuiltIns: 'usage',
        corejs: 3
    })
;
    
module.exports = Encore.getWebpackConfig();

上記の設定により、Encoreはアプリケーションのエントリーポイントとして ./assets/js/app.jsをロードし、JavaScript関連のファイルを管理するために使用します。

Reactのコンポーネントを構築する

Reactは、JavaScript開発者がプロジェクト内で使用する再利用可能なコンポーネントを構築する能力を提供します。これにより、アプリケーションの構造を強化する、モジュール化された再利用可能なコードを簡単に構築できます。

まず始めに、assets/jsに新しくcomponentsフォルダーを作成します。この componentsフォルダには、以下の再利用可能なコンポーネントが格納されます。

  • Home.js: アプリケーションのトップページとなるコンポーネントで、公開ルートのコンテンツをユーザーに表示するために使用されます。
  • Posts.js: このコンポーネントは、JSONPlaceholderのパブリックAPIからのコンテンツの取得を処理します。
  • Users.js: このコンポーネントは、Symfonyプロジェクト内で作成されたバックエンドAPIからユーザー一覧を取得して表示するために使われます。

AppComponentを更新する

まず、アプリケーションのエントリーポイントから、Reactを初期化するために必要なコンテンツを追加し、rootというIDを持つHTML要素にバインドします。先ほどWebpack Encoreで自動生成されたassetsフォルダ内にある./assets/js/app.jsの中身を以下のように置き換えます。

// ./src/js/app.js
      
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import '../css/app.css';
import Home from './components/Home';
    
ReactDOM.render(<Router><Home /></Router>, document.getElementById('root'));

スタイルシートを追加する

このページの見栄えをよりよくするために、いくつかのスタイルを./assets/css/app.css 内に追加します。app.cssファイルを開き、以下を貼り付けます。

// assets/css/app.css
      
.row-section{float:left; width:100%; background: #42275a;  /* fallback for old browsers */
}
.row-section h2{float:left; width:100%; color:#fff; margin-bottom:30px; font-size: 14px;}
.row-section h2 span{font-family: 'Libre Baskerville', serif; display:block; font-size:45px; text-transform:none; margin-bottom:20px; margin-top:30px;font-weight:700;}
.row-section h2 a{color:#d2abce;}
.row-section .row-block{background:#fff; padding:20px; margin-bottom:50px;}
.row-section .row-block ul{margin:0; padding:0;}
.row-section .row-block ul li{list-style:none; margin-bottom:20px;}
.row-section .row-block ul li:last-child{margin-bottom:0;}
.row-section .row-block ul li:hover{cursor:grabbing;}
.row-section .row-block .media{border:1px solid #d5dbdd; padding:5px 20px; border-radius: 5px; box-shadow:0px 2px 1px rgba(0,0,0,0.04); background:#fff;}
.row-section .media .media-left img{width:75px;}
.row-section .media .media-body p{padding: 0 15px; font-size:14px;}
.row-section .media .media-body h4 {color: #6b456a; font-size: 18px; font-weight: 600; margin-bottom: 0; padding-left: 14px; margin-top:12px;}
.btn-default{background:#6B456A; color:#fff; border-radius:30px; border:none; font-size:16px;}
.fa-spin {
    margin: 0 auto;}
body {
    background-color: lightgray;
}

Homeコンポーネント

次に、Homeコンポーネントを作成するために、先ほど作成した ./assets/js/components/Home.jsファイルを開き、次のコードを追加します。

// ./assets/js/components/Home.js
      
import React, {Component} from 'react';
import {Route, Switch,Redirect, Link, withRouter} from 'react-router-dom';
import Users from './Users';
import Posts from './Posts';
    
class Home extends Component {
    
    render() {
        return (
            <div>
                <nav className="navbar navbar-expand-lg navbar-dark bg-dark">
                    <Link className={"navbar-brand"} to={"/"}> Symfony React Project </Link>
                    <div className="collapse navbar-collapse" id="navbarText">
                        <ul className="navbar-nav mr-auto">
                            <li className="nav-item">
                                <Link className={"nav-link"} to={"/posts"}> Posts </Link>
                            </li>
    
                            <li className="nav-item">
                                <Link className={"nav-link"} to={"/users"}> Users </Link>
                            </li>
                        </ul>
                    </div>
                </nav>
                <Switch>
                    <Redirect exact from="/" to="/users" />
                    <Route path="/users" component={Users} />
                    <Route path="/posts" component={Posts} />
                </Switch>
            </div>
        )
    }
}
    
export default Home;

ここでは、必要なモジュールをインポートし(一部のファイルはこのセクションの後半で作成します)、render()メソッド内で、ナビゲーションバーを含め、React-Routerを使用して適切なコンポーネントをレンダリングしています。

Userコンポーネント

先ほど作成したバックエンドAPIからユーザー一覧を取得するために、./assets/js/components/Users.jsを開き、以下のコードを貼り付けます。

// ./assets/js/components/Users.js
    
import React, {Component} from 'react';
import axios from 'axios';
    
class Users extends Component {
    constructor() {
        super();
        this.state = { users: [], loading: true};
    }
    
    componentDidMount() {
        this.getUsers();
    }
    
    getUsers() {
        axios.get(`http://localhost:8000/api/users`).then(users => {
            this.setState({ users: users.data, loading: false})
        })
    }
    
    render() {
        const loading = this.state.loading;
        return(
            <div>
                <section className="row-section">
                    <div className="container">
                        <div className="row">
                            <h2 className="text-center"><span>List of users</span>Created with <i
                                className="fa fa-heart"></i> by yemiwebby</h2>
                        </div>
                        {loading ? (
                            <div className={'row text-center'}>
                                <span className="fa fa-spin fa-spinner fa-4x"></span>
                            </div>
                        ) : (
                            <div className={'row'}>
                                { this.state.users.map(user =>
                                    <div className="col-md-10 offset-md-1 row-block" key={user.id}>
                                        <ul id="sortable">
                                            <li>
                                                <div className="media">
                                                    <div className="media-left align-self-center">
                                                        <img className="rounded-circle"
                                                            src={user.imageURL}/>
                                                    </div>
                                                    <div className="media-body">
                                                        <h4>{user.name}</h4>
                                                        <p>{user.description}</p>
                                                    </div>
                                                    <div className="media-right align-self-center">
                                                        <a href="#" className="btn btn-default">Contact Now</a>
                                                    </div>
                                                </div>
                                            </li>
                                        </ul>
                                    </div>
                                )}
                            </div>
                        )}
                    </div>
                </section>
            </div>
        )
    }
}
export default Users;

上記のコンポーネントで、getUsers()メソッドを作成し、バックエンドAPIからユーザー一覧を取得し、コンポーネントがマウントされた時点でこのメソッドを呼び出し、リストをループしてビューを更新しています。

Postsコンポーネント

次に、投稿の一覧を取得するために、assets/js/components/Posts.js内で以下のコードを貼り付けます。

// ./assets/js/components/Posts.js
    
import React, {Component} from 'react';
import axios from 'axios';
    
    
class Posts extends Component {
    constructor() {
        super();
        
        this.state = { posts: [], loading: true}
    }
    
    componentDidMount() {
        this.getPosts();
    }
    
    getPosts() {
        axios.get(`https://jsonplaceholder.typicode.com/posts/`).then(res => {
            const posts = res.data.slice(0,15);
            this.setState({ posts, loading: false })
        })
    }
    
    render() {
        const loading = this.state.loading;
        return (
            <div>
                <section className="row-section">
                    <div className="container">
                        <div className="row">
                            <h2 className="text-center"><span>List of posts</span>Created with <i
                                className="fa fa-heart"></i> by yemiwebby </h2>
                        </div>
    
                        {loading ? (
                            <div className={'row text-center'}>
                                <span className="fa fa-spin fa-spinner fa-4x"></span>
                            </div>
    
                        ) : (
                            <div className={'row'}>
                                {this.state.posts.map(post =>
                                    <div className="col-md-10 offset-md-1 row-block" key={post.id}>
                                        <ul id="sortable">
                                            <li>
                                                <div className="media">
                                                    <div className="media-body">
                                                        <h4>{post.title}</h4>
                                                        <p>{post.body}</p>
                                                    </div>
                                                </div>
                                            </li>
                                        </ul>
                                    </div>
                                )}
                            </div>
                        )}
                    </div>
                </section>
            </div>
        )
    }
}
    
export default Posts;

このコンポーネント内で、パブリックAPIからサンプル投稿の一覧を取得するためにgetPosts()メソッドを作成し、ループしてビューを更新しています。

ベーステンプレートの更新

templates/base.html.twigに移動して、以下のコードを貼り付けます。

// templates/base.html.twig
      
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>{% block title %}Welcome!{% endblock %}</title>
    {% block stylesheets %}
        {{ encore_entry_link_tags('app') }}
    {% endblock %}
    
    <link href="//maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://fonts.googleapis.com/css?family=Libre+Baskerville:400,700">
    <link href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" rel="stylesheet">
</head>
<body>
{% block body %}{% endblock %}
{% block javascripts %}
    {{ encore_entry_script_tags('app') }}
{% endblock %}
</body>
</html>

このファイルには、Bootstrap、Font Awesome、Google FontsのCDNファイルが含まれています。また、Webpack Encore Bundleを使っているので、encore_entry_script_tags()スクリプトとencore_entry_link_tags()リンクタグを追加しています。

アプリケーションを動かす

さて、アプリケーションを実行し、その機能をテストしてみましょう。その前に、SymfonyとReactの両方のアプリケーションが、プロジェクトディレクトリ内の別々のターミナルから現在実行されていることを確認してください。すでに終了している場合は、Symfonyアプリケーションを再開するために次のコマンドを実行します。

$ php bin/console server:run

2つ目のターミナルから、以下のコマンドを実行して、Reactアプリケーションをコンパイルし、JavaScriptファイルの変更を監視します。

$ yarn encore dev --watch

http://localhost:8000にアクセスし、ユーザー一覧を確認します。

ユーザーリスト

次に、ナビゲーションバーから「Posts」をクリックすると、JSONPlaceholderから取得した投稿のリストが表示されます。

投稿リスト

最後に

遠隔地からコンテンツを取得するためにAjaxコールを行うアプリケーションを構成できることは、シングルページアプリケーション(SPA)の利点の1つです。これは、アプリケーションのパフォーマンスを向上させ、また、異なるページ間のナビゲーションを非常に容易にします。

このチュートリアルでは、バックエンドにSymfonyを使用し、Reactで駆動するフロントエンドのロジックを使用して、シングルページアプリケーションを構築する方法をご紹介しました。これにより、ReactとSymfonyを組み合わせることがいかにシームレスであるかをご理解いただけたと思います。

このチュートリアルがお役に立てれば幸いです。GitHub上のこのアプリケーションのソースコードを見て、自由に機能を追加してください。

Olususi Oluyemiは、技術愛好家、プログラミングフリーク、新しい技術を取り入れるのが好きなウェブ開発者です。

Twitter: https://twitter.com/yemiwebby
GitHub: https://github.com/yemiwebby
Website: https://yemiwebby.com.ng/