優総研

ポートフォリオ制作なうなんだが…その2

 

 

 

 

最短で完成を目指す。

 

※雑記。現状は参考?にするのはおすすめしません。最後に整理します。

 

もくじ

内容

本の感想投稿掲示板 + Google Books APIとのマッシュアップ

他書評サービスのコミュニティ機能があまり使われておらず成功していないから、Yomuyoは本好きの間で小さくてもコミュニケーションが生まれるものにしたいな。それがこのサービスの意義。

 

  • 収益という目的性があるよって存在意義
    →Amazon APIの売り上げ条件の為採用を断念
  • おすすめの本というコミュニティ
  • Google Books APIがアプリの外見に華やかさを与えてくれる

※Amazon のAP API利用は売上実績がないとAPI利用でエラーになるので断念。Google Book APIでスタートさせて、Amazonアフィリエイトで実績を十分に作れたら、PA APIに移行するのが良いかな?

 

機能

  • Google Books API連携 + Amazonアソシエイト
  • 検索
  • 書評投稿
  • 会員機能 + 投稿
  • 書評ランキング
  • レビュアランキング
  • レコメンド(余裕があれば実装したい)

シンプルに最短で実装する為に機能を一時的にそぎ落とす。

キャッシュ戦略

  • Google Books APIのキャッシュ
    外部APIに負荷を与え過ぎて利用停止されないように、Google Books APIでの問い合わせ結果はElasticache(Redis)に1週間分をキャッシュする。
    →検索結果が高速化してUXも向上
    // まだGoogle Books APIは良いけれど、Amazon PA APIはシビアなので必須。
  • よくある参照結果もキャッシュ
    トップページなど30秒~1分程度キャッシュします。
    →DBへの負荷軽減と高速化。

 

 

技術要素

AWSのフルマネージドとSaaSでなるべくサーバレス構成にしています。

  • 開発ツール:VS Code + Git 済
  • 開発環境[ 構築|更新|テスト]自動化: Bash Shellスクリプト + docker-compose 済
  • 機密情報隠蔽化:KMS + AWS Systems Manager パラメータグループ 済
  • 自動デプロイ:Docker => GitHub => CodePipeline => CodeBuild => ECR +ECS 済
  • CI + ChatOps:GitHub => CodePipeline => CodeBuild + PHPUnit + [ CloudWatch Events => Lambda+KMS => Slack ] 済
  • フレームワーク:Laravel 済
  • データベースGUI管理ツール:phpMyAdmin on Docker 済
  • 言語:PHP7 済
  • バッチ処理:Lambda, CloudWatch(定期スナップショット) 済
  • RDBMS:RDS(MySQL) 済
  • NoSQL:Elasticache for Redis(セッションサーバ) 済
  • オブジェクトキャッシュ:Elasticache for Redis + Laravel 済
  • フロント:Vue.js + Laravel
  • CDN:Laravel + CloudFront + S3 済
  • ログ分析基盤:Cloudwatch + S3 + Athena, Elasticsearch+kibana 済
  • 日時ロギング:CloudWatch Logs(LogGroup)  => Lambda => S3
  • DNS:Route53 済
  • 証明書自動更新:Route53 + ACM + [ ALB|CloudFront ] 済
  • メール送信管理:SendGrid 済
  • メッセージキュー:Laravel + AWS SQS
  • 監視:Mackerel => [ Slack|Twilio ], CloudWatch Alert => SNS => Lambda => Slack
  • 負荷試験:Jmeter, Apache Bench, Locust
  • セキュリティ:ELB+AWS WAF(Trend Micro Managed Rules for AWS WAF) 済
  • ソーシャルログイン(Laravel Socialite+[Facebook, Twitter]) 済
  • いいねボタン:Ajax + jQuery + Laravel 済
  • マークアップ, グリッド:HTML/CSS, Bootstrap4, Laravel Blade 済
  • ER図 自動作成・更新:SchemaSpy on Docker + Nginx 済

 

 

ローカル or EC2

  • local(開発)
    EC2 + docker-compose
    ローコストで開発&確認

ECS

GitHubのブランチ名でデプロイ先を分ける

  • staging(検証)
    ECS(staging)+EC2
    本番と同じ構成でテストして表示や動作を確認します。
  • production(本番)
    ECS(production)+Fargate

Fargateにするとホスト管理の開放、スケールを気にしなくて良い。

 

Laravel

バージョン:5.8

  • CRUD + Eloquent ORM  済
  • リレーション
    tinker, hasMany, belogsTo, many to many,
  • Eagerローディング
    with()によるN+1対策
  • トランザクション 済
    ロック処理,ロールバック, リトライ
  • ログイン 済
  • Ajax + jQuery いいねボタン 済
  • ランキング機能 済
  • ソーシャルログイン(Facebook, Twitter) 済
  • セッション 済
    relfresh(), old()
  • バリデーション 済
    Request, 
  • アクセス管理 済
  • ストレージ + S3連携 済
  • try catch + ログ出力 済
  • 単体テスト(PHPUnit)
  • ページネーション 済
  • サービスプロバイダ(DI) 済
  • メール送信 済
  • システム管理者用ページ
    Vue.js + RestAPI(Laravel)で作る?
  • ジョブキュー・イベント + AWS SQS
  • Artisan
    auth, request, response, rule, middleware, scope, migrate, seeder, tinker,

 

おすすめ書籍

 

[amazon_link asins=’4798052582,4798059072,4798110663,B07T5995HK,4863542178′ template=’ProductCarousel’ store=’izayoi55-22′ marketplace=’JP’ link_id=’5611bcdd-41d2-4000-a62d-d6648a2af9d5′]

 

 

Slack通知

Codebuildが通ったら、slackに通知するようにする

 

 

「Incoming  Webhook」で検索して【インストール】を選択します。

 

 

 

 

 

Webhook URLを得られます、後で利用するのでメモしておきましょう。

https://は含めずに、「hooks.slack.com/ser…」をメモしてください。

 

 

 

IAM Role Lambda実行用ロールの作成

 

 

 

 

 

 

 

 

 

 

KMS 暗号化用キーの作成

Key Management Service(KMS)で環境変数を暗号化する為のキーを作成します。

【カスタマー管理型のキー】から【キーの作成】をクリックします。

 

「lambda-encrypter-key」と入力して【次へ】をクリックします。

 

 

【次へ】をクリック

 

先ほど作成した【lambda_fullaccess_role】を選択して【次へ】をクリックします。

 

 

【完了】をクリックします。

カスタマー管理型のキー(CMK)が作成された。次のLambdaの設定で利用します。

 

Slack通知用 Lambda関数の作成

 

「cloudwatch slack」で検索して「cloudwatch-alarm-to-slack-python」を見つけます。

「設定」をクリックします。

 

 

 

  1. slackChannel:#yomuyo
  2. kmsEncryptedHookUrl:WEBhookURLの内容
  3. 伝送中に暗号化するAWS KMSキー:lambda-encrypter-key

3つを入力してから、kmsEncryptedHookUrlの項目で【暗号化】をクリックします。

 

 

 

Slack通知用のLambda関数が作成されました。

 

Cloudwatch Events

 

Codebuild用ルールの作成

 

 

 

 

名前を「Codebuild-notify-Slack-Rule」にして【ルールの作成】をクリックします。

 

 

 

Lambdaに戻る

Lambda関数を差し替える

import boto3
import json
import logging
import os

from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

ENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']
SLACK_CHANNEL = os.environ['slackChannel']
HOOK_URL = "https://" + boto3.client('kms').decrypt(CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL))['Plaintext'].decode('utf-8')

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    logger.info("Event: " + str(event))

    project = event["detail"]["project-name"]
    state = event["detail"]["build-status"]

    slack_message = {
        'channel': SLACK_CHANNEL,
        'text': "CodeBuild: %s - %s" % (project, state)
    }

    req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8'))
    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted to %s", slack_message['channel'])
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)

 

テスト実行用JSON

{
  "version": "0",
  "id": "98a0df14-0aa3-41e1-b603-5b27ce3c1431",
  "detail-type": "CodeBuild Build State Change",
  "source": "aws.codebuild",
  "account": "123456789012",
  "time": "2017-07-12T00:42:28Z",
  "region": "us-east-1",
  "resources": [
    "arn:aws:codebuild:us-east-1:123456789012:build/SampleProjectName:6bdced96-e528-485b-a64c-10df867f5f33"
  ],
  "detail": {
    "build-status": "IN_PROGRESS",
    "project-name": "SampleProjectName",
    "build-id": "arn:aws:codebuild:us-east-1:123456789012:build/SampleProjectName:6bdced96-e528-485b-a64c-10df867f5f33",
    "current-phase": "SUBMITTED",
    "current-phase-context": "[]",
    "version": "1"
  }
}

 

 

  1. テスト実行を行い、Slackに通知されるか
  2. GitHubにpushしてSlackに通知されるか

2つともクリアで設定完了

 

ログ分析基盤

 

Elasticsearch Service

 

 

例の如く、「t2.small.elasticsearch」で最小インスタンスを狙っていきます。

 

 

 

 

アクセス許可を行うIPを指定します。

 

 

 

きちんと作成されるまでには10分程度かかる。

 

待つのじゃ。

 

Kibana

ElasticsearchのKibanaのURLをクリックする

 

Kibanaにアクセスできる。

 

 

Cloudwatch Logs

Dockerのログについて、Nginx, PHP-FPMコンテナのログ標準出力になっており、awslogsドライバを通してCloudwatch Logsに出力される。

 

S3 + Athenaへ

S3にエクスポートできる。S3にエクスポートしたら「Athena」でクエリ検索が可能

ただし、この場合は手動になってしまうので、実際はLambdaで定期的にバッチ処理するか、Kinesis FirehoseからS3にエクスポートされるようにする。

 

Elasticsearch Serviceへ

 

「Elasticsearch Service」へ連携し、Kibanaで可視化できる。

 

 

 

 

 

 

 

  • AWSLambdaVPCAccessExecutionRole

ポリシーを付与します。

 

 

 

【次へ】をクリックしたら画面が遷移するので、【ストリーミングの作成】をクリックする。

 

 

 

RDS(MySQL8.0)

本当は絶対Auroraを使いたいところだけれど、お財布に厳しいのでリーンスタートとしてRDSのMySQL8.0で行きます。

 

パラメータグループの作成

 

これでパラメータグループ作成は完了。

 

 

 

 

 

 

 

 

 

 

 

データベースの作成

開発用EC2, ECSのインスタンスと同じVPCを選択しよう。

 

 

先ほど作成したパラメータグループを指定します。

 

 

 

 

 

 

 

RDSの書き込みエンドポイントをCNAMEで登録する

Route53に

  • 名前:db-master.yomuyo.net
  • タイプ:CNAME
  • 値:<RDSエンドポイント>

こうしておくとわかりやすいです。

 

セキュリティグループ

 

MySQL/Auroraに対して、開発用EC2, ECSのインスタンスのセキュリティグループIDをソースに指定する。

これでEC2からRDSにセキュアに接続できるようになる。

$ mysql -u yomuyodbmaster -h db-master.yomuyo.net -p


Enter password:<パスワード入力>
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 24
Server version: 8.0.15 Source distribution

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

 

 

.gitignoreについて

$ vi $HOME/yomuyo/.gitignore

/node_modules
/public/hot
/public/storage
/storage/*.key
/vendor
.env
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log

 

 

 

GitHubにアップロード

$ git add .
$ git commit -m ".env ADD for RDS Connect"
$ git push origin master

ECSまで自動デプロイされる。

 

本番のALBにアクセスして問題ないことを確認する
http://yomuyo-alb-xxx.ap-northeast-1.elb.amazonaws.com/

 

 

Route53 + ACM + ALBで自動証明書更新環境

 

Route53

「yomuyo.net」のホストゾーンを作成します。

 

 

【レコードセットの作成】をクリックします。

 

 

www.yomuyo.netとALBをエイリアスで紐づけます。CNAMEよりエイリアスのほうが通信コスパが良いです。

 

 

yomuyo.netとALBをエイリアスで紐づけます。

 

 

これでRoute53の設定はおしまい。

 

 

バリュードメインのDNSサーバの設定にAWSのネームサーバを設定します。

 

http://yomuyo.net/

これでアクセスが出来ました。

 

ACM(Certificate Manager)

AWSの証明書発行フルマネージドサービス

 

「yomuyo.net」, 「www.yomuyo.net」を取得します。

 

 

 

 

 

 

 

 

10分程度待ってからリロードさせてください。

 

証明書が発行されていますね!

 

ALBにACM証明書を適用する

 

【保存】をクリックします。

 

https://yomuyo.net/

アクセス出来ました。
※アクセスできない場合はセキュリティグループのインバウンドに443で接続できる設定になっているか見直してください。

 

HTTP(80)のアクセスをHTTPSにリダイレクトする

一昔前はNginx側でリダイレクトしていたけれど、今はALBのルールから設定が行えます。

 

 

 

 

 

 

http://yomuyo.net/にアクセスした時に、

https://yomuyo.net/にリダイレクトがかかれば設定は大丈夫。

 

ネイキッドドメインをhttps://www.<ドメイン名>にリダイレクト

443のルールをこのようにすると良い。

https://example.net/
→https://www.example.net

 

MackerelでECSの監視

https://mackerel.io/ja/

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

MACKEREL_APIKEYをメモに控えます。この値は後でECSで利用します。

 

 

ECS側設定

タスク定義から、mackerel-container-agentのコンテナを追加する形でMackerelと紐づけることが出来ます。

 

 

 

 

 

  • コンテナ名
    mackerel-container-agent
  • イメージ
    mackerel/mackerel-container-agent:latest
  • メモリ制限 ハード制限
    128

 

環境変数

  • MACKEREL_CONTAINER_PLATFORM
    ecs_v3
  • MACKEREL_APIKEY: Mackerel API
    ※さっき控えたAPIキーの値

 

 

 

 

【サービスの更新】からウィザードに沿って次へと進めて、反映を行ってください。

 

Mackerelにホストが追加されました。

 

コンテナ毎にグラフも見れるわけです。

 

MackerelからSlackへの通知

メトリクスを監視します。

 

CPUの使用率監視

 

 

 

 

SWAP監視

 

 

 

 

以前Slackで作成していたいWebhookのURLを利用することでMackerelとSlackの連携を行います。

 

これで連携が出来ました。

 

 

MackerelからTwillioに通知する

(姓と名が逆だけれど、これは後で直せるYO!)

https://twilio.kddi-web.com/signup/

サインアップで進めていきます。

 

 

 

 

SendGrid メール送信管理

 

【Setting】=> 【Sender Authentication】=>【Domain Authentication】を選択します。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

逆引き設定について

  • Proプランかつ固定IPアドレス追加で予算オーバーになるんだが!
    →導入せず
  • 実際のエンタープライズな利用では逆引き設定は必ず設定しましょう

 

Web API Keyの取得

【Setting】=> 【API Keys】=>  【Create API Key】

 

 

※SendGridのAPIキーは漏らさないようにしましょう、APIキーだけで第三者にSendGridが使われます。

 

curlコマンドでAPIキーで送信できるかをテストします。

curl --request POST \
  --url https://api.sendgrid.com/v3/mail/send \
  --header 'Authorization: Bearer <SendGrid APIキー>' \
  --header 'Content-Type: application/json' \
  --data '{"personalizations": [{"to": [{"email": "ToUser@example.com"}]}],"from": {"email": "FromUser@yomuyo.net"},"subject": "Hello, World!","content": [{"type": "text/plain", "value": "Heya!"}]}'

メールが受信できたので作業を進めます。

 

 

LaravelでSendGrid APIによるメール送信

 

AWS System ManagerのパラメータストアにAPIキーを設定する

コンテナでの環境変数の値を確認

ECS配下のEC2にSSHログインする。

$ docker ps

 

PHP-FPMのコンテナにログインする

$ docker exec -it <イメージID> bash

 

環境変数を確認する

# env

 

SendGridのAPIキーやDBの接続情報が入っていればOK

 

 

SendGrid-PHPライブラリのインストール

$ vi $APP_PATH/composer.json


・・・
    "require": {
        "php": "^7.1.3",
        "fideloper/proxy": "^4.0",
        "laravel/framework": "5.8.*",
        "laravel/tinker": "^1.0",
        "sendgrid/sendgrid": "~7" // ←●追加
    },
・・・

 

アップデートと同時にインストールも行われる

$ docker-compose exec php-fpm composer install
$ docker-compose exec php-fpm composer dump-autoload

 

$ vi $HOME/yomuyo-docker-template/env/.env.local

APP_NAME=yomuyo
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost

LOG_CHANNEL=stack

## MySQL
DB_CONNECTION=mysql
DB_HOST=mysql56
DB_PORT=3306
DB_DATABASE=yomuyodb
DB_USERNAME=root
DB_PASSWORD=naishodayo

# Master
DB_MASTER_HOST=mysql56
DB_MASTER_PORT=3306

# Slave(Read)
DB_SLAVE_HOST=mysql56
DB_SLAVE_PORT=3306


※最終行に追加
## SendGrid
SENDGRID_API_KEY=<SendGridキー>

※リードレプリカする予算はないので、Slave(Read)もマスターのドメインを利用します。

貧乏が悪いのだ。

 

$ vi $APP_PATH/config/mail.php


<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Mail Driver
    |--------------------------------------------------------------------------
    |
    | Laravel supports both SMTP and PHP's "mail" function as drivers for the
    | sending of e-mail. You may specify which one you're using throughout
    | your application here. By default, Laravel is setup for SMTP mail.
    |
    | Supported: "smtp", "sendmail", "mailgun", "mandrill", "ses",
    |            "sparkpost", "postmark", "log", "array"
    |
    */

    'driver' => env('MAIL_DRIVER', 'smtp'),
    'host' => env('MAIL_HOST', 'smtp.sendgrid.net'),
    'port' => env('MAIL_PORT', 587),
    'from' => [
        'address' => env('MAIL_FROM_ADDRESS', 'no-reply@yomuyo.net'),
        'name' => env('MAIL_FROM_NAME', '自動送信メール'),
    ],
    'encryption' => env('MAIL_ENCRYPTION', 'tls'),
    'username' => env('apikey'),
    'password' => env(getenv('SENDGRID_API_KEY')),


・・・

 

$ vi $APP_PATH/app/Http/Controllers/EmailController.php


<?php

namespace App\Http\Controllers;

use App\Http\Controllers;
use Illuminate\Http\Request;

class EmailController extends Controller
{

    public function contact()
    {
        $email = new \SendGrid\Mail\Mail();
        $email->setFrom("no-reply@yomuyo.net", "Example User");
        $email->setSubject("Sending with SendGrid is Fun");
        $email->addTo("ToUser@example.com", "Example User");
        $email->addContent("text/plain", "and easy to do anywhere, even with PHP");
        $email->addContent(
            "text/html", "<strong>and easy to do anywhere, even with PHP</strong>"
        );

        $sendgrid = new \SendGrid(getenv('SENDGRID_API_KEY'));
        try {
            $response = $sendgrid->send($email);
            print $response->statusCode() . "\n";
            print_r($response->headers());
            print $response->body() . "\n";
        } catch (Exception $e) {
            echo 'Caught exception: '. $e->getMessage() ."\n";
        }

        return view('emails.contact');
    }
}

 

$ vi $APP_PATH/routes/web.php


※下記を追加する

Route::get('contact', 'EmailController@contact');

 

アクセスすると送信できる
https://<ドメイン名>/contact/

 

セッション管理 ElastiCache Redis

  • ALBのスティッキーセッションで良いんじゃない?って実装してトラブルになっている事例を良く聞く。
  • セキュリティエンジニアではないけれど、Cookieの値をクライアント側にそのまま持たせるのはセキュリティ上良くないことは知っている。

  • クラスタ構成の場合は素直にセッションサーバを建てるのが安定。
  • セッションサーバが単一障害点になる!
    だからフルマネージドのElastiCacheを利用する

パラメータグループの作成

 

 

Redisの作成

 

パラメータグループは先ほど作成したものをきちんと選択します。

レプリケーション数は予算がないので「0」にした。実際はちゃんとレプリケーションさせましょ~!

 

 

ElastiCache(Redis)が作成されました。

 

Route53

  • プライマリエンドポイントをCNAMEで「elasticache-redis-primary.yomuyo.net」に設定する

 

セキュリティグループ

  • Redisのセキュリティグループに、EC2からのみアクセスできるように設定
    ※エンドポイントの正引きがローカルIPなので大丈夫だとは思いますが、念の為「0.0.0.0/0」のように全開放するのは避けて下さい。

 

接続テスト

redis-cliコマンドを利用したいのでredisをインストール

$ sudo yum install -y redis

 

EC2からElastiCacheに接続する

$ redis-cli -h elasticache-redis-primary.yomuyo.net


elasticache-redis-primary.yomuyo.net:6379>

接続できた。

 

これでLaravelの設定を行えば、ElastiCacheにセッションを保存することが可能になります。

 

Laravelとの連携

 

設定状況「確認」

$ view $APP_PATH/config/database.php


    'redis' => [

        'client' => env('REDIS_CLIENT', 'predis'),

        'options' => [
            'cluster' => env('REDIS_CLUSTER', 'predis'),
            'prefix' => Str::slug(env('APP_NAME', 'laravel'), '_').'_database_',
        ],

        'default' => [
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', 6379),
            'database' => env('REDIS_DB', 0),
        ],

 

predisライブラリのインストール

$ docker-compose exec php-fpm composer require predis/predis
$ docker-compose exec php-fpm composer install

 

$ vi $HOME/yomuyo/.env


## Redis(Session)
SESSION_DRIVER=redis
REDIS_HOST=elasticache-redis-primary.yomuyo.net
REDIS_PASSWORD=null
REDIS_PORT=6379

 

 

elasticache-redis-primary.yomuyo.net:6379> keys *

1) "yomuyo_database_yomuyo_cache:xxxxxxxxxxxxxxxxxxxxx"

値を確認出来たらおっけ

 

環境変数設定

AWS System Managerのパラメータストア, ECSのPHP-FPMコンテナの環境変数にRedis_HOSTを設定する必要がある。

 

 

 

Google Books API取得

https://console.developers.google.com/

 

Google Books APIで取得できるデータはキーが豊富!

…なんだけど、[“id”]を起点として責めていった方が良い。[“thumnail”]などのキーが本によってあったりなかったりして安定しない。ISBNコードが抜けていたり、独自もありで配列処理を頑張らなくちゃで厄介…。

だから[“id”]を利用すると良い、IDを制すものがGoogle Books APIを制すのだ。

 

APIキーがなくても使える!

$ vi $APP_PATH/app/Http/Controllers/BookController.php

<?php

 namespace App\Http\Controllers;

 use Illuminate\Http\Request;
 use App\Models\Book;           // ←追加 ●Bookモデルを呼び出すよ
 use App\Http\Requests\BookRequest;
 use Illuminate\Pagination\LengthAwarePaginator;
 use Illuminate\Pagination\Paginator;
 use Illuminate\Support\Facades\Log;
 use App\Models\Review;
 use Illuminate\Support\Facades\Cache;   // キャッシュファサード

class BookController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return Response
     */
    public function index()
    {
        $key_count         = (string) "BookController_index_count";   // キャッシュキー
        $key_items         = (string) "BookController_index_items";   // キャッシュキー
        $key_reviews       = (string) "BookController_index_reviews"; // キャッシュキー
        $limit_count       = 60;                                // キャッシュ保持期間
        $limit_items       = 30;                                // キャッシュ保持期間
        $limit_reviews     = 30;                                // キャッシュ保持期間

        // レビュー総数を取得
        $review = new Review;
        $count = $review->sum($key_count, $limit_count);

        // 4件ずつ一覧取得
        $items   = $review->getList($key_items, $limit_items, 4);
        // 6件ずつレビューを取得
        $reviews = $review->getList($key_reviews, $limit_reviews, 6);
        return view('book.index', compact("count", "items", "reviews"));
    }


    /** ==================================
     *   Google Books APIから取得
     *  ==================================
     *  @param  BookRequest $request
     *  @return array
     */
    public function search(BookRequest $request)
    {
        $form = $request->all();
        unset($form['_token']); // トークンは削除しておく

        $currentPage = isset($_GET['page']) ? (int)$_GET['page'] : 1;  // 現在のページ
        $perPage = 8;                                                  // Paginationでの1ページ当たりの表示数

        $code = md5($form['name']); // キャッシュキーで日本語を避けたいので変換
        $key_data    = "BookController_search_{$code}_{$currentPage}"; // キャッシュキー
        $limit_data  = 604800;                                                                // キャッシュ保持期間(604800 = 一週間)


        try{
               if(isset($form['name'])){
                   $post_data  = trim( preg_replace("/( | )/", "", $form['name']) ); // 著者名 or タイトルを取得(空白を削除)
                   $totalItems = 40;             // APIで取得するデータ最大数
                   $perPage    = 8;              // Paginationでの1ページ当たりの表示数
               }else{
                   $books_flag = 0; // データなし
                   return view('book.result', compact("books_flag") );
               }

               // キーからキャッシュを取得
               if(Cache::has($key_data)){
                   $cache = (array) Cache::get($key_data);
               }

               //// キャッシュがあればキャッシュを取得
               if( isset($cache) ){
                   $json_decode = (array) $cache; // 変数名をリネームして合わせる
               }else{
                   // Google BooksAPIからデータをJSONで取得して配列化
                   $data = "https://www.googleapis.com/books/v1/volumes?q={$post_data}&country=JP&maxResults={$totalItems}&orderBy=newest&langRestrict=ja";
                   $json = @file_get_contents($data);
                   $json_decode = json_decode($json, true);
                   Cache::add($key_data, $json_decode, $limit_data);
               }


               $books_flag = 1;

               // 本の検索データがあるかを判定
               if($json_decode['totalItems'] == 0)
               {

                   $books_flag = 0; // データなし
                  return view('book.result', compact("post_data", "books_flag") );
               }


               // ページャ用データ作成
               $itemCollection = collect($json_decode['items']);           // collectヘルパの利用
               $currentPageItems = $itemCollection->slice(($currentPage * $perPage) - $perPage, $perPage)->all(); // $this->slice(配列の切り分け開始位置, 終了位置)
               $paginatedItems = new LengthAwarePaginator($currentPageItems , count($itemCollection), $perPage);
               $paginatedItems->setPath("/book/search/?name={$post_data}");

               return view('book.result', compact("paginatedItems", "post_data", "books_flag") );
        }
        catch(\Exception $e){
            echo "<a href=\"/\">トップページへ戻る</a>:<br/>";
            echo "データ取得エラー。ご迷惑をおかけしております。:" . $e->getMessage();
            Log::error($e->getMessage());
            exit();
        }

    }



    public function detail(Request $request)
    {
            $item  = $request->all();
            unset($item['_token']);
            return view('book.detail', compact("item") );
    }

・・・
・・・

 

 

配列の視覚化

<?php
    echo('<pre>');
    var_dump($json_decode['items']);
    echo('</pre>');
    exit();
?>

var_dump()の前後に<pre>タグを行うと見やすくなる。

 

ヘッダーに検索フォームを付けた

/views/layouts/partials/header.blade.php

$ vi $APP_PATH/resources/views/layouts/partials/header.blade.php


<!-- header & grobal navi -->
<nav class="navbar navbar-default" style="background-color: #FFFFFF;">
  <div class="container-fluid">
  <div class="navbar-header">
   <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbarEexample2">
   <span class="sr-only">Toggle navigation</span>
   <span class="icon-bar"></span>
   <span class="icon-bar"></span>
   </button>

   <a class="navbar-brand" href="/">yomuyo</a>
  </div>
  <div class="collapse navbar-collapse" id="navbarEexample2">
   <ul class="nav navbar-nav">
    <li><a href="/add">新規登録(無料)</a></li>
    <li><a href="/login">ログイン</a></li>
   </ul>
  </div>
  </div>
</nav>

<div align="center">
  <form action="/book/search" method="POST">
   @csrf
   著者・タイトル
   <input type="text" name="name" />
   <input type="submit" value="検索" />
  </form>
</div>

<hr/>

 

 

/app/Http/Controllers/BookController.php

$ cat $HOME/yomuyo/app/Http/Controllers/BookController.php


<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\Book;           // ←追加 ●Bookモデルを呼び出すよ
use App\Http\Requests\BookRequest;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Log;

class BookController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return Response
     */
    public function index()
    {
            return view('book.index');
    }


    public function search(BookRequest $request)
    {
        try{

            $post_data  = (string) trim( preg_replace("/( | )/", "", $request->name) ); // 著者名 or タイトルを取得(空白を削除)
            $totalItems = (int) 40;             // APIで取得するデータ最大数
            $perPage    = (int) 8;              // Paginationでの1ページ当たりの表示数

            // Google BooksAPIからデータをJSONで取得して配列化
            $data = "https://www.googleapis.com/books/v1/volumes?q={$post_data}&country=JP&maxResults={$totalItems}&orderBy=newest&langRestrict=ja";
            $json = @file_get_contents($data);
            $json_decode = json_decode($json, true);

            $books_flag = (int) 1;
            // 本の検索データがあるかを判定
            if($json_decode['totalItems'] == 0)
            {
                $books_flag = (int) 0; // データなし
                return view('book.result', compact("post_data", "books_flag") );
            }


            // ページャ用データ作成
            $currentPage = LengthAwarePaginator::resolveCurrentPage();  // 現在のページ数を$request['page']の値をLengthAwarePaginator::resolveCurrentPage()で取得
            $itemCollection = collect($json_decode['items']);           // collectヘルパの利用
            $currentPageItems = $itemCollection->slice(($currentPage * $perPage) - $perPage, $perPage)->all(); // $this->slice(配列の切り分け開始位置, 終了位置)
            $paginatedItems = new LengthAwarePaginator($currentPageItems , count($itemCollection), $perPage);
            $paginatedItems->setPath("/book/search/?name={$post_data}");


            return view('book.result', compact("paginatedItems", "post_data", "books_flag") );
        }
        catch(\Exception $e){
            echo "データ取得エラー。ご迷惑をおかけしております。<a href="/">トップページへ戻る</a>";
            Log::error($e->getMessage());
            exit;
        }

    }



    public function detail(Request $request)
    {
            $item  = $request->all();
            return view('book.detail', compact("item") );
    }

・・・

 

Google APIで本を検索します。

?q=intitle:{$post_data[“name”]}&q=inauthor:{$post_data[“name”]}&country=JP”;

  • intitle
    タイトル
  • inauthor
    著者名

何でもヒットするとUI上問題があるので、この2つを指定します。

 

表示先

$ cat $HOME/yomuyo/resources/views/book/result.blade.php


@extends('layouts.layout')
@section('title', 'サンプルホーム')
@section('content')
 <div class="page-header" class="col-sm-12 col-md-12 col-lg-12">
  <h2><small>検索結果<b>『{{ $post_data }}』</b></small></h2>
 </div>

<?php
    //echo('<pre>');
    //var_dump($paginatedItems);
    //echo('</pre>');
    //exit();
?>


  <div class="flex-container row col-sm-12 col-md-12 col-lg-12">
  @if($books_flag==1)
    @foreach($paginatedItems as $item)

      <!-- Google Books Thumnail取得 -->
      @php
          $thumbnail = "https://books.google.com/books?id=".$item["id"]."&printsec=frontcover&img=1&zoom=5&edge=curl&source=gbs_api";
      @endphp

   <div class="card flex-card col-sm-6 col-md-3" >
      @if(isset($item["volumeInfo"]["imageLinks"]["thumbnail"]) )
        <div align="center">
          <a href="/book/detail?id={{ $item["id"] }}&thumbnail={{ $thumbnail }}&title={{ $item["volumeInfo"]["title"] }}">
            <img class="img-thumbnail" src="{{ $thumbnail }}" alt="{{ $item["volumeInfo"]["title"] }}">
          </a>
        </div>
      @else
        <div align="center">
          <img class="img-thumbnail" src="{{ asset('/images/no-image.jpg')  }}" alt="画像">
        </div>
      @endif
      <div class="card-body">
      @if(isset($item["volumeInfo"]["title"]))
        <a href="/book/search?name={{ str_limit($item["volumeInfo"]["title"], $limit = 40) }}">
              <h4 class="card-title">{{ str_limit($item["volumeInfo"]["title"], $limit = 28, $end = '...') }}</h4>
        </a>
      @else
        <h4 class="card-title">タイトルなし</h4>
      @endif
        <a href="/home?id={{ $item["id"] }}&thumbnail={{ $thumbnail }}&title={{ str_limit($item["volumeInfo"]["title"], $limit = 16, $end = '') }}" class="btn btn-primary">登録</a>
         <a href="https://www.amazon.co.jp/s?k={{ $item["volumeInfo"]["title"] }}" target="_blank" class="btn btn-default">Amazonで購入</a>
      </div><!-- card-body -->
   </div><!-- card flex-card -->


    @endforeach
  </div><!-- /.flex-container -->




    {{ $paginatedItems->appends($post_data)->render() }}


  @else($books_flag==0)
 <div class="page-header" class="col-sm-12 col-md-12 col-lg-12">
   <h2>書籍データがないようです。ごめんなさい。</h2>
 </div>
  @endif



@endsection

 

検索結果の取得

ちゃんとデータを引っ張れている。

 

小ネタ

値を入れずにGoogle Books APIに投げると、ハッキング関係の本がレコメンドされる
https://www.googleapis.com/books/v1/volumes?q=intitle:&country=JP

初見はひやっとした;

 

Bootstrap4 グリッド

cosの高さをそろえて横並びにする

 

<div class="container">
  <div class="row row-eq-height">
  @foreach($reviews as $review)
   <div class="col-xs-12 col-sm-4 col-md-4 col-lg-4" >
     <div class="innerbox">

     ・・・内容

     </div><!-- innerbox -->
   </div>
  @endforeach 
  </div><!-- row -->
</div><!-- container -->
  • .containerのdivでrowを囲む
  • rowのカラムにCSSでflex属性を付与する為のrow-eq-heightクラスを付与する
    ※row-eq-heightの名前は何でも良いです。
  • CSSでパディングを設定する為にinnerboxクラスを付与する

 

 

/public/css/style.css

.row-eq-height {
    display: flex;
    flex-wrap: wrap;
}

.innerbox {
  margin-top:10px;
  padding: 10px;
  border: double 5px #4ec4d3;
  border-top: solid 10px #b5f492;
}
  • .row-eq-heightにdisplay:flex; flex-wrap:wrap;
  • .innerboxにパディングを設定する

 

 

 

<div class="container">
 <div class="row row-eq-height">
 @foreach($reviews as $review)
   <div class="col-xs-12 col-sm-4 col-md-4 col-lg-4" >
     <div class="innerbox">
          <img src="{{ asset('/images/profile_default_icon.gif') }}"> {{ $review->user_name }} さん  いいね<span class="badge">14</span>
          <hr/>
          {{ $review->comment }}
          <hr/>
          <div class="row">
            <div class="col-xs-12 col-sm-4 col-md-4 col-lg-4">
              <a href="/book/detail?id={{ $review->google_book_id }}&thumbnail={{ $review->thumbnail }}&title={{ $review->book_title }}"><img class="img-thumbnail" src="http://s3.yomuyo.net/books/{{ $review->thumbnail }}" alt="{{ $review->book_title }}"></a>
            </div>
            <div class="col-xs-12 col-sm-8 col-md-8 col-lg-8">
              <a href="/book/search?name={{ str_limit($review->book_title, $limit = 28, $end = '...') }}"><h4 class="card-title">{{ str_limit($review->book_title, $limit = 38, $end = '...') }}</h4></a>
              <hr/>
              <a href="/home?id={$review->thumbnail&title={{ str_limit($review->book_title, $limit = 28, $end = '...') }}, $limit = 16, $end = '') }}" class="btn btn-primary">登録</a> <a href="https://www.amazon.co.jp/s?k={{ $review->book_title }}" target="_blank" class="btn btn-default">Amazonで購入</a>
            </div>
          </div><!-- row -->
       <form>
            @csrf
              <div class="form-group">
                <textarea name="res" rows="2" cols="33" style="font-size: 18px;" placeholder="ここにコメントを書いてください。"></textarea>
              </div>
              <div class="form-group">
                  <button type="submit" class="btn btn-primary" >コメントする</button>
              </div>
            </form>
     </div><!-- innerbox -->
   </div>
 @endforeach

 </div><!-- row -->
</div><!-- container -->

 

 

 

フォーカスした際にplaceholderを非表示にする

<div align="center">
  <form action="/book/search" method="POST">
   @csrf
   <h3 class="search">本を探そう</h3>
   <input type="text" name="name" placeholder="本のタイトル・著者名" onfocus="this.placeholder=''" onblur="this.placeholder='本のタイトル・著者名'"/>
   <input type="submit" value="検索" class="submit-button" />
   @if($errors->has('name'))
     <hr/>
     <tr><th><td><span class="error_mes">{{ $errors->first('name') }}</span></td></tr>
   @endif
  </form>
</div>

 

 

Laravel MVC周りのひな形

MVC

 

レイアウトの作成

 

 

$ mkdir -p $APP_PATH/resources/views/layouts/partials

 

$ vi $APP_PATH/resources/views/layouts/layout.blade.php
 
 
<!DOCTYPE HTML>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>@yield('title')</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css" rel="stylesheet" media="screen"
>
    <link href="/css/sticky-footer.css" rel="stylesheet" media="screen">
 
</head>
<body>
<!-- ヘッダー -->
@include('layouts.partials.header')
 
<div class="container">
 
  <div class="row" id="content">
  <div class="col-md-9">
  <!-- コンテンツ -->
  @yield('content')
  </div>
  </div>
 
</div>

 

$ vi $APP_PATH/resources/views/layouts/partials/header.blade.php


<!-- header & grobal navi -->
<nav class="navbar navbar-default" style="background-color: #FFFFFF;">
  <div class="container-fluid">
  <div class="navbar-header">
  <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbarEexample2">
  <span class="sr-only">Toggle navigation</span>
  <span class="icon-bar"></span>
  <span class="icon-bar"></span>
  <span class="icon-bar"></span>
  </button>
  <a class="navbar-brand" href="/">
  <img alt="Sample掲示板なのだ" src="/img/logo.png" style="height: 20px;">
  </a>
  </div>

  <div class="collapse navbar-collapse" id="navbarEexample2">
  <ul class="nav navbar-nav">
  <li class="active"><a href="/">メニュー</a></li>
  <li><a href="/add">新規登録</a></li>
  </ul>
  </div>
  </div>
</nav>

 

$ vi $APP_PATH/resources/views/layouts/partials/footer.blade.php
 
<!-- footer -->
<footer class="footer">
  <div class="container">
  <p class="text-muted">Copyright (C) yomuyo運営委員会 All Rights Reserved.</p>
  </div>
</footer>

 

booksテーブルの作成

テーブルは複数系で指定します

$ docker-compose exec php-fpm php artisan make:migration books_table --create=books

「–create=テーブル名」オプションをつけた方が出来上がるひな形が良い。

 

$ vi $APP_PATH/database/migrations/2019_07_01_010149_books_table.php


<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class BooksTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('books', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->string('author');
            $table->string('comment');
            $table->string('tag');
            $table->string('users_id');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('books');
    }
}

 

テーブル作成

$ docker-compose exec php-fpm php artisan migrate:refresh

 

シーダファイル BooksTableSeederの作成

シーダを利用してbooksテーブルにサンプルデータを入れます。

$ docker-compose exec php-fpm php artisan make:seeder BooksTableSeeder

 

シーダファイルを編集してサンプルデータを入れる

$ cat $APP_PATH/database/seeds/BooksTableSeeder.php


<?php

use Illuminate\Database\Seeder;

class BooksTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        DB::table('books')->insert([
            "name"     => "成功するチームの作り方",
            "author"   => "増田智明",
            "comment"  => "チーム開発を音楽のオーケストレーションに準えたマネジメント本。買いです!",
            "tag"      => "IT",
            "users_id" => "1",
            "created_at" => new DateTime(),
            "updated_at" => new DateTime()
        ]);
    }
}

 

DatabaseSeeder.phpファイルを編集して、「BooksTableSeeder」をシーダ対象とする。

$ vi $APP_PATH/database/seeds/DatabaseSeeder.php


<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        //$this->call(UsersTableSeeder::class);
        $this->call(BooksTableSeeder::class);
    }
}

 

 

シーダの実行

$ docker-compose exec php-fpm php /app/artisan db:seed

これでサンプルデータが入る

 

データの確認

$ mysql -u yomuyodbmaster -h db-master.yomuyo.net -p


Enter password:
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 148
Server version: 8.0.15 Source distribution

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [(none)]> use yomuyodb;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A


Database changed
MySQL [yomuyodb]> SELECT * FROM books;
+----+-----------------------------------+--------------+--------------------------------------------------------------------------------------------------------------+-----+----------+---------------------+---------------------+
| id | name                              | author       | comment                                                                                                      | tag | users_id | created_at          | updated_at          |
+----+-----------------------------------+--------------+--------------------------------------------------------------------------------------------------------------+-----+----------+---------------------+---------------------+
|  1 | 成功するチームの作り方            | 増田智明     | チーム開発を音楽のオーケストレーションに準えたマネジメント本。買いです!                                     | IT  | 1        | 2019-07-01 01:34:44 | 2019-07-01 01:34:44 |
+----+-----------------------------------+--------------+--------------------------------------------------------------------------------------------------------------+-----+----------+---------------------+---------------------+
1 row in set (0.00 sec)

データが入っていることが確認できた。

 

BookController, Bookモデルの作成

 

Model格納ディレクトリの作成

$ mkdir $APP_PATH/app/Models

 

Bookモデルの作成 ※モデルは単数系で指定する

$ docker-compose exec php-fpm php artisan make:model Models/Review

 

$ vi $APP_PATH/app/Models/Review.php


<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;      // ←追加 ●DBを操作するのにこれは必須
use Illuminate\Http\Request;            // ←追加 ●きっと後で使うよ
use Storage;                            // AWS S3アクセス league/flysystem-aws-s3-v3
use Illuminate\Support\Facades\Log;     // ログ
use Illuminate\Support\Facades\Cache;   // キャッシュファサード

class Review extends Model
{
    protected $table      = 'reviews';      // テーブル名
    protected $primaryKey = 'id';           // PK
    protected $guarded    = array('id');    // PK


   /** ==========================
    *   リレーション
    *  ==========================
    */
    public function book()
    {
        return $this->belogsTo(Book::class);
    }

    public function user()
    {
        return $this->belogsTo(User::class);
    }



   /** =======================================================
    *   レビュー総件数を取得
    *  ========================================================
    *   @param  string  $key     : キャッシュのキー
    *   @param  integer $limit   : 保持期間(秒)
    *   @return integer $cache   : キャッシュ(レビュー総件数)
    *   @return integer $count   : レビュー総件数
    */
    public function sum(string $key, int $limit)
    {
        // キーからキャッシュを取得
        $cache = Cache::get($key);

        // キャッシュがあればキャッシュを返す
        if( isset($cache) ){
            return $json_decode = (int) $cache;
        }else{
            // キャッシュがなければ取得して、キャッシュに保存する
            $count = DB::table($this->table)->count();
            Cache::add($key, json_encode($count), $limit); // キャッシュがなければキャッシュする
            return $count;
        }
    }

   /** ==================================================
    *    $number 件 読まれている本を一覧取得
    *   =================================================
    *   @param string   $key        : キャッシュキー
    *   @param integeer $limit      : キャッシュ保持期間
    *   @param integer  $number     : 取得件数
    *   @param integer  $id         : ユーザID
    *   @return array               : レビューデータ
    */
    public function getList(string $key = null, int $limit =null, int $number, int $id = null)
    {
        // キーからキャッシュを取得
        $cache = Cache::get($key);

        // キャッシュがあればキャッシュを返す
        if( isset($cache) ){
            $json_decode = (int) $cache;
        }else{

            if( isset($id) )
            {
                // users.idが指定されている場合: 任意のユーザのレビューを取得
                $items = DB::table($this->table)->select(
                                                      'reviews.id',
                                                      'reviews.book_id',
                                                      'reviews.user_id',
                                                      'reviews.netabare_flag',
                                                      'reviews.comment',
                                                      'reviews.updated_at',
                                                      'books.google_book_id',
                                                      'books.name as book_title',
                                                      'books.thumbnail',
                                                      'users.name as user_name'
                                                     )
                                             ->join('books',  'reviews.book_id', '=', 'books.id')
                                             ->join('users',  'reviews.user_id', '=', 'users.id')
                                             ->where('users.id', '=', $id)
                                             ->paginate($number);
                return $items;
            }else{
                // users.idが指定されていない場合: 全件からレビューを取得
                $items = DB::table($this->table)->select(
                                                      'reviews.id',
                                                      'reviews.book_id',
                                                      'reviews.user_id',
                                                      'reviews.netabare_flag',
                                                      'reviews.comment',
                                                      'reviews.updated_at',
                                                      'books.google_book_id',
                                                      'books.name as book_title',
                                                      'books.thumbnail',
                                                      'users.name as user_name'
                                                     )
                                             ->join('books', 'reviews.book_id', '=', 'books.id')
                                             ->join('users', 'reviews.user_id', '=', 'users.id')
                                             ->paginate($number);
                return $items;
            }
        }
    }

・・・
・・・

}

 

Bookコントローラの作成

$ docker-compose exec php-fpm php artisan make:controller BookController

 

コントローラのひな形作成

$ vi $APP_PATH/app/Http/Controllers/BookController.php


<?php

 namespace App\Http\Controllers;

 use Illuminate\Http\Request;
 use App\Models\Book;           // ←追加 ●Bookモデルを呼び出すよ
 use App\Http\Requests\BookRequest;
 use Illuminate\Pagination\LengthAwarePaginator;
 use Illuminate\Pagination\Paginator;
 use Illuminate\Support\Facades\Log;
 use App\Models\Review;
 use Illuminate\Support\Facades\Cache;   // キャッシュファサード

class BookController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return Response
     */
    public function index()
    {
        $key_count         = (string) "BookController_index_count";   // キャッシュキー
        $key_items         = (string) "BookController_index_items";   // キャッシュキー
        $key_reviews       = (string) "BookController_index_reviews"; // キャッシュキー
        $limit_count       = 60;                                // キャッシュ保持期間
        $limit_items       = 30;                                // キャッシュ保持期間
        $limit_reviews     = 30;                                // キャッシュ保持期間

        // レビュー総数を取得
        $review = new Review;
        $count = $review->sum($key_count, $limit_count);

        // 4件ずつ一覧取得
        $items   = $review->getList($key_items, $limit_items, 4);
        // 6件ずつレビューを取得
        $reviews = $review->getList($key_reviews, $limit_reviews, 6);
        return view('book.index', compact("count", "items", "reviews"));
    }

・・・
・・・
}

 

$ mkdir $APP_PATH/resources/views/book/

 

$ vi $APP_PATH/resources/views/book/index.blade.php


@extends('layouts.layout')
@section('title', 'サンプルホーム')
@section('content')
 <div class="page-header" style="margin-top:-30px;padding-bottom:0px;">
  <h1><small>Yomuyo -自分を変えた一冊を共有しよう-</small></h1>
 </div>

 <div class="top_image">
  <img src="{{ asset('/images/19212klzds_TP_V.jpg') }}">
  <p>最高の本を伝える<br/>
  新しい本に出会う</p>
 </div>


<br/>
<br/>

 <div class="page-header" style="margin-top:-30px;padding-bottom:0px;">
  <h2>みんなの投稿</h2>
 </div>


@endsection

 

$ vi $APP_PATH/resources/views/book/result.blade.php


@extends('layouts.layout')
@section('title', 'サンプルホーム')
@section('content')
 <div class="page-header" class="col-sm-12 col-md-12 col-lg-12">
  <h2><small>検索結果<b>『{{ $post_data }}』</b></small></h2>
 </div>

<?php
    //echo('<pre>');
    //var_dump($paginatedItems);
    //echo('</pre>');
    //exit();
?>


  <div class="flex-container row col-sm-12 col-md-12 col-lg-12">
  @if($books_flag==1)
    @foreach($paginatedItems as $item)

      <!-- Google Books Thumnail取得 -->
      @php
          $thumbnail = "https://books.google.com/books?id=".$item["id"]."&printsec=frontcover&img=1&zoom=5&edge=curl&source=gbs_api";
      @endphp

   <div class="card flex-card col-sm-6 col-md-3" >
      @if(isset($item["volumeInfo"]["imageLinks"]["thumbnail"]) )
        <div align="center">
          <a href="/book/detail?id={{ $item["id"] }}&thumbnail={{ $thumbnail }}&title={{ $item["volumeInfo"]["title"] }}">
            <img class="img-thumbnail" src="{{ $thumbnail }}" alt="{{ $item["volumeInfo"]["title"] }}">
          </a>
        </div>
      @else
        <div align="center">
          <img class="img-thumbnail" src="{{ asset('/images/no-image.jpg')  }}" alt="画像">
        </div>
      @endif
      <div class="card-body">
      @if(isset($item["volumeInfo"]["title"]))
        <a href="/book/search?name={{ str_limit($item["volumeInfo"]["title"], $limit = 40) }}">
              <h4 class="card-title">{{ str_limit($item["volumeInfo"]["title"], $limit = 28, $end = '...') }}</h4>
        </a>
      @else
        <h4 class="card-title">タイトルなし</h4>
      @endif
        <a href="/home?id={{ $item["id"] }}&thumbnail={{ $thumbnail }}&title={{ str_limit($item["volumeInfo"]["title"], $limit = 16, $end = '') }}" class="btn btn-primary">登録</a>
         <a href="https://www.amazon.co.jp/s?k={{ $item["volumeInfo"]["title"] }}" target="_blank" class="btn btn-default">Amazonで購入</a>
      </div><!-- card-body -->
   </div><!-- card flex-card -->


    @endforeach
  </div><!-- /.flex-container -->




    {{ $paginatedItems->appends($post_data)->render() }}


  @else($books_flag==0)
 <div class="page-header" class="col-sm-12 col-md-12 col-lg-12">
   <h2>書籍データがないようです。ごめんなさい。</h2>
 </div>
  @endif



@endsection

 

画像フォルダ作成

$ mkdir $APP_PATH/public/images/

ここに画像を格納する

 

$ vi $APP_PATH/public/css/style.css

@charset "utf-8";

html{
 font-size: 62.5%;
}

body{
 color: #333;
 font-size: 1.6rem;
}

.top_image {
  position: relative;
}

.top_image p {
  font-size: 4rem;
  color: white;
  position: absolute;
  top: 50%;
  left: 50%;
  -ms-transform: translate(-50%,-50%);
  -webkit-transform: translate(-50%,-50%);
  transform: translate(-50%,-50%);
  margin:0;
  padding:0;
  /*文字の装飾は省略*/
}

.top_image img {
  width: 100%;
}

 

 

 

 

$ vi $APP_PATH/routes/web.php


<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

//Route::get('/', function () {
//    return view('welcome');
//});

Route::get('/', 'BookController@index');
Route::post('book/search', 'BookController@search');

Route::get('contact', 'EmailController@contact');

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

 

 

ログイン

Laravelのmake:authを利用すると、ログインがあっという間に作れます。CSRF対策もされており、パスワードリセット機能もあります。

これだとポートフォリオ的にいまいちなので、一通り機能を作ったらソーシャルログイン機能をつけよう。

 

$ docker-compose exec php-fpm php /app/artisan make:auth

 

下記のルーティングが出来る

  • https://<ドメイン>/login
  • https://<ドメイン>/register
  • https://<ドメイン>/password/reset

 

ログインユーザ用のテーブルの作成

$ docker-compose exec php-fpm php /app/artisan migrate

Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table

 

シーディング

$ php artisan make:seeder UsersTableSeeder

 

$ vi $APP_PATH/database/seeds/UsersTableSeeder.php


<?php

use Carbon\Carbon; // 日付クラス
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class UsersTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        DB::table('users')->insert([
           'name' => 'test3',
           'email' => 'test3@example.net',
           'password' => bcrypt('yomuyo55'),
           'created_at' => Carbon::now(),
           'updated_at' => Carbon::now(),
        ]);
    }
}

 

シーダー定義ファイルに追加

$ vi $APP_PATH/database/seeds/DatabaseSeeder.php


<?php

・・・
    public function run()
    {
        //$this->call(UsersTableSeeder::class);
        $this->call(BooksTableSeeder::class);
+       $this->call(UsersTableSeeder::class);
    }
}

 

ダミーデータの挿入

$ docker-compose exec php-fpm php artisan migrate --seed

phpMyAdminでデータを確認して完了

 

 

日本語化対応

$ vi $APP_PATH/resources/lang/ja.json


{
    "Login"                : "ログイン",
    "Register"             : "新規登録(無料)",
    "E-Mail Address"       : "メールアドレス",
    "Password"             : "パスワード",
    "Remember Me"          : "ログイン状態を保持する",
    "Forgot Your Password?": "パスワードをお忘れになった方はこちら",
    "Name"                 : "ニックネーム",
    "Confirm Password"     : "パスワードの確認"
}

make:authによって作成されたbladeのヘルパー関数を日本語に置き換える

{{ __('E-Mail Address') }}

 

ロケール設定

$ vi $APP_PATH/config/app.php

・・・

    //'locale' => 'en',
    'locale' => 'ja',

・・・

 

 

Facebookログインの申請

API利用のキーとシークレットが必要

  1. Facebookの開発者登録
    https://developers.facebook.com/apps
    【設定】→ 【ベーシック】からキーとIDを確認できる。
  2. OauthリダイレクトIDを取得, コールバックURLの設定
    https://<ドメイン名>/auth/callback/facebook
    Facebookログイン → 【設定】から設定。
    ※httpsしか対応していないので注意。

 

Twitterでのソーシャルログイン申請

API利用のキーとシークレットが必要

申請を行います。
https://developer.twitter.com/

ソーシャルログインしか利用しませんと強調したら、6時間程度で審査完

 

 

 

socialiteのインストール

$ docker-compose exec php-fpm composer require laravel/socialite

 

config/app.php

$ vi $APP_PATH/config/app.php


    'providers' => [

・・・

        // Other service providers...
        Laravel\Socialite\SocialiteServiceProvider::class, //←●追加

    ],

 

    'aliases' => [

・・・

        'Socialite' => Laravel\Socialite\Facades\Socialite::class, //←●追加

    ],

 

.env.local

$ vi $HOME/yomuyo-docker-template/env/.env.local

※下記を追加


## Social Login
# Facebook
FACEBOOK_APP_ID=XXXXXXX
FACEBOOK_APP_SECRET=XXXXXXXXXXXXXXXXXX
FACEBOOK_CALLBACK_URL=https://<ドメイン名>/auth/callback/facebook

# Twitter
TWITTER_APP_ID=XXXXXXX
TWITTER_APP_SECRET=XXXXXXXXXXXXXXXXXX
TWITTER_APP_ACCESS_TOKEN=XXXXXXXXXXXXXXXXXX
TWITTER_APP_ACCESS_SECRET=XXXXXXXXXXXXXXXXXX
TWITTER_CALLBACK_URL=https://<ドメイン名>/auth/callback/twitter

Twitterの場合はアクセストークンが必要。

 

/config/services.php

$ vi $APP_PATH/config/services.php

<?php

return [

・・・

    'facebook' => [
        'client_id'     => env('FACEBOOK_APP_ID'),
        'client_secret' => env('FACEBOOK_APP_SECRET'),
        'redirect'      => env('FACEBOOK_CALLBACK_URL'),
    ],

    'twitter' => [
        'client_id'           => env('TWITTER_APP_ID'),
        'client_secret'       => env('TWITTER_APP_SECRET'),
        'access_token'        => env('TWITTER_APP_ACCESS_TOKEN'),
        'access_token_secret' => env('TWITTER_APP_ACCESS_TOKEN_SECRET'),
        'redirect'            => env('TWITTER_CALLBACK_URL'),
    ],

];

 

 

$ vi $APP_PATH/config/twitter.php

<?php

//Twitter API アクセストークン
return [
    'access_token' => env('TWITTER_APP_ACCESS_TOKEN'),
    'access_token_secret' => env('TWITTER_APP_ACCESS_TOKEN_SECRET'),
];

 

 

/app/Http/Controllers/SocialController.php

$ cat $APP_PATH/app/Http/Controllers/SocialController.php


<?php

 namespace App\Http\Controllers;
 use Illuminate\Http\Request;
 use Validator,Redirect,Response,File;
 use Socialite;
 use App\User;
 use Auth;

class SocialController extends Controller
{
    public function redirect($provider)
    {
        return Socialite::driver($provider)->redirect();
    }


    public function callback($provider)
    {
        // ユーザ情報のインスタンスを取得
        if($provider == "twitter")
        {
            //$access_token = config('twitter.access_token');
            //$access_token_secret = config('twitter.access_token_secret');
            //$getInfo = Socialite::driver('twitter')->userFromTokenAndSecret($access_token, $access_token_secret);
              $getInfo = Socialite::driver($provider)->user();
        }else{
            $getInfo = Socialite::driver($provider)->stateless()->user();
        }


        // $providerの指定で動的にSNS別のユーザインスタンスを作成
        $user = $this->createUser($getInfo,$provider);

        // そのままログイン
        //auth()->login($user);
        Auth::login($user);
        return redirect()->to('/home');
    }


    function createUser($getInfo,$provider)
    {
        // IDを取得
        $user = User::where('provider_id', $getInfo->id)->first();
        // provider_idがuserテーブルに存在しないなら、テーブルに挿入
        if(!$user){
            $user = User::create([
                        'name'     => $getInfo->name,
                        'email'    => $getInfo->email,
                        'provider' => $provider,
                        'provider_id' => $getInfo->id
                    ]);
        }
        return $user;
    }
}

 

ポイント

    public function callback($provider)
    {
        // ユーザ情報のインスタンスを取得
        if($provider == "twitter")
        {
            //$access_token = config('twitter.access_token');
            //$access_token_secret = config('twitter.access_token_secret');
            //$getInfo = Socialite::driver('twitter')->userFromTokenAndSecret($access_token, $access_token_secret);
              $getInfo = Socialite::driver($provider)->user();
        }else{
            $getInfo = Socialite::driver($provider)->stateless()->user();
        }

TwitterとFacebookでユーザ情報の取得方法を変えています。

  • Twitter
    $getInfo = Socialite::driver($provider)->user();
  • Facebook
    ->stateless()が必要
    $getInfo = Socialite::driver($provider)->stateless()->user();

プロバイダ先の仕様によって取得方法を変えていく必要がありそうですね!

 

/routes/web.php

$ vi $APP_PATH/routes/web.php

・・・

// ソーシャルログイン
Route::get('/auth/redirect/{provider}', 'SocialController@redirect');
Route::get('/auth/callback/{provider}', 'SocialController@callback');

 

$ vi $APP_PATH/database/migrations/xxxx_create_users_table.php



<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->string('email')->unique()->nullable();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password')->nullable();
            $table->string('avatar')->nullable()->unique();
            $table->string('provider')->nullable();
            $table->string('provider_id')->nullable()->unique();
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
}

 

代入値のホワイトリスト設定

$ vi $APP_PATH/app/User.php


・・・

class User extends Authenticatable
{
    use Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'email', 'password', 'provider', 'provider_id', // ←●追加
    ];

・・・

外部から入力される値を$fillableでホワイトリスト形式で指定します。

※$guardedはブラックリスト形式。$fillableと$guardedの併用はできない。

 

migrate:refreshで実行

$ docker-compose exec php-fpm php /app/artisan migrate:fresh

一度テーブル作っちゃっているのでこのオプションが必要

こうなる。

 

再度シーディング

$ docker-compose exec php-fpm php artisan migrate:refresh --seed

 

 

$ vi $HOME/yomuyo/resources/views/auth/login.blade.php


@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ __('Login') }}</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('login') }}">
                        @csrf

※下記を追加


                       SNSアカウントでログイン

                       <div class="social-login" align="center">
                               <div class="col-sm-6 col-md-6 col-lg-6" >
                                   <!-- Facebook Login Button -->
                                   <div class="facebook"><a href="{{ url('auth/redirect/facebook')}}">Facebookでログイン</a></div>
                                   <div class="twitter"><a href="{{ url('auth/redirect/twitter')}}">Twitterでログイン</a></div>
                               </div>
                       </div><!-- social-login -->

・・・

 

$ vi $HOME/yomuyo/config/app.php

・・・
    //'locale' => 'en',
    'locale' => 'ja',

 

Facebookログイン
https://<ドメイン名>/auth/login/facebook

 

dd($user)して値を見る

https://<ドメイン名>/auth/facebook/callback?code=※略

callbackからユーザデータを取得できました。

 

Facebookでログインボタンを押して認証すれば、こんな風にログイン出来る。

 

$ vi $APP_PATH/app/Http/Controllers/HomeController.php


<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class HomeController extends Controller
{
    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        // authミドルウェア生成.認証の有無ををチェックする。
        // このコントローラで利用する関数すべてに影響を与える
        $this->middleware('auth');
    }

    /**
     * Show the application dashboard.
     *
     * @return \Illuminate\Contracts\Support\Renderable
     */
    public function index(Request $request)
    {
        // ログインユーザ情報を渡す
        $user = Auth::user();
        return view('home', ['user' => $user]);
    }
}

 

$ cat $APP_PATH/resources/views/home.blade.php


@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">こんにちは!<b>{{ $user->name  }}</b>さん。</div>

                <div class="card-body">
                    @if (session('status'))
                        <div class="alert alert-success" role="alert">
                            {{ session('status') }}
                        </div>
                    @endif

                    読んだ本を探して感想を伝えよう!
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

 

ユーザ名が取れました。

 

Laravel \ Socialite \ Two \ InvalidStateException
No message

    public function callback($provider)
    {
        // ユーザ情報のインスタンスを取得
-       $getInfo = Socialite::driver($provider)->user();
+       $getInfo = Socialite::driver($provider)->stateless()->user(); //●←stateless()を付ける
        // $providerの指定で動的にSNS別のユーザインスタンスを作成
        $user = $this->createUser($getInfo,$provider);
        auth()->login($user);
        return redirect()->to('/home');
    }

statelessにつけない場合は不安定になるんだが…。

 

Twitterでもログインを確認。これでソーシャルログインはおしまい。

余裕ができたらLINEでのログインは行いたい。

 

ログイン後のURLを変更する

/homeではなく、/mypageに変更

$ vi $APP_PATH/app/Http/Middleware/RedirectIfAuthenticated.php

・・・

    public function handle($request, Closure $next, $guard = null)
    {
        if (Auth::guard($guard)->check()) {
            //return redirect('/home');
            return redirect('mypage');
        }

        return $next($request);
    }
}

 

$ vi $CONT_PATH/Auth/LoginController.php
$ vi $CONT_PATH/Auth/RegisterController.php
$ vi $CONT_PATH/Auth/ResetPasswordController.php
$ vi $CONT_PATH/Auth/VerificationController.php

-    protected $redirectTo = '/home';
+    protected $redirectTo = '/mypage';

 

 

アクセス管理

ログイン状態でないとアクセスできない会員ページを作ろう

 

 

投稿機能

 

レビュー、いいね、コメントの3テーブルを作ります。

docker-compose exec php-fpm php artisan make:migration create_reviews_table
docker-compose exec php-fpm php artisan make:migration create_nices_table
docker-compose exec php-fpm php artisan make:migration create_comments_table

 

 

 

 

 

バリデーション

フォーム投稿でのバリデーション

  • 未入力の場合
  • 文字数が50を超えた場合

 

バリデーション用のクラスを作成する

$ docker-compose exec php-fpm php /app/artisan make:request BookRequest

 

$ vi $APP_PATH/app/Http/Requests/BookRequest.php


<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class BookRequest extends FormRequest
{

    // エラー時のリダイレクトページ
    protected $redirect = '/';

    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        //return false;
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'name'     => 'required|max:50'
        ];
    }

    public function messages()
    {
        return [
            'name.required'     => '作者名か、本のタイトルを入力してください。',
            'name.max'     => '文字数が多いようです。短くキーワードを入力してください。',
        ];
    }

}

 

    // エラー時のリダイレクトページ
    protected $redirect = '/';

今回のヘッダーの検索のように処理の後に同じURIにリダイレクトするPOSTの場合は、そのままだとリダイレクトループになりエラーになります。だからリダイレクトページを指定しました。

 

 

xxxx.header.blade.php

・・・
  <form action="/book/search" method="POST">
   @csrf
   著者・タイトル
   <input type="text" name="name" />
   <input type="submit" value="検索" />
   @if($errors->has('name'))
     <tr><th><td><span class="error_mes">{{ $errors->first('name') }}</span></td></tr>
   @endif
  </form>
・・・

 

 

ページネーション

/app/Http/Controllers/BookController.php

$ cat $APP_PATH/app/Http/Controllers/BookController.php


<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\Book;           // ←追加 ●Bookモデルを呼び出すよ
use App\Http\Requests\BookRequest;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;


class BookController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return Response
     */
    public function index()
    {
            return view('book.index');
    }


    public function search(BookRequest $request)
    {
            $post_data  = $request->name; // 著者名 or タイトル
            $totalItems = 32;             // APIで取得するデータ最大数
            $perPage    = 8;              // Paginationでの1ページ当たりの表示数

            // Google BooksAPIからデータをJSONで取得して配列化
            $data = "https://www.googleapis.com/books/v1/volumes?q={$post_data}&country=JP&maxResults={$totalItems}&orderBy=newest&langRestrict=ja";
            $json = file_get_contents($data);
            $json_decode = json_decode($json, true);

            $books_flag = 1;
            // 本の検索データがあるかを判定
            if($json_decode['totalItems'] == 0)
            {
                $books_flag = 0; // データなし
                return view('book.result', compact("post_data", "books_flag") );
            }

            $currentPage = LengthAwarePaginator::resolveCurrentPage();  // 現在のページ数を$request['page']の値をLengthAwarePaginator::resolveCurrentPage()で取得
            $itemCollection = collect($json_decode['items']);           // collectヘルパの利用
            $currentPageItems = $itemCollection->slice(($currentPage * $perPage) - $perPage, $perPage)->all(); // $this->slice(配列の切り分け開始位置, 終了位置)
            $paginatedItems = new LengthAwarePaginator($currentPageItems , count($itemCollection), $perPage);
            $paginatedItems->setPath("/book/search/?name={$post_data}");
            return view('book.result', compact("paginatedItems", "post_data", "books_flag") );

    }

}

 

 

$APP_PATH/resources/views/book/result.blade.php

$ cat $APP_PATH/resources/views/book/result.blade.php


@extends('layouts.layout')
@section('title', 'サンプルホーム')
@section('content')
 <div class="page-header" class="col-sm-12 col-md-12 col-lg-12">
  <h2><small>検索結果<b>『{{ $post_data }}』</b></small></h2>
 </div>

<?php
    //echo('<pre>');
    //var_dump($paginator);
    //echo('</pre>');
    //exit();
?>

  <div class="flex-container row col-sm-12 col-md-12 col-lg-12">
  @if($books_flag==1)
    @foreach($paginatedItems as $item)


   <div class="card flex-card col-sm-6 col-md-3" >
      @if(isset($item["volumeInfo"]["imageLinks"]["thumbnail"]) )
        <div align="center"><img class="img-thumbnail" src="{{ $item["volumeInfo"]["imageLinks"]["thumbnail"] }}" alt="スダンダードコースのイメージ画像"></div>
      @else
        <div align="center"><img class="img-thumbnail" src="{{ asset('/images/no-image.jpg')  }}" alt="画像"></div>
      @endif
      <div class="card-body">
      @if(isset($item["volumeInfo"]["title"]))
        <h4 class="card-title">{{ str_limit($item["volumeInfo"]["title"], $limit = 20, $end = '...') }}</h4>
      @else
        <h4 class="card-title">タイトルなし</h4>
      @endif
      @if(isset($item["volumeInfo"]["authors"][0]))
        <a href="/book/search?name={{ $item["volumeInfo"]["authors"][0] }}"><p class="card-text">{{ str_limit($item["volumeInfo"]["authors"][0], $limit = 20, $end = '...') }}</p></a>
      @else
        <p class="card-text">作者名なし</p>
      @endif
        <a href="#" class="btn btn-primary">登録</a> <a href="#" class="btn btn-default">Amazonで購入</a>
      </div><!-- card-body -->
   </div><!-- card flex-card -->

    @endforeach
  </div><!-- /.flex-container -->
    {{ $paginatedItems->appends($post_data)->render() }}


  @else($books_flag==0)
 <div class="page-header" class="col-sm-12 col-md-12 col-lg-12">
   <h2>書籍データがないようです。ごめんなさい。</h2>
 </div>
  @endif

@endsection

 

前のページのデータを引き継ぎたい時は->links()の前にappends()を利用する。

$ vi $VIEW_PATH/book/detail.blade.php

- {{ $reviews->links() }}
+ {{ $reviews->appends(request()->input())->links() }}

 

 

 

 

Tinker リレーション

テーブル定義はこういう風になっています。

  • 親:booksテーブル
  • 子:reviewsテーブル

 

Modelでリレーションを定義します。

$ docker-compose exec php-fpm php artisan make:model Book
$ docker-compose exec php-fpm php artisan make:model Review

 

$ vi $APP_PATH/app/Models/Book.php


<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB; // ←追加 ●DBを操作するのにこれは必須
use Illuminate\Http\Request;       // ←追加 ●きっと後で使うよ

class Book extends Model
{
    protected $table = 'books';             // テーブル名
    protected $primaryKey = 'id';           // PK
    protected $guarded = array('id');       // PK

    public function review()
    {
        retrn $this->hasMany(Review::class);
    }

・・・

}

 

$ vi $APP_PATH/app/Models/Review.php


<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;      // ←追加 ●DBを操作するのにこれは必須
use Illuminate\Http\Request;            // ←追加 ●きっと後で使うよ
use Storage;                            // AWS S3アクセス league/flysystem-aws-s3-v3
use Illuminate\Support\Facades\Log; // ログ

class Review extends Model
{
    protected $table      = 'reviews';      // テーブル名
    protected $primaryKey = 'id';           // PK
    protected $guarded    = array('id');    // PK

    public function books()
    {
        return $this->belongsTo(Book::class);
    }

・・・

}

 

tinkerで名前空間を定義

$ docker-compose exec php-fpm php artisan tinker

Psy Shell v0.9.9 (PHP 7.3.7 ? cli) by Justin Hileman
>>> use App\Models\Book
>>> use App\Models\Review
>>> exit
Exit:  Goodbye

 

オブジェクトキャッシュを利用しよう

 

.envにCACHE_DRIVERを指定する必要があります。

$ vi $APP_PATH/.env

## Redis
CACHE_DRIVER=redis
SESSION_DRIVER=redis
REDIS_HOST=elasticache-redis-primary.yomuyo.net
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_READ_WRITE_TIMEOUT=60
・・・

この環境ではredisを利用しています。
※デフォルトはfile

 

/config/database.php

$ vi $APP_PATH/config/database.php

・・・

    'redis' => [

        'client' => env('REDIS_CLIENT', 'predis'),

        'options' => [
            'cluster' => env('REDIS_CLUSTER', 'predis'),
            'prefix' => Str::slug(env('APP_NAME', 'laravel'), '_').'_database_',
        ],

        'default' => [
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', 6379),
            'database' => env('REDIS_DB', 0),
        ],

        'cache' => [
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', 6379),
            'database' => env('REDIS_CACHE_DB', 1),
        ],

    ],

↓●変更

    'redis' => [

        'client' => 'predis',

        'options' => [
            'cluster' => 'redis',
            'prefix' => Str::slug(env('APP_NAME', 'laravel'), '_').'_database_',
            'parameters' => [
                'password' => env('REDIS_PASSWORD', null),
                'scheme' => env('REDIS_SCHEME', 'tcp'),
                'port' => env('REDIS_PORT', 6379),
            ],
        ],
        'default' => [
            'host' => env('REDIS_HOST'),
            'password' => env('REDIS_PASSWORD'),
            'port' => env('REDIS_PORT', 6379),
            'database' => env('REDIS_DB', 0),
        ],

        'cache' => [
            'host' => env('REDIS_HOST'),
            'password' => env('REDIS_PASSWORD'),
            'port' => env('REDIS_PORT', 6379),
            'database' => env('REDIS_CACHE_DB', 1),
        ],

    ],
    'clusters' => [
        'default'=> [
            'host' => env('REDIS_SCHEME', 'tcp')  . '://' .  env('REDIS_HOST'),
        ],
        'options' => [
            'cluster' => 'redis',
        ]
    ],


];

 

キャッシュ, コンフィグキャッシュのクリア

$ docker-compose exec php-fpm php artisan cache:clear
$ docker-compose exec php-fpm php artisan config:clear

 

 

レビューの総件数を取得した後にキャッシュしています。

$ vi $MODEL_PATH/Review.php


<?php

 namespace App\Models;

 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Support\Facades\DB;      // ←追加 ●DBを操作するのにこれは必須
 use Illuminate\Http\Request;            // ←追加 ●きっと後で使うよ
 use Storage;                            // AWS S3アクセス league/flysystem-aws-s3-v3
 use Illuminate\Support\Facades\Log;     // ログ
 use Illuminate\Support\Facades\Cache;   // キャッシュファサード


class Review extends Model
{
    protected $table      = 'reviews';      // テーブル名
    protected $primaryKey = 'id';           // PK
    protected $guarded    = array('id');    // PK


   /** ==========================
    *   リレーション
    *  ==========================
    */
    public function book()
    {
        return $this->belogsTo(Book::class);
    }

    public function user()
    {
        return $this->belogsTo(User::class);
    }



   /** =======================================================
    *   レビュー総件数を取得
    *  ========================================================
    *   @param  string  $key     : キャッシュのキー
    *   @param  integer $limit   : 保持期間(秒)
    *   @return integer $cache   : キャッシュ(レビュー総件数)
    *   @return integer $count   : レビュー総件数
    */
    public function sum(string $key, int $limit)
    {
        // キーからキャッシュを取得
        $cache = Cache::get($key);

        // キャッシュがあればキャッシュを返す
        if( isset($cache) ){
            return $json_decode = (int) $cache;
        }else{
            // キャッシュがなければ取得して、キャッシュに保存する
            $count = DB::table($this->table)->count();
            Cache::add($key, json_encode($count), $limit); // キャッシュがなければキャッシュする
            return $count;
        }
    }

   /** ==========================================================
    *    $number 件 読まれている本を一覧取得
    *
    *    MEMO:ユーザID $idが含まれている場合はキャッシュしない
    *   =========================================================
    *    @param string   nullable $key      : キャッシュキー
    *    @param integeer nullable $limit    : キャッシュ保持期間
    *    @param integer           $number   : 取得件数
    *    @param integer  nullable $id       : ユーザID
    *    @return array                      : レビューデータ
    */
    public function getList(string $key=null, int $limit=null, int $number, int $id=null)
    {
        // キーからキャッシュを取得
        $cache = Cache::get($key);

        // キャッシュがあればキャッシュを返す
        if( isset($cache) ){
            $json_decode = (int) $cache;
        }else{

            if( isset($id) )
            {
                // users.idが指定されている場合: 任意のユーザのレビューを作成日時による降順で取得
                $items = DB::table($this->table)->select(
                                                      'reviews.id',
                                                      'reviews.book_id',
                                                      'reviews.user_id',
                                                      'reviews.netabare_flag',
                                                      'reviews.comment',
                                                      'reviews.updated_at',
                                                      'books.google_book_id',
                                                      'books.name as book_title',
                                                      'books.thumbnail',
                                                      'users.name as user_name'
                                                     )
                                             ->join('books',  'reviews.book_id', '=', 'books.id')
                                             ->join('users',  'reviews.user_id', '=', 'users.id')
                                             ->where('users.id', '=', $id)
                                             ->orderBy('reviews.created_at', 'desc')
                                             ->paginate($number);
                return $items;
            }else{
                // users.idが指定されていない場合: 全件からレビューを更新日時による降順で取得
                $items = DB::table($this->table)->select(
                                                      'reviews.id',
                                                      'reviews.book_id',
                                                      'reviews.user_id',
                                                      'reviews.netabare_flag',
                                                      'reviews.comment',
                                                      'reviews.updated_at',
                                                      'books.google_book_id',
                                                      'books.name as book_title',
                                                      'books.thumbnail',
                                                      'users.name as user_name'
                                                     )
                                             ->join('books', 'reviews.book_id', '=', 'books.id')
                                             ->join('users', 'reviews.user_id', '=', 'users.id')
                                             ->orderBy('reviews.updated_at', 'desc')
                                             ->paginate($number);

                Cache::add($key, json_encode($items), $limit); // キャッシュする
                return $items;
            }
        }
    }
・・・
・・・

}

 

コントローラからの利用

$ vi $CONT_PATH/BookController.php


<?php

 namespace App\Http\Controllers;

 use Illuminate\Http\Request;
 use App\Models\Book;           // ←追加 ●Bookモデルを呼び出すよ
 use App\Http\Requests\BookRequest;
 use Illuminate\Pagination\LengthAwarePaginator;
 use Illuminate\Pagination\Paginator;
 use Illuminate\Support\Facades\Log;
 use App\Models\Review;
 use Illuminate\Support\Facades\Cache;   // キャッシュファサード

class BookController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return Response
     */
    public function index()
    {
        $key_count         = (string) "BookController_index_count";   // キャッシュキー
        $key_items         = (string) "BookController_index_items";   // キャッシュキー
        $key_reviews       = (string) "BookController_index_reviews"; // キャッシュキー
        $limit_count       = 60;                                // キャッシュ保持期間
        $limit_items       = 30;                                // キャッシュ保持期間
        $limit_reviews     = 30;                                // キャッシュ保持期間

        // レビュー総数を取得
        $review = new Review;
        $count = $review->sum($key_count, $limit_count);

        // 4件ずつ一覧取得
        $items   = $review->getList($key_items, $limit_items, 4);
        // 6件ずつレビューを取得
        $reviews = $review->getList($key_reviews, $limit_reviews, 6);
        return view('book.index', compact("count", "items", "reviews"));
    }


    /** ==================================
     *   Google Books APIから取得
     *  ==================================
     *  @param  BookRequest $request
     *  @return array
     */
    public function search(BookRequest $request)
    {
        $form = $request->all();
        unset($form['_token']); // トークンは削除しておく

        $currentPage = isset($_GET['page']) ? (int)$_GET['page'] : 1;  // 現在のページ
        $perPage = 8;                                                  // Paginationでの1ページ当たりの表示数

        $code = md5($form['name']); // キャッシュキーで日本語を避けたいので変換
        $key_data    = "BookController_search_{$code}_{$currentPage}"; // キャッシュキー
        $limit_data  = 604800;                                                                // キャッシュ保持期間(604800 = 一週間)


        try{
               if(isset($form['name'])){
                   $post_data  = trim( preg_replace("/( | )/", "", $form['name']) ); // 著者名 or タイトルを取得(空白を削除)
                   $totalItems = 40;             // APIで取得するデータ最大数
                   $perPage    = 8;              // Paginationでの1ページ当たりの表示数
               }else{
                   $books_flag = 0; // データなし
                   return view('book.result', compact("books_flag") );
               }

               // キーからキャッシュを取得
               if(Cache::has($key_data)){
                   $cache = (array) Cache::get($key_data);
               }

               //// キャッシュがあればキャッシュを取得
               if( isset($cache) ){
                   $json_decode = (array) $cache; // 変数名をリネームして合わせる
               }else{
                   // Google BooksAPIからデータをJSONで取得して配列化
                   $data = "https://www.googleapis.com/books/v1/volumes?q={$post_data}&country=JP&maxResults={$totalItems}&orderBy=newest&langRestrict=ja";
                   $json = @file_get_contents($data);
                   $json_decode = json_decode($json, true);
                   Cache::add($key_data, $json_decode, $limit_data);
               }


               $books_flag = 1;

               // 本の検索データがあるかを判定
               if($json_decode['totalItems'] == 0)
               {

                   $books_flag = 0; // データなし
                  return view('book.result', compact("post_data", "books_flag") );
               }


               // ページャ用データ作成
               $itemCollection = collect($json_decode['items']);           // collectヘルパの利用
               $currentPageItems = $itemCollection->slice(($currentPage * $perPage) - $perPage, $perPage)->all(); // $this->slice(配列の切り分け開始位置, 終了位置)
               $paginatedItems = new LengthAwarePaginator($currentPageItems , count($itemCollection), $perPage);
               $paginatedItems->setPath("/book/search/?name={$post_data}");

               return view('book.result', compact("paginatedItems", "post_data", "books_flag") );
        }
        catch(\Exception $e){
            echo "<a href=\"/\">トップページへ戻る</a>:<br/>";
            echo "データ取得エラー。ご迷惑をおかけしております。:" . $e->getMessage();
            Log::error($e->getMessage());
            exit();
        }

    }



    public function detail(Request $request)
    {
            $item  = $request->all();
            unset($item['_token']);
            return view('book.detail', compact("item") );
    }

・・・

 

ElastiCacheに接続

$ redis-cli -h elasticache-redis-primary.yomuyo.net

 

データベース1に接続

elasticache-redis-primary.yomuyo.net:6379> select 1
OK

 

monitorモードでキャッシュが書き込まれるか確認

elasticache-redis-primary.yomuyo.net:6379[1]> monitor

 

 

ランキング機能の時には、ランキングデータもキャッシュしたいですね!

 

 

ランキング機能 【みんなが読んでいる本】

MySQLでカウントして、redisでソートすることにする。

 

rankingのトピックブランチを切る

$ git branch ranking
$ git checkout ranking

リモートリポジトリ(GitHub)にrankingブランチをpush

$ git push origin ranking

 

 

modelに作ることにする

$ docker-compose exec php-fpm php artisan make:model Models/Ranking

 

tinkerで登録

$ docker-compose exec php-fpm php artisan tinker

>>>use App\Models\Ranking
>>> exit
Exit:  Goodbye

 

 

$ vi $MODEL_PATH/Ranking.php


<?php

 namespace App\Models;

 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Support\Facades\DB;
 use App\Models\Review;
 use Illuminate\Support\Facades\Cache;

class Ranking extends Model
{

    /** ===========================================================
     *   ランキングを取得
     *  ===========================================================
     *  @param  string nullable $key    キャッシュキー
     *  @param  int    nullable $limit  キャッシュ保存期間(秒)
     *  @param  int    nullable $number 取得するレコード数
     *  @return array
     */
    public function rank(string $key=null, int $limit=null, int $number)
    {
        // キーからキャッシュを取得
        $cache = Cache::get($key);

        // キャッシュがあればキャッシュを返す
        if( isset($cache) ){
            return $json_decode = (array) $cache;
        }else{
            // キャッシュがなければ取得して、キャッシュに保存する
            $result = DB::select("
                                  SELECT reviews.book_id,
                                         books.name AS book_title,
                                         COUNT(*) AS total,
                                         books.thumbnail,
                                         books.google_book_id
                                  FROM reviews LEFT JOIN books
                                               ON reviews.book_id = books.id
                                  GROUP BY reviews.book_id
                                  ORDER BY total DESC
                                  LIMIT {$number};
                                ");

            Cache::add($key, json_encode($result), $limit); // キャッシュする
            return $result;
        }
    }
}

ORMでGROUP BY で集計したカラムのほかにSELECTするとエラーが出るので、生SQLで取得することにしました。

 

$ vi $CONT_PATH/BookController.php


<?php

 namespace App\Http\Controllers;

 use Illuminate\Http\Request;
 use App\Models\Book;           // ←追加 ●Bookモデルを呼び出すよ
 use App\Http\Requests\BookRequest;
 use Illuminate\Pagination\LengthAwarePaginator;
 use Illuminate\Pagination\Paginator;
 use Illuminate\Support\Facades\Log;
 use App\Models\Review;
 use App\Models\Ranking;
 use Illuminate\Support\Facades\Cache;   // キャッシュファサード

class BookController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return Response
     */
    public function index()
    {
        // キャッシュ設定
        $key_ranking       = (string) "BookController_index_ranking"; // キャッシュキー
        $limit_ranking     = 86400;                                   // キャッシュ保持期間(1日=86400秒)
        $key_count         = (string) "BookController_index_count";   // キャッシュキー
        $limit_count       = 60;                                      // キャッシュ保持期間
        $key_reviews       = (string) "BookController_index_reviews"; // キャッシュキー
        $limit_reviews     = 30;                                      // キャッシュ保持期間

        // ランキングデータを取得
        $ranking = new Ranking();
        $items  = $ranking->rank($key_ranking, $limit_ranking, 4);

        // レビュー総数を取得
        $review = new Review;
        $count = $review->sum($key_count, $limit_count);

        // 6件ずつレビューを取得
        $reviews = $review->getList($key_reviews, $limit_reviews, 6);
        return view('book.index', compact("items", "count", "reviews"));
    }

・・・

 

 

Debugbar

 

インストール

$ docker-compose exec php-fpm composer require barryvdh/laravel-debugbar --dev

 

$ vi $APP_PATH/config/app.php


    'providers' => [

・・・

        Illuminate\View\ViewServiceProvider::class,
+       Barryvdh\Debugbar\ServiceProvider::class,

        /*
         * Package Service Providers...
         */

        /*
         * Application Service Providers...
         */
        App\Providers\AppServiceProvider::class,



・・・

    'aliases' => [

・・・

        'Socialite' => Laravel\Socialite\Facades\Socialite::class,
+       'Debugbar' => Barryvdh\Debugbar\Facade::class,

    ],

 

下記みたいにデバッグ出来る

$hoge = Hoge::latest()->get();
\Debugbar::info($hoge);

 

本番 環境変数

# 本番環境変数
APP_ENV=production

# デバッグ表示無効 ※本番でtrue厳禁
APP_DEBUG=false

# デバッグバー無効 ※本番でtrue厳禁
DEBUGBAR_ENABLED=false

これはしっかりECSのコンテナに設定しておこう。パスワードが見れてしまったりと、劇薬なので注意が必要。

 

レスポンスでセキュリティ対策

 

 

クリックジャッキング対策(iframe禁止)

 

 

キャッシュコントロール

 

 

XSS(文字コード指定)対策

 

 

ランキングをキャッシュする

 

 

 

イベントとイベントリスナーで通知しよう

 

 

Vue.js

Laravelにはpackage.jsonの中にVue.jsがデフォルトで含まれている。

$ cat $APP_PATH/package.json

{
    "private": true,
    "scripts": {
        "dev": "npm run development",
        "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
        "watch": "npm run development -- --watch",
        "watch-poll": "npm run watch -- --watch-poll",
        "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
        "prod": "npm run production",
        "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
    },
    "devDependencies": {
        "axios": "^0.18",
        "bootstrap": "^4.1.0",
        "cross-env": "^5.1",
        "jquery": "^3.2",
        "laravel-mix": "^4.0.7",
        "lodash": "^4.17.5",
        "popper.js": "^1.12",
        "resolve-url-loader": "^2.3.1",
        "sass": "^1.15.2",
        "sass-loader": "^7.1.0",
        "vue": "^2.5.17",
        "vue-template-compiler": "^2.6.10"
    }
}

 

package.jsonからのインストールとコンパイル

$ docker-compose exec php-fpm npm install
$ docker-compose exec php-fpm npm run dev

 

 

 

本番環境へのDBマイグレーション

AWS CLI、またはECSのEC2インスタンスのPHP-FPMコンテナを利用して実行。

$ docker exec <コンテナID> php artisan migrate --force

 

  • 本番環境はAPP_ENV=productionなので、–forceが必要
  • docker exec <コンテナID> php artisan migrate:fresh –force
    fresh, reflesh系はテーブルを全削除するので危険…!

 

 

なんかうまく反映されないんだけど、これ?

 

すべてのコンテナの停止、コンテナ削除、イメージ削除

docker stop $(docker ps -q)
docker rm $(docker ps -q -a)
docker rmi $(docker images -q)

 

キャッシュのクリア

docker-compose exec php-fpm php artisan cache:clear
docker-compose exec php-fpm php artisan config:clear
docker-compose exec php-fpm php artisan route:clear
docker-compose exec php-fpm php artisan view:clear

 

ECSのデプロイが終わらない

taskがスタートを繰り返して、終わらない。

【サービス】=>”yomuyo-service”=>【イベント】=>タスクを見ると「停止理由」が書いてある。

この画像の場合は、SSMからパラメータを取得できなかったために行ったエラー。

 

Vue.js

 

 

Amazonおすすめ

iPad 9世代 2021年最新作

iPad 9世代出たから買い替え。安いぞ!🐱 初めてならiPad。Kindleを外で見るならiPad mini。ほとんどの人には通常のiPadをおすすめします><

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)