tag:crieit.net,2005:https://crieit.net/magazines/dala00/Laravel%E3%81%A7%E8%B3%AA%E5%95%8F%E7%AE%B1%E3%81%BF%E3%81%9F%E3%81%84%E3%81%AA%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%E3%82%92%E4%BD%9C%E3%82%8B/feed [連載] Laravelで質問箱みたいなサービスを作るの投稿 - Crieit Crieitで連載「[連載] Laravelで質問箱みたいなサービスを作る」の最近の投稿 2020-02-11T23:52:01+09:00 https://crieit.net/magazines/dala00/Laravel%E3%81%A7%E8%B3%AA%E5%95%8F%E7%AE%B1%E3%81%BF%E3%81%9F%E3%81%84%E3%81%AA%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%E3%82%92%E4%BD%9C%E3%82%8B/feed tag:crieit.net,2005:PublicArticle/15715 2020-02-11T23:48:05+09:00 2020-02-11T23:52:01+09:00 https://crieit.net/posts/b42ed137feb9160fe2ed7ec882f9cdff 未回答のものを絞り込む機能を追加する <p>前回作った受け取った質問一覧ページは未回答のものも回答済みのものも表示されていて未回答のものを探しにくいため、未回答の質問を絞り込む機能を作成します。</p> <h2 id="アクションを検索に対応"><a href="#%E3%82%A2%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E6%A4%9C%E7%B4%A2%E3%81%AB%E5%AF%BE%E5%BF%9C">アクションを検索に対応</a></h2> <p>まずはアクション内の絞り込みの部分を調整します。URLに<code>no_answer=1</code>というパラメータが付いた場合だけ未回答の絞り込みを行うようにします。</p> <p>検索条件は質問に紐づく回答が存在するかになります。SQLでいうとquestionsに紐づくanswersが存在しないもの、ということになります。具体的にはLaravelのQuestionControllerのindexメソッドを下記のように変更しました。</p> <pre><code class="php"> public function index(Request $request) { $query = Question::with('answer') ->where('received_user_id', Auth::id()) ->orderByDesc('id'); if ($request->input('no_answer')) { $query->whereDoesntHave('Answer'); } $questions = $query->paginate(); return view('question.index', compact('questions')); } </code></pre> <p>これで例えば<code>http://localhost:8000/questions?no_answer=1</code>のようなURLにアクセスすると未回答のものだけを表示できるようになりました。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5e42b04a0c9df.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5e42b04a0c9df.png?mw=700" alt="" /></a></p> <p>前回Debugbarを導入して、実際に発行されているSQLを簡単に見ることができるようになったため見てみると、下記のようなSQLが発行されるようになりました(整形及び一部省略しています)</p> <pre><code class="sql">select * from `questions` where `received_user_id` = 1 and not exists( select * from `answers` where `questions`.`id` = `answers`.`question_id` ) order by `id` desc </code></pre> <h2 id="ページ分けに対応"><a href="#%E3%83%9A%E3%83%BC%E3%82%B8%E5%88%86%E3%81%91%E3%81%AB%E5%AF%BE%E5%BF%9C">ページ分けに対応</a></h2> <p>このままだと、ページ分けされた際に別ページのリンクをクリックした際に<code>no_answer=1</code>のパラメータが解除されてしまいます。そのため、パラメータを維持できるようにページネーションの表示を下記のように変更します。</p> <pre><code class="html"> <span>{</span><span>{</span> $questions->appends(request()->only(['no_answer']))->links() <span>}</span><span>}</span> </code></pre> <p>このようにlinksメソッドを呼ぶ前にappendsを挟んで追加パラメータを指定することで、ページネーションのリンクにも追加のパラメータが維持されるようになります。</p> <p>ちなみに<code>$query->paginate(2)</code>の様に表示されるデータより少ない数のlimitを指定すればデータを増やさなくても確認できます。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5e42b20f896a6.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5e42b20f896a6.png?mw=700" alt="" /></a></p> <h2 id="実際に切り替えられるようにする"><a href="#%E5%AE%9F%E9%9A%9B%E3%81%AB%E5%88%87%E3%82%8A%E6%9B%BF%E3%81%88%E3%82%89%E3%82%8C%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B">実際に切り替えられるようにする</a></h2> <p>あとは実際にno_answer付きと無しのURLを切り替えられるようにしておきます。テンプレートに下記のような記述を追記しました。</p> <pre><code class="html"> <nav class="nav justify-content-center m-3"> <a class="nav-link @if (!request()->input('no_answer')) active @endif" href="<span>{</span><span>{</span> url('questions') <span>}</span><span>}</span>"> すべて </a> <a class="nav-link @if (request()->input('no_answer')) active @endif" href="<span>{</span><span>{</span> url('questions?no_answer=1') <span>}</span><span>}</span>"> 未回答のみ </a> </nav> </code></pre> <p>Bootstrapの下記のコンポーネントを利用しています。</p> <p><a target="_blank" rel="nofollow noopener" href="https://getbootstrap.com/docs/4.4/components/navs/">Navs · Bootstrap</a></p> <p>ちなみにこの横並びの表記にはFlexboxが利用されています。センタリングしておきたかったので<code>nav</code>のクラスに<code>justify-content-center</code>を追加しています。</p> <p><a target="_blank" rel="nofollow noopener" href="https://getbootstrap.com/docs/4.4/utilities/flex/#justify-content">Justify content - Flex · Bootstrap</a></p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5e42b5540d50e.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5e42b5540d50e.png?mw=700" alt="" /></a></p> <h2 id="SQLの調整"><a href="#SQL%E3%81%AE%E8%AA%BF%E6%95%B4">SQLの調整</a></h2> <p>これで一通り対応はできました。ただしこのサービスの場合、質問と回答のデータはメインになる、且つ一番増える可能性が高いため、上記で実装したようにリレーションを利用しての検索は後々重くなりすぎて問題になる可能性があります。そのため一旦ちょっと軽量化のための対策を行います。</p> <p>動作としてはすでに出来ており、必須ではありませんので読み飛ばしても問題ありません。</p> <p>具体的な対応方法として、リレーションして判断させるのではなく、回答がついた際に質問データ側に回答済みフラグを付けておくようにします。そのようにすることで質問データのみを使ったSQLで機能を実現できるようになります。</p> <h3 id="マイグレーションを作成"><a href="#%E3%83%9E%E3%82%A4%E3%82%B0%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E4%BD%9C%E6%88%90">マイグレーションを作成</a></h3> <p>まずはその回答済みフラグをquestionsテーブルに追加するためのマイグレーションを追加します。</p> <pre><code>php artisan make:migration add_has_answer_to_questions --table questions </code></pre> <p>下記のようなマイグレーションを書きます。has_answerというカラムを追加します。</p> <pre><code class="php"> public function up() { Schema::table('questions', function (Blueprint $table) { $table->boolean('has_answer')->default(false)->after('body'); }); } public function down() { Schema::table('questions', function (Blueprint $table) { $table->dropColumn('has_answer'); }); } </code></pre> <p>これを実行すればDB側の調整は完了です。あとは実際の処理を入れていきます。</p> <h3 id="回答された際に回答済みフラグを更新するようにする"><a href="#%E5%9B%9E%E7%AD%94%E3%81%95%E3%82%8C%E3%81%9F%E9%9A%9B%E3%81%AB%E5%9B%9E%E7%AD%94%E6%B8%88%E3%81%BF%E3%83%95%E3%83%A9%E3%82%B0%E3%82%92%E6%9B%B4%E6%96%B0%E3%81%99%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B">回答された際に回答済みフラグを更新するようにする</a></h3> <p>AnswerControllerのstoreアクションの回答データ登録後に下記の処理を追加しておきます。</p> <pre><code class="php"> $question->has_answer = true; $question->save(); </code></pre> <p>問題なければ回答時にhas_answerフラグがオンになります。(※answersテーブルの作成マイグレーションの外部キー作成が間違っていたので修正済みです。<a href="https://crieit.net/posts/33685639291474c3aabfdaafeafa87cd">質問に回答する機能を作る</a>)</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5e42bbffc3781.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5e42bbffc3781.png?mw=700" alt="質問に回答する機能を作る" /></a></p> <h3 id="検索クエリを変更する"><a href="#%E6%A4%9C%E7%B4%A2%E3%82%AF%E3%82%A8%E3%83%AA%E3%82%92%E5%A4%89%E6%9B%B4%E3%81%99%E3%82%8B">検索クエリを変更する</a></h3> <p>先程作成した回答済みのみを抽出するSQLをフラグを使ったシンプルな形に変更します。さきほど実装したwhereDoesntHaveを変更します。</p> <pre><code class="php"> if ($request->input('no_answer')) { $query->where('has_answer', true); } </code></pre> <p>これで一通り完了です。動作確認しておきましょう。クエリも下記のようなシンプルな形になりました。</p> <pre><code class="sql">select * from `questions` where `received_user_id` = 1 and `has_answer` = 1 order by `id` desc </code></pre> <h2 id="まとめ"><a href="#%E3%81%BE%E3%81%A8%E3%82%81">まとめ</a></h2> <p>これで未回答のものだけで絞り込みができるようになりました。</p> <p>次はナビゲーションなどがまだ未整備で使いづらいためそのあたりを調整していきたいと思います。</p> だら@Crieit開発者 tag:crieit.net,2005:PublicArticle/15712 2020-02-09T18:53:13+09:00 2020-02-09T18:58:15+09:00 https://crieit.net/posts/Laravel-Debugbar-5e3fd689cd5a8 LaravelのDebugbarを導入する <p>すこし横道にそれますが、Debugbarという便利ツールを導入してみます。</p> <p><a target="_blank" rel="nofollow noopener" href="https://github.com/barryvdh/laravel-debugbar">barryvdh/laravel-debugbar: Laravel Debugbar (Integrates PHP Debug Bar)</a></p> <p>Debugbarは開発時に様々な情報を表示してくれる便利なツールです。これを導入しておくと、わざわざダンプしなくても簡単に画面上で様々な値を確認することが出来ます。APIサーバーなどの場合は意味がありませんが、普通にページを表示するアプリケーションの場合に有用です。ページ表示時だけでなく、ajaxで通信した値なども自動的に表示してくれたりします。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5e3fb2803b85f.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5e3fb2803b85f.png?mw=700" alt="" /></a></p> <h2 id="インストール"><a href="#%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB">インストール</a></h2> <p>Composerでインストールします。</p> <pre><code>composer require barryvdh/laravel-debugbar --dev </code></pre> <p>古いバージョンのLaravelでなければ基本的にこれだけで導入は完了です。環境変数で<code>APP_DEBUG=true</code>の設定にしておけば表示されます。</p> <h2 id="まとめ"><a href="#%E3%81%BE%E3%81%A8%E3%82%81">まとめ</a></h2> <p>ということで次でちょっとLaravelが発行しているSQLを見ながら話を進めたかったのでDebugbarの回を挟んでみました。</p> だら@Crieit開発者 tag:crieit.net,2005:PublicArticle/15537 2019-11-11T07:50:57+09:00 2020-02-08T16:52:18+09:00 https://crieit.net/posts/ace4ccb8595768287c0183f9b1a05d65 受け取った質問一覧ページを作る <p>次は受け取った質問の一覧ページを作成します。一通り主要な詳細ページは作ってきましたが、今のままではサービスとしては利用できません。受け取った質問一覧ページを作成し、全ての質問にアクセスできるようにします。</p> <h2 id="質問一覧ページを作成"><a href="#%E8%B3%AA%E5%95%8F%E4%B8%80%E8%A6%A7%E3%83%9A%E3%83%BC%E3%82%B8%E3%82%92%E4%BD%9C%E6%88%90">質問一覧ページを作成</a></h2> <p>実際に質問一覧ページを作成していきます。</p> <h3 id="アクションを作成"><a href="#%E3%82%A2%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E4%BD%9C%E6%88%90">アクションを作成</a></h3> <p>まずはQuestionControllerに一覧ページ用のアクションを追加します。getではなくpaginateを使うことでページ切り替えをして全て閲覧できるようにしています。</p> <pre><code class="php"> public function index() { $questions = Question::where('received_user_id', Auth::id()) ->orderByDesc('id') ->paginate(); return view('question.index', compact('questions')); } </code></pre> <h3 id="ルーティングを作成"><a href="#%E3%83%AB%E3%83%BC%E3%83%86%E3%82%A3%E3%83%B3%E3%82%B0%E3%82%92%E4%BD%9C%E6%88%90">ルーティングを作成</a></h3> <p>このアクション用のルーティングを設定します。authミドルウェアが設定されているQuestionControllerの設定にindexを追加します。元々storeのルーティングが作成済みだったのでそちらに追記します。</p> <pre><code class="php"> Route::resource('questions', 'QuestionController')->only(['index', 'store']); </code></pre> <h3 id="テンプレートを作成"><a href="#%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88%E3%82%92%E4%BD%9C%E6%88%90">テンプレートを作成</a></h3> <p>次に<code>resources/views/question/index.blade.php</code>を作成します。</p> <pre><code class="html">@extends('layouts.app') @section('content') <section class="text-center"> <h1 style="font-size: 1.5rem">届いた質問一覧</h1> @foreach ($questions as $question) <a href="<span>{</span><span>{</span> url("questions/{$question->id}/received") <span>}</span><span>}</span>"> <div class="card mb-4"> <div class="card-body"> <span>{</span><span>{</span> $question->body <span>}</span><span>}</span> </div> </div> </a> @endforeach <div class="text-center d-inline-block"> <span>{</span><span>{</span> $questions->links() <span>}</span><span>}</span> </div> </section> @endsection </code></pre> <p>これでログインした状態で<code>http://localhost:8000/questions</code>にアクセスすれば表示されます。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5dc7500405e29.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5dc7500405e29.png?mw=700" alt="" /></a></p> <p>これでアクションの<code>paginate()</code>を<code>paginate(2)</code>等にして1ページ内のデータ表示数を試しに変えてみることでページの表示も確認できます。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5dc7506545ea1.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5dc7506545ea1.png?mw=700" alt="" /></a></p> <h2 id="回答したかどうかを表示する"><a href="#%E5%9B%9E%E7%AD%94%E3%81%97%E3%81%9F%E3%81%8B%E3%81%A9%E3%81%86%E3%81%8B%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B">回答したかどうかを表示する</a></h2> <p>このままだと回答したかどうかが分かりづらいため、未回答かどうかも表示するようにしてみます。</p> <h3 id="表示を調整する"><a href="#%E8%A1%A8%E7%A4%BA%E3%82%92%E8%AA%BF%E6%95%B4%E3%81%99%E3%82%8B">表示を調整する</a></h3> <p>Questionは一つのAnswerを持っているため、そのデータがあるかどうかで判断ができます。これは<code>$question->answer</code>のようにして取得できます。テンプレートを調整していきますが、回答済、未回答を表示するのはBootstrapの下記のbadgeコンポーネントが良さそうですのでこれを利用します。</p> <p><a target="_blank" rel="nofollow noopener" href="https://getbootstrap.com/docs/4.3/components/badge/">Badges · Bootstrap</a></p> <p>下記のように調整しました。</p> <pre><code class="html"> @foreach ($questions as $question) <a href="<span>{</span><span>{</span> url("questions/{$question->id}/received") <span>}</span><span>}</span>"> <div class="card mb-4"> <div class="card-body"> <span>{</span><span>{</span> $question->body <span>}</span><span>}</span> @if ($question->answer) <span class="badge badge-success">回答済</span> @else <span class="badge badge-warning">未回答</span> @endif </div> </div> </a> @endforeach </code></pre> <p>表示状はもうこれでOKです。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5dc7530a6c113.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5dc7530a6c113.png?mw=700" alt="" /></a></p> <h3 id="SQLの実行を最適化する"><a href="#SQL%E3%81%AE%E5%AE%9F%E8%A1%8C%E3%82%92%E6%9C%80%E9%81%A9%E5%8C%96%E3%81%99%E3%82%8B">SQLの実行を最適化する</a></h3> <p>しかし一つ問題点として、ループの中で<code>$question->answer</code>を実行しているため、ループの中で何回もSQLのデータ処理が行われてしまいます。データが20件あれば最初の一覧取得と毎回の回答取得で21回も呼ばれてしまうため負荷的には非効率です。</p> <p>そのため、LaravelのEagerローディングを利用します。</p> <p><a target="_blank" rel="nofollow noopener" href="https://readouble.com/laravel/6.x/ja/eloquent-relationships.html#eager-loading">Eagerロード</a></p> <p>これだと最初に質問一覧を取った時、その質問のIDに紐づく回答を全て取得して紐付けておいてくれるため、今回の場合だと合計2回のクエリ実行で済みます。アクションのデータ取得部分を下記のように調整します。</p> <pre><code class="php"> $questions = Question::with('answer') ->where('received_user_id', Auth::id()) ->orderByDesc('id') ->paginate(); </code></pre> <p>withをつけることでEagerローディングが行われるようになります。</p> <h2 id="次は"><a href="#%E6%AC%A1%E3%81%AF">次は</a></h2> <p>あとは未回答のものだけを絞り込む機能をつけようかと思いましたが、せっかくLaravel Mixを使っているのでVue.jsのコンポーネントを使った簡単なJavaScriptの処理を入れて実装してみたいと思いますので次回に分けることにします…と思いましたがそこまで必要性を感じられていないのでとりあえず普通にJavaScript無しで実装していきます。</p> だら@Crieit開発者 tag:crieit.net,2005:PublicArticle/15495 2019-10-21T07:45:11+09:00 2019-10-21T07:45:11+09:00 https://crieit.net/posts/Twitter-OGP Twitterシェア用のOGPを作成する <p>質問、回答と作成しましたので、次にその回答をツイッターに画像つきで投稿する必要があります。ツイートされた時に画像を表示するため、そのOGP用の画像を生成する処理を作成します。</p> <p>こんな画像を作成します。</p> <p><a href="https://crieit.now.sh/upload_images/597a7f2e06da1377ad7cbc486bd0f4cf5da72758df7f4.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/597a7f2e06da1377ad7cbc486bd0f4cf5da72758df7f4.png?mw=700" alt="" /></a></p> <p>ちなみに実は以前別の記事でもLaravelでOGPを作る方法を解説しました。</p> <p><a href="https://crieit.net/posts/Laravel-OPG">LaravelでOGPを作る</a></p> <p>上記ではGDを使って生成しています。同じような解説になってしまってもあまり意味がないので、今回はImagickを使って生成してみます。サーバーにはImageMagick自体とPHPのImagickがインストールされている必要があります。準備が難しい場合はGDのパターンで試してみてください。</p> <h2 id="背景画像を用意する"><a href="#%E8%83%8C%E6%99%AF%E7%94%BB%E5%83%8F%E3%82%92%E7%94%A8%E6%84%8F%E3%81%99%E3%82%8B">背景画像を用意する</a></h2> <p>簡単にOGPをカッコよくしたいので、背景には枠画像を使うことにしました。下記でフリー素材をダウンロードしてきました。</p> <p><a target="_blank" rel="nofollow noopener" href="https://sozai-good.com/illust/free-background/town/29850">街中の住宅シルエット フレーム素材 枠-囲み 247 | 素材Good</a></p> <p>これを<code>resources/images/ogp.png</code>として保存しておきます。</p> <h2 id="フォントを用意する"><a href="#%E3%83%95%E3%82%A9%E3%83%B3%E3%83%88%E3%82%92%E7%94%A8%E6%84%8F%E3%81%99%E3%82%8B">フォントを用意する</a></h2> <p>文字を描画するためにはフォントが必要です。Googleフォントから日本語フォントをダウンロードしてきました。これを<code>resources/fonts</code>フォルダに保存しておきます。</p> <h2 id="画像の生成処理を作る"><a href="#%E7%94%BB%E5%83%8F%E3%81%AE%E7%94%9F%E6%88%90%E5%87%A6%E7%90%86%E3%82%92%E4%BD%9C%E3%82%8B">画像の生成処理を作る</a></h2> <p>素材が準備できましたので、Questionモデルに画像の生成処理を作ります。</p> <p>まずはuseを追加します。</p> <pre><code class="php">use Imagick; use ImagickDraw; </code></pre> <p>そしてメソッドを追加します。各行の文字数が長いと画像からはみ出してしまうため、適当に折り返しを行っています。</p> <pre><code class="php"> public function generateOgp(): Imagick { $body = $this->getWordwrapedBody(); $image = new Imagick(resource_path('images/ogp.png')); $draw = new ImagickDraw(); $draw->setFont(resource_path('fonts/MPLUS1p-Regular.ttf')); $draw->setFontSize(24); $draw->setTextAlignment(Imagick::ALIGN_CENTER); $lines = explode("\n", $body); $draw->annotation(300, 157 - (count($lines) - 1) * 12, $body); $image->drawImage($draw); return $image; } private function getWordwrapedBody(): string { $lines = explode("\n", $this->body); $result = []; foreach ($lines as $line) { $length = mb_strlen($line); for ($start = 0; $start < $length; $start += 20) { $result[] = mb_substr($line, $start, 20); } } return join("\n", $result); } </code></pre> <h2 id="画像を表示する"><a href="#%E7%94%BB%E5%83%8F%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B">画像を表示する</a></h2> <p>実際にコントローラに画像を表示するためのアクションを追加します。AnswerControllerに追加します。</p> <pre><code class="php"> public function ogp($id) { $answer = Answer::findOrFail($id); $image = $answer->question->generateOgp(); return response($image, 200) ->header('Content-Type', 'image/png'); } </code></pre> <p>順番が前後してしまいましたが、上記でanswerから関連するquestionを取得するようにしているため、Models/Answer.phpにリレーションを定義しておきます。</p> <pre><code class="php"> public function question() { return $this->belongsTo(Question::class); } </code></pre> <p>アクセスできるようにルーティングを設定します。誰でもアクセスできるようにする必要があるので認証ミドルウェアのグループ外に記述します。</p> <pre><code class="php">Route::get('answers/{id}/ogp.png', 'AnswerController@ogp'); </code></pre> <p>これで<code>http://localhost:8000/answers/1/ogp.png</code>のようなURLにアクセスすると、画像が表示されます。(ホスト名とIDは適宜置き換えてください)</p> <h2 id="回答の詳細ページを作成"><a href="#%E5%9B%9E%E7%AD%94%E3%81%AE%E8%A9%B3%E7%B4%B0%E3%83%9A%E3%83%BC%E3%82%B8%E3%82%92%E4%BD%9C%E6%88%90">回答の詳細ページを作成</a></h2> <p>画像ができましたので、次に実際にTwitterにURLをシェアしてもらうための回答詳細ページを作成します。URLは<code>answers/ID</code>にします。questionsのreceived機能とほとんど同じではあるのですが、あちらは質問を受け取った登録済みユーザー専用のページになります。表示もほとんど同じで良いと思いますが、同じページで共通化して条件分岐などでの対処にしてしまうと、不具合が発生した時に誰でも質問を見れてしまう場合が発生してしまう可能性があります。そういった問題を避けるためにも多少冗長化し、answersデータがあるものだけは一般公開で表示する、という安全な仕様にしています。</p> <p>とりあえずAnswerControllerにメソッドを追加します。</p> <pre><code class="php"> public function show($id) { $answer = Answer::findOrFail($id); return view('answer.show', compact('answer')); } </code></pre> <p>ここにアクセスできるようにルーティングを追加しておきます。ogpの下辺りにshowメソッドの追加を並べておきます。</p> <pre><code class="php">Route::get('answers/{id}/ogp.png', 'AnswerController@ogp'); Route::resource('answers', 'AnswerController')->only(['show']); </code></pre> <p>表示する時に回答者の名前も表示するため、回答データから回答ユーザーを取得できるように、<code>Models/Answer.php</code>にリレーションの定義を追加しておきます。</p> <pre><code class="php"> public function user() { return $this->belongsTo(User::class); } </code></pre> <p>これで<code>$answer->user</code>のような形でアクセスできます。</p> <p>そしてテンプレートを<code>resources/views/answer/show.blade.php</code>として作成します。基本的にreceivedからコピーして不要部分を削っただけです。せっかくなので質問の箇所はOGPと同じ画像を表示するようにしています。軽くはないと思うのでリリース後はCloudflareでキャッシュしたいですね。</p> <pre><code class="html">@extends('layouts.app') @section('title', "{$answer->user->name}さんへの質問") @section('description', $answer->body) @section('ogp', url("answers/{$answer->id}/ogp.png")) @section('content') <section class="text-center"> <h1 style="font-size: 1.5rem">届いた質問</h1> <div class="mb-4"> <img src="<span>{</span><span>{</span> url("answers/{$answer->id}/ogp.png") <span>}</span><span>}</span>"> </div> <h2 style="font-size: 1.2rem"><span>{</span><span>{</span> $answer->user->name <span>}</span><span>}</span>さんからの回答</h2> <div class="card"> <div class="card-body"> <span>{</span><span>{</span> $answer->body <span>}</span><span>}</span> </div> </div> </section> @endsection </code></pre> <p>sectionでtitle, description, ogpを指定しています。この様にして、各ページごとに共通レイアウトに渡したい値を指定することが出来ます。共通レイアウト側にこれを受けて表示をするための処理を追加しておきます。headタグ内に下記を追加します。(タイトルの部分は条件分岐を追加しています)</p> <pre><code class="html"> @hasSection('title') <title>@yield('title') - <span>{</span><span>{</span> config('app.name') <span>}</span><span>}</span></title> <meta property="og:title" content="@yield('title') - <span>{</span><span>{</span> config('app.name') <span>}</span><span>}</span>"> @else <title><span>{</span><span>{</span> config('app.name') <span>}</span><span>}</span></title> <meta property="og:title" content="<span>{</span><span>{</span> config('app.name') <span>}</span><span>}</span>"> @endif @hasSection('description') <meta name="description" content="@yield('description')"> <meta property="og:description" content="@yield('description')"> @else <meta name="description" content="質問と回答ができるサービスです"> <meta property="og:description" content="質問と回答ができるサービスです"> @endif <meta property="og:type" content="website"> <meta property="og:site_name" content="Crieit"> @hasSection('ogp') <meta property="og:image" content="@yield('ogp')"> <meta name="twitter:card" content="summary_large_image"> <meta name="twitter:image" content="@yield('ogp')"> @endif </code></pre> <p>こんな感じで表示できるようになりました。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5dac76a488a46.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5dac76a488a46.png?mw=700" alt="" /></a></p> <p>今はローカル環境で試しているのでダメですが、サーバーにアップしている状態であればこのページのURLをツイートすればTwitterでも画像が表示されるようになっているはずです。</p> <p>ちなみに上記のスクリーンショットのようにちょっと左右のズレがあるようです。画像のサイズと画面の横幅があっていないため、共通レイアウトに直接書かれている<code>600px</code>という表記を適当に全て<code>700px</code>に変更しておきます。</p> <h2 id="まとめ"><a href="#%E3%81%BE%E3%81%A8%E3%82%81">まとめ</a></h2> <p>とりあえずメインの機能はおおよそできてきました。このあと面白くなくなってくる可能性があるのでこの記事はあとにすればよかったと後悔しています。次回は細々としたところを調整していくか、ハロウィン仕様への変更を予定しています。</p> だら@Crieit開発者 tag:crieit.net,2005:PublicArticle/15470 2019-10-09T21:39:36+09:00 2020-02-11T23:34:29+09:00 https://crieit.net/posts/33685639291474c3aabfdaafeafa87cd 質問に回答する機能を作る <p>質問機能は作成済みですので、同様にメインとなる機能である回答機能を作成していきます。</p> <h2 id="回答テーブルを作成する"><a href="#%E5%9B%9E%E7%AD%94%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B">回答テーブルを作成する</a></h2> <p>とりあえず回答用のテーブルのマイグレーションを作成しておきます。</p> <pre><code>php artisan make:migration create_answers --create answers </code></pre> <p>中身はこんな感じです。</p> <pre><code class="php">class CreateAnswers extends Migration { public function up() { Schema::create('answers', function (Blueprint $table) { $table->bigIncrements('id'); $table->bigInteger('question_id')->unsigned()->index(); $table->bigInteger('user_id')->unsigned()->index(); $table->text('body'); $table->timestamps(); $table->foreign('question_id')->references('id')->on('questions')->onDelete('cascade'); }); } public function down() { Schema::dropIfExists('answers'); } } </code></pre> <p>どの質問への回答か、どのユーザーが回答したか、回答の内容のカラムを追加しています。user_idは実際には不要で、question_idでquestionsを検索し、questionsのreceived_user_idでどのユーザが回答したかは分かります。</p> <p>ただし、回答者用の自分の回答一覧ページを作る際に、わざわざ上記のような形でJOINして検索を行わなくてはならないため負荷が増える可能性があります。具体的には下記のようなSQLです。個人的にはJOIN先のみでWHEREするのは非常に微妙だと思っています。</p> <pre><code class="sql">SELECT answers.* FROM answers LEFT JOIN questions ON questions.id = answers.question_id WHERE questions.received_user_id = <span>{</span><span>{</span>ユーザーID<span>}</span><span>}</span> </code></pre> <p>そのためリレーショナルデータベース的には不要ですが、検索を軽量化するための冗長化としてuser_idも入れています。これで下記のシンプルなSQLで取得が可能となります。むちゃくちゃ軽いです。</p> <pre><code class="sql">SELECT * FROM answers WHERE user_id = <span>{</span><span>{</span>ユーザーID<span>}</span><span>}</span> </code></pre> <p>何にしろマイグレーションを実行しておきます。</p> <pre><code>php artisan migrate </code></pre> <h2 id="回答ページを作成"><a href="#%E5%9B%9E%E7%AD%94%E3%83%9A%E3%83%BC%E3%82%B8%E3%82%92%E4%BD%9C%E6%88%90">回答ページを作成</a></h2> <p>本来であれば自分に来た質問一覧ページからリンクをクリックして回答ページへ行く流れですが、とりあえず回答処理を作ってしまいたいので直接回答ページから作っていきます。</p> <p>回答するページのURLは<code>questions/{id}/received</code>とします。とりあえずルーティングを設定します。authミドルウェアの中に入れましょう。</p> <pre><code class="php">Route::group(['middleware' => ['auth']], function () { Route::get('questions/{id}/received', 'QuestionController@received'); Route::resource('questions', 'QuestionController')->only(['store']); }); </code></pre> <p>回答フォーム用の処理を入れていきます。QuestionControllerに下記のようなアクションを入れます。</p> <pre><code class="php"> public function received($questionId) { $question = Question::where('id', $questionId) ->where('received_user_id', Auth::id()) ->firstOrFail(); return view('question.received', compact('question')); } </code></pre> <p>取得する質問データが自分に宛てられたものかどうかもチェックしており、違う場合は本来存在しないURLですのでfirstOrFailで404にしています。このあたりはチームでやっているのであれば各プロジェクトのルールによって適宜適切なエラー処理のルールで対応していくと良いと思います。</p> <p>続いて表示するためのテンプレートを<code>resources/views/question/received.blade.php</code>として作成します。</p> <pre><code class="html">@extends('layouts.app') @section('content') <section class="text-center pt-4"> <h1 style="font-size: 1.5rem">回答する</h1> <div class="card"> <div class="card-body"> <span>{</span><span>{</span> $question->body <span>}</span><span>}</span> </div> </div> </section> @endsection </code></pre> <p>とりあえず回答時に見るための質問表示です。Bootstrapのカードで適当に表示しています。</p> <p><a target="_blank" rel="nofollow noopener" href="https://getbootstrap.com/docs/4.3/components/card/">Cards · Bootstrap</a></p> <p><code>http://localhost:8000/questions/1/received</code> にアクセスすると下記が表示されます。ホスト名、ポート、ID(URLの例で1になっているところ)は適宜自分の環境に合わせてください。認証ガードをしていますのでうまく表示されない場合はログインしておいてください。表示が崩れる場合は<code>yarn hot</code>の実行も必要です。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5d9b4de0a61e2.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5d9b4de0a61e2.png?mw=700" alt="" /></a></p> <p>続いてcard(の閉じタグ)の下にフォームを入れていきます。質問フォームとほとんど同じです。</p> <pre><code class="html"> <div class="card mb-4"> <div class="card-body"> <span>{</span><span>{</span> $question->body <span>}</span><span>}</span> </div> </div> {!! Form::open(['url' => url('answers')]) !!} {!! Form::hidden('question_id', $question->id) !!} <div class="form-group"> {!! Form::textarea('body', null, [ 'class' => 'form-control', 'placeholder' => '回答する', 'required' => true, ]) !!} </div> {!! Form::Submit('回答する', ['class' => 'btn btn-primary']) !!} {!! Form::close() !!} </code></pre> <p>質問とくっついてしまうので質問のclassに<code>mb-4</code>でmargin-bottomも入れています。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5d9bc575e3e36.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5d9bc575e3e36.png?mw=700" alt="" /></a></p> <h2 id="回答を登録する"><a href="#%E5%9B%9E%E7%AD%94%E3%82%92%E7%99%BB%E9%8C%B2%E3%81%99%E3%82%8B">回答を登録する</a></h2> <p>formもできたので回答を登録できるようにします。まずは回答処理用のコントローラを作成します。</p> <pre><code>php artisan make:controller AnswerController </code></pre> <p>ついでにモデルも使うと思うので作っておきます</p> <pre><code>php artisan make:model Models/Answer </code></pre> <p>Questionの時と同様、guardedを設定しておきます。</p> <pre><code class="php">class Answer extends Model { protected $guarded = ['id']; } </code></pre> <p>回答データを保存する際、質問データの子として保存しますので、その関係を質問のモデルにも定義しておきます。<code>Models/Question.php</code>をに下記のような定義を追加します。</p> <pre><code class="php"> public function answer() { return $this->hasOne(Answer::class); } </code></pre> <p>これはそのままの通り、Question has one answer、つまり質問データは一つの回答データを持つ、という定義でリレーションといいます。このようにリレーションを定義しておくことでデータを保存する際や取得する時に簡単に処理を作成することができるようになっています。詳しくは下記の公式マニュアルにも書かれており、他にも様々な種類のリレーションの定義ができるようになっています。</p> <p><a target="_blank" rel="nofollow noopener" href="https://readouble.com/laravel/6.0/ja/eloquent-relationships.html">Eloquent:リレーション 6.0 Laravel</a></p> <p>実際に回答登録処理を作成します。まずは回答用のルーティングを作成します。answersというところです。なんとなくですが、問題がでるところでなければアルファベット順で書くと増えた時に見やすいと思います。</p> <pre><code class="php">Route::group(['middleware' => ['auth']], function () { Route::resource('answers', 'AnswerController')->only(['store']); Route::get('questions/{id}/received', 'QuestionController@received'); Route::resource('questions', 'QuestionController')->only(['store']); }); </code></pre> <h3 id="resourceとは"><a href="#resource%E3%81%A8%E3%81%AF">resourceとは</a></h3> <p>ところでこの<code>resource</code>とは何かを一旦解説します。resourceというのは、基本的な処理のためのアクションをまとめてルーティングに登録できる機能です。Laravelでは具体的には下記になっています。</p> <div class="table-responsive"><table> <thead> <tr> <th>メソッド</th> <th>アクション</th> <th>意味</th> </tr> </thead> <tbody> <tr> <td>GET</td> <td>index</td> <td>一覧</td> </tr> <tr> <td>GET</td> <td>create</td> <td>新規登録ページ</td> </tr> <tr> <td>POST</td> <td>store</td> <td>新規登録処理</td> </tr> <tr> <td>GET</td> <td>edit</td> <td>編集ページ</td> </tr> <tr> <td>PUT, PATCH</td> <td>update</td> <td>更新処理</td> </tr> <tr> <td>DELETE</td> <td>destroy</td> <td>削除</td> </tr> </tbody> </table></div> <p>アクションというのはコントローラのメソッド名ですので分かりやすいと思います。今回の例でいうと回答の登録処理にはstoreというメソッドを追加します。</p> <p>メソッドというのはちょっと最初はわかりにくいかもしれませんが、アプリケーションにどのような処理をするか、というのを伝えるためには実はURLだけでなく、URLとメソッドの組み合わせが必要になっています。</p> <p>普段ブラウザでWebにアクセスする場合は意識していないと思いますが、単にURLにアクセスしているだけではなく、実はGETメソッドでそのURLにアクセスを行っています。そしてformで何かを登録する際も、その登録先のURLにただアクセスしているわけではなく、POST, PUT, PATCH, DELETEのどれかでアクセスを行っています。</p> <p>つまり例えば同じ<code>/questions</code>というURLでも、ブラウザで単にアクセスした場合はGETのアクセスになるので一覧ページが表示され、formでPOSTした場合はデータが登録される、ということになります。とりあえず今のところは深く考える必要はなく、上記のテーブルのメソッドとアクションの組み合わせでコントローラに処理を書けば良い、ということになります。そして、そもそも<code>Route::resource</code>というメソッドがそれを勝手にいい感じに処理してくれるため、とりあえずは深く考える必要はありません。</p> <p>回答ページである<code>questions/{id}/received</code>のように、resourceの形ではなく独自で定義したい場合に<code>Route::get</code>や<code>Route::post</code>等を利用することができる、という感じです。</p> <p>マニュアルでもこのあたりは書かれています。</p> <p><a target="_blank" rel="nofollow noopener" href="https://readouble.com/laravel/5.7/ja/controllers.html#resource-controllers">リソースコントローラ</a></p> <h3 id="回答処理を作成"><a href="#%E5%9B%9E%E7%AD%94%E5%87%A6%E7%90%86%E3%82%92%E4%BD%9C%E6%88%90">回答処理を作成</a></h3> <p>さて、では実際にAnswerControllerに回答の登録処理を作成します。</p> <pre><code class="php">namespace App\Http\Controllers; use App\Models\Answer; use App\Models\Question; use Auth; use Illuminate\Http\Request; class AnswerController extends Controller { public function store(Request $request) { $question = Question::where('id', $request->input('question_id')) ->where('received_user_id', Auth::id()) ->firstOrFail(); $answer = new Answer($request->all()); $answer->user_id = Auth::id(); $question->answer()->save($answer); session()->flash('success', '回答しました。'); return redirect("questions/{$question->id}/received"); } } </code></pre> <p>最初は回答フォームの処理と同様、自分に来た質問かどうかをチェックしています。</p> <p>次に回答データを作成しています。<code>$question->answer()->save($answer);</code>という処理により、その<code>$question</code>の配下になるように回答を保存してくれます。この保存の仕方だとわざわざ<code>$answer->question_id</code>を自分で代入しなくても勝手に入れて保存してくれます。先程モデルに定義したリレーションによってこれができるようになっています。</p> <p>これで実際に回答フォームから回答を登録すると正常にデータが入ります。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5d9bd5d4614cf.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5d9bd5d4614cf.png?mw=700" alt="" /></a></p> <h2 id="回答ページに回答済みの内容を表示する"><a href="#%E5%9B%9E%E7%AD%94%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AB%E5%9B%9E%E7%AD%94%E6%B8%88%E3%81%BF%E3%81%AE%E5%86%85%E5%AE%B9%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B">回答ページに回答済みの内容を表示する</a></h2> <p>既に回答した質問に回答フォームを表示してしまうとおかしいため、回答済みの場合は回答データを表示するようにしておきます。回答ページのテンプレートの回答フォームを条件分岐で表示を切り替えます。ちょこちょこ見出しも変えたりしたためまるごと下記に書きます。</p> <pre><code class="html"><section class="text-center pt-4"> <h1 style="font-size: 1.5rem">届いた質問</h1> <div class="card mb-4"> <div class="card-body"> <span>{</span><span>{</span> $question->body <span>}</span><span>}</span> </div> </div> <h2 style="font-size: 1.2rem">回答</h2> @if ($question->answer) <div class="card"> <div class="card-body"> <span>{</span><span>{</span> $question->answer->body <span>}</span><span>}</span> </div> </div> @else {!! Form::open(['url' => url('answers')]) !!} {!! Form::hidden('question_id', $question->id) !!} <div class="form-group"> {!! Form::textarea('body', null, [ 'class' => 'form-control', 'placeholder' => '回答する', 'required' => true, ]) !!} </div> {!! Form::Submit('回答する', ['class' => 'btn btn-primary']) !!} {!! Form::close() !!} @endif </section> </code></pre> <p>リレーションを定義しているため、<code>$question->answer</code>という形で簡単に回答が取れます。回答データがあれば<code>@if</code>の方が実行されて回答が表示されます。回答がなければnullになりますので、<code>@else</code>の方が実行され、回答フォームが表示されます。回答データを直接phpMyAdmin等で削除してみたり再度回答してみたりすると切り替わるのが分かると思います。</p> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5d9bdaa76946e.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5d9bdaa76946e.png?mw=700" alt="" /></a></p> <p>これで一旦回答機能の作成は完了です。</p> <h2 id="微調整"><a href="#%E5%BE%AE%E8%AA%BF%E6%95%B4">微調整</a></h2> <p>少し気になるところが出てきたため一旦微調整しておきます。</p> <h3 id="ナビを調整"><a href="#%E3%83%8A%E3%83%93%E3%82%92%E8%AA%BF%E6%95%B4">ナビを調整</a></h3> <p>ナビがcontainerの中に入って短く切れている感じが変だったので、containerから出してbodyの最初に配置しました。また、ナビの中にcontainerをいれて全体的な横幅を揃えました。</p> <pre><code class="html"> <nav class="navbar navbar-expand-lg navbar-dark bg-primary"> <div class="container" style="max-width: 600px"> : : </div> </nav> <div class="container" style="max-width: 600px"> @if (session('success')) <div class="alert alert-success" role="alert"> <span>{</span><span>{</span> session('success') <span>}</span><span>}</span> </div> @endif @yield('content') </div> </code></pre> <p><a href="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5d9c96ff13982.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6e2af66c610d88bc766649f72032893a5d9c96ff13982.png?mw=700" alt="" /></a></p> <h3 id="最初の余白を調整"><a href="#%E6%9C%80%E5%88%9D%E3%81%AE%E4%BD%99%E7%99%BD%E3%82%92%E8%AA%BF%E6%95%B4">最初の余白を調整</a></h3> <p>質問ページも回答ページも最初の要素に<code>pt-4</code>をいれて、ナビとくっつきすぎるのを防いでいました。しかし全てのページでこれをやるのは微妙すぎるため、ナビのmargin-bottomをつけて共通化するようにしました。</p> <p>具体的には各ページの<code>pt-4</code>を削除し、ナビに<code>mb-4</code>を入れました。</p> <h2 id="まとめ"><a href="#%E3%81%BE%E3%81%A8%E3%82%81">まとめ</a></h2> <p>質問形サービスの根幹となる機能がまた一つできました。まだ細かいところも大きいところもちょこちょこ残っていますので、引き続き進めていきます。次回は片手で小銭を最大何枚持てるかを検証していきたいと思います。</p> だら@Crieit開発者