最短で完成を目指す。
※雑記。現状は参考?にするのはおすすめしません。最後に整理します。
もくじ
内容
本の感想投稿掲示板 + 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」を見つけます。
「設定」をクリックします。
- slackChannel:#yomuyo
- kmsEncryptedHookUrl:WEBhookURLの内容
- 伝送中に暗号化する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" } }
- テスト実行を行い、Slackに通知されるか
- 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の監視
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周りのひな形
レイアウトの作成
$ 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利用のキーとシークレットが必要
- Facebookの開発者登録
https://developers.facebook.com/apps
【設定】→ 【ベーシック】からキーとIDを確認できる。 - 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