Help us understand the problem. What is going on with this article?

Laravelでwiki的なものをつくってみる(後編)

More than 3 years have passed since last update.

Schooアドベントカレンダーの21日目の記事です。

前編はこちら

前編のまとめ

  • Schooの@ohidaです
  • 2016年9月Schooにジョインしたてへぺろ系非エンジニア職
  • 好きなアニメは「斉木楠雄のΨ難」
  • Laravelでwiki的なものをつくろう
  • 色々あったけれど、ページの一覧画面が表示されるところまでできた

というわけで前回のつづきを楽しくつくっていきましょう。

パーマリンク

リンクをはってしまってもう我慢できないと思いますので、クリックしてみましょう。真っ白な画面が表示されたら成功の証です。(正しく動作していない場合はエラー画面になります)

ちなみにURLはこんな感じになっていると思います。

http://wiki.dev/pages/blanditiis

コントローラの該当のアクションをみてみます。ルーティングテーブルを確認してみましょう。

Verb URI Action Route Name
GET /pages/{page} show pages.show

PostControllershow()メソッドが呼ばれているようです。
Laravelがコントローラ作成時にCRUDに対応するメソッドの雛形をつくってくれていますが、当然ですがまだからっぽです。

app/Http/Controllers/PageController.php
public function show($id)
{
    //
}

ここで指定されたページを表示するためのロジックを書きたいところですが、ちょっとまってください、$idというのが渡ってきていますね。これはルーティング時にデフォルトではIDが渡されることを想定しているということで、実態にあわせて書き換えます。今回は文字列の$titleが渡されますので、こんな風にします。

app/Http/Controllers/PageController.php
public function show(string $title)
{
    //
}

でロジックを書き、

app/Http/Controllers/PageController.php
public function show(string $title)
{
    // タイトルにマッチするページを取得する
    $page = Page::whereTitle($title)->first();

    return view('pages.show')->with([
        'page' => $page,
    ]);
}

ページ表示のためのテンプレートをつくる

resources/views/pages/show.blade.php
@extends('app')

@section('content')
    <h1>{{ $page->title }}</h1>
    <div>
        {{ $page->body }}
    </div>
@endsection

そしてブラウザでhttp://wiki.dev/pages/blanditiisを確認すると・・・

(´;ω;`)できたブワッ

Implicit Binding

突然の横文字。

さて、こんな感じで一覧ページとパーマリンクの表示が一応できるようになりました。
先ほどshow()メソッドの引数$idstring $titleに変え且つページの取得ロジックを記述しましたが、これと同じ処理は他のメソッドでも必要となる予定です。

  • edit($id)
  • update(Request $request, $id)
  • destroy($id)

これらについても先ほどと同様に変更すれば問題なく動作するのですが、ここではLaravelの機能をつかって、よりスマートな感じで解決してみることにします。

Laravelでは、ルーティングの情報から直接モデルを取得できるImplicit Bindingという機能があります。今回の場合、ルーティング時に$titleという文字列が取得できるので、それをキーとしてページモデルを解決できます。

Pageモデルに以下のメソッドを追加することでtitleをキーとして指定します。(指定しない場合はidがキーとなります)

app/Page.php
public function getRouteKeyName()
{
   return 'title';
}

アクションの引数で該当のモデルを指定することでこの機能が使われます。アクションを以下のように書き換えます。

app/Http/Controllers/PageController.php
public function show(Page $page)  ここ
{
   return view('pages.show')->with([
       'page' => $page,
    ]);
}

暗黙的にモデルを解決(束縛)するのでImplicit Bindingということですね。
アクション実行時には既に該当するページの取得が完了しているので、だいぶスッキリと処理を書けるようになったと思います。Laravelすごい。

こういった依存性注入はLaravelの得意とするところで、うまく活用することできれいなコードを書くことができます。

他のメソッドの引数も同じように書き換えておきましょう。

  • edit(Page $page)
  • update(Request $request, Page $page)
  • destroy(Page $page)

ページを作成

ページを書く機能がまだありませんので、次はここに着手してみます。
ページの作成では、次のルーティングを使います。

Verb URI Action Route Name
GET /pages/create create pages.create
POST /pages store pages.store

フォームを表示

まずはGET /pages/createを実装します。これはページの作成フォーム画面になります。
ブラウザで/pages/createにアクセスすると例によって白紙になるので、以下のような処理を記述します。

app/Http/Controllers/PageController.php
public function create()
{
    return view('pages.form', [
       'page' => new Page(),
    ]);
}

テンプレートについて、今回はすこしトリッキーですが、新規作成と編集で同じものをつかうことにします。
@if ($page->id)の分岐で作成と編集を切り替えていて、 新規の場合はPOST、編集の場合はPUTメソッドが用いられるようにしています。

resources/views/pages/form.blade.php
@extends('app')

@section('content')
    <h1>Form</h1>

    @if (count($errors) > 0)
        <div class="alert alert-danger">
            <ul>
                @foreach ($errors->all() as $error)
                    <li>{{ $error }}</li>
                @endforeach
            </ul>
        </div>
    @endif

    @if ($page->id)
        <form action="/pages/{{ $page->title }}" method="POST">
            {{ method_field('PUT') }}
    @else
        <form action="/pages" method="POST">
    @endif
        {{ csrf_field() }}
        <div class="form-group @if($errors->has('title')) has-error @endif">
            <label class="control-label">Title</label>
            <input name="title" type="text" class="form-control" value="{{ old('title', $page->title) }}">
        </div>
        <div class="form-group @if($errors->has('body')) has-error @endif">
            <label class="control-label">Body</label>
            <textarea name="body" class="form-control" name="" id="" cols="30" rows="10">{{ old('body', $page->body) }}</textarea>
        </div>
        <input type="submit" class="form-control btn-primary">
    </form>

@endsection

ブラウザでhttp://wiki.dev/pages/createを確認すると無事フォームが表示されました。

ページの作成

つづいてPOST /pagesの処理をコントローラに記述します。
フォームからのリクエストをバリデーションして、ページを作成して、できたページのURLにリダイレクトします。

app/Http/Controllers/PageController.php
public function store(Request $request)
{
    // バリデーション
    $this->validate($request, [
        'title' => 'required|unique:pages,title',
        'body' => 'required',
    ]);

    // ページを作成
    $post = Page::create($request->all());

    // つくったページのURLにリダイレクト
    return redirect($post->url);
}

だいぶシンプルですね。ちなみにバリデーションに関しては、Laravelでは上記の汎用的な書き方以外にも、Form Request Validationというフォームのバリデーション時に便利な機能も用意されていますので、ぜひチェックしてみてください。

確認しよう

ブラウザで確認してみましょう。

でポストしてみます。

(´;ω;`)できたブワッ

内容を空にしたり、既にあるものと同じタイトルで投稿しようとするとバリデーションエラーが発生し、正常にチェックが行われていることがわかります。

ページを編集

つづいて編集機能をつくりましょう。作成のときと同じ感じでいけます。
利用するルーティングは以下のとおりです。

Verb URI Action Route Name
GET /pages/{page}/edit edit pages.edit
PUT/PATCH /pages/{page} update pages.update

フォームを表示

GET /pages/{page}/editを実装します。これはページ編集フォーム画面になります。

app/Http/Controllers/PageController.php
public function edit(Page $page)
{
    return view('pages.form', [
       'page' => $page,
    ]);
}

フォームは先ほどつくったのでそのまま利用します。

ページの編集

アクションの実装についても、作成のときとほぼ同じ流れになります。
違いとしては、メソッドの引数として該当のページのインスタンスが渡されるので(前述したImplicit Bindingで)、それを利用してバリデート(タイトルのuniqueチェック)とアップデートを行うところになります。
(uniqueチェックにIdを渡すことで自分自身を例外にしています)

app/Http/Controllers/PageController.php
public function update(Request $request, Page $page)
{
    $this->validate($request, [
        'title' => 'required|unique:pages,title,'.$page->id,
        'body' => 'required',
    ]);

    $page->update($request->all());

    return redirect($page->url);
}

ブラウザで確認してみましょう。

スクショは割愛しますが (´;ω;`)できたブワッ ということでお願いします

markdownに対応する

さて、いよいよお楽しみのmarkdownに対応するときがきました。
本来はここまでで前編くらいの予定でしたが、なんでしょう後編も既に長文感がでてきました。

それでは気分とページを書き換えて、試しにmarkdownぽいものを書いてみましょう。
さっきつくった「Hello World!」のページを編集してみます。

※編集のためのリンクは用意してないので、ページのパーマリンクでURLの末尾に/editをつけてください

そしてポスト

はい。

markdownライブラリを導入

PHPにもいろいろなmarkdownのライブラリがありますが、今回は「cebe/markdown」というものを使ってみます。

composerでインストールします。

$ composer require cebe/markdown

パースしてみる

cebe/markdownの使いかたはドキュメントをみるとこんな感じのようです。

github.com/cebe/markdownより
$parser = new \cebe\markdown\Markdown();
echo $parser->parse($markdown);

さっそくこれをつかってmarkdownのパースを実装してみましょう。Postモデルに自身のbodyをパースするためのparse()メソッドを追加します。

useでインポートして

app/Page.php
use cebe\markdown\Markdown as Markdown;  追加

class Page extends Model
{

parse()メソッドを追加

app/Page.php
// bodyのmarkdownをパースする
public function parse()
{
    $parser = new Markdown();

    return $parser->parse($this->body);
}

さらにテンプレートからモデルの属性値として取得できるようにしておきます(URLのときにやったのと同じです)

app/Page.php
public function getMarkdownBodyAttribute()
{
    return $this->parse();
}

そしてテンプレートの該当箇所を書き換える

resources/views/pages/show.blade.php
@extends('app')

@section('content')
    <h1>{{ $page->title }}</h1>
    <div>
        {!! $page->markdown_body !!}  ここ
    </div>
@endsection

※HTMLをそのまま表示するため、エスケープをしないリテラルに変えています

ブラウザで確認してみましょう

(´;ω;`)できたブワッ

わりと簡単にみんな大好きなmarkdownに対応することができました!
ちなみに僕はmarkdownは難しいので苦手です。

:star: 別ページへのリンクをはる(1)

さて次に、別ページへのリンクに対応してみましょう。「Laravelでwiki的なものをつくってみる」というこの記事において、もっともwikiらしいといえる機能がここであります。ハイライト感を出すためにタイトルにも星をつけてみました。

wikiではよく[ページタイトル]的な機能で別ページへのリンクを設けたりしますので、それになぞらえた感じの仕組みをつくってみましょう。

markdownで同じ書式にすると都合が悪そうな予感がするので、今回は次のような仕様にしてみます。

  • [[ページタイトル]]という書式で別ページへのリンクをはることができる
  • ページがまだ存在しなければ新規作成ページを表示 ← NEW

ちなみにNEWというのは僕の中で今さら気づいた新しいタスクという意味になります。

独自タグへの対応

[[ページタイトル]]という独自タグを実装してみます。

cebe/markdownのドキュメントによると、このような場合は拡張クラスをつくるとのことなのでつくってみましょう。traitで機能を切り分けるとキレイそうですが、ひとまず今回はシンプルにこんな感じにしました。

app/WikiMarkdown.php
<?php

namespace App;

use cebe\markdown\GithubMarkdown;

class WikiMarkdown extends GithubMarkdown
{
    public function __construct()
    {
        $this->enableNewlines = true;
        $this->html5 = true;
    }

    /**
     * @marker [[
     * @marker ]]
     */
    protected function parseBracketTag($markdown)
    {
        if (preg_match('/^\[\[(.+?)\]\]/', $markdown, $matches)) {
            return [
                ['bracket', $this->parseInline($matches[1])],
                strlen($matches[0]),
            ];
        }

        return [['text', substr($markdown, 0, 2)], 2];
    }

    protected function renderBracket($element)
    {
        $title = $this->renderAbsy($element[1]);
        $url = route('pages.show', $title);

        return sprintf('<a href="%s" class="router-link">%s</a>', $url, $title);
    }
}

parseBracketTag()と('bracket'という名前を通してそこから呼び出される)renderBracket()が今回の[[ページタイトル]]に対応するためのコードになります。
当該の記法をページへのリンクに書き換えるという処理を行っています。

ちなみにPHPDoc的なコメントにある@markerはアノテーションとして機能するので、消すと動かなくなります。@markerで指定した文字列がみつかるとこのメソッドがよばれるようです。不気味ですね。

ついでに、改行を<br>として扱いたいので、MarkdownクラスのかわりにGithubMarkdownクラスを継承し、コンストラクタでオプションの設定をしています。

これで独自タグをパースできるクラスができましたので、モデルのパース処理でこのクラスを利用するようにします。

app/Page.php
// use cebe\markdown\GithubMarkdown as Markdown; ← これの代わりに
use App\WikiMarkdown as Markdown;  これをインポートする

インポート時のクラス名は変えていないので、クラスを利用する箇所のコードはそのままでOKです。

それではブラウザで確認してみましょう。ドキドキ。
存在するページのタイトルをつかって独自タグを記述します。

こんな感じの内容でポストしてみましょう。

ちゃんとリンクになっていますね。クリックすると・・・

スクリーンショット 2016-12-21 12.26.09.png

(´;ω;`)できたブワッ

:star2: 別ページへのリンクをはる(2)

うまく独自タグが機能するようになりました。
が、まだ無いページへのリンクがはられた場合のことをさっきまで考えていなかったので、その対応を行ってみましょう。

そもそもまだ無いページにアクセスするとどうなるのかを見てみることにします。
まずポストを書き換えて、存在しないページへのリンクをはります。

そしてリンクからアクセス

例外! というわけで、モデルが見つからないというエラーになりました。この場合、例のImplicit Binding経由での処理になっているので、ルーティング時に取得した$titleにマッチするページが見つからないということを言っています。

対応方法としてはいくつかありそうな気がするのですが、今回は素直にメソッドの処理を書き換えてみたいと思います。
該当の箇所はPageControllershow()なので、ここを変えていきます。

まず現状はこの通りです。

app/Http/Controllers/PageController.php
public function show(Page $page)
{
    return view('pages.show')->with([
        'page' => $page,
    ]);
}

ここでPageモデルを解決しようとしているのが原因なので、余計なことをせずに$title文字列を受け取るようにしてみます。

app/Http/Controllers/PageController.php
public function show(string $title)  ここ
{
    ...

ただ引数を書き換えただけ的な。
これでタイトルを受け取れるようになりました。モデルの解決が行われなくなったので、先ほどの例外はもう発生しないはずです。
かわりにモデルの解決を自分で行う必要ができましたので、その処理を書きます。

app/Page.php
public function show(string $title)
{
    $page = Page::whereTitle($title)->first();

    return view('pages.show')->with([
        'page' => $page,
    ]);
}

このかたちはまさに本ページの一番上でやったパーマリンク機能の最初に書いた処理と同じですね。運命の再開。

さて、ページが見つからない場合どうするか?について考えます。いくつか選択肢はあって、いきなり新規作成ページに飛ばしてもいいし、ページが存在しないのでつくりますか?などと表示してもいいと思います。今回はよりwikiっぽい感じのする後者的アプローチをとることにします。

app/Page.php
public function show(string $title)
{
    $page = Page::whereTitle($title)->first();

    if (!$page) {
        $page = new Page();
        $page->title = $title;
    }

    return view('pages.show')->with([
        'page' => $page,
    ]);
}

ページが見つからないときには新しいインスタンスを作成し、リクエストから渡される$titleをタイトルに代入しています。
あとはページが存在するときと同様の処理になり、ビューにページのデータが渡されます。

それではブラウザでhttp://wiki.dev/pages/ないページを確認してみましょう。

(´;ω;`)できたブワッ

あとはテンプレートを少しいじって、それっぽいメッセージとリンクを追加してみましょう。

resources/views/pages/show.blade.php
@extends('app')

@section('content')
    <h1>{{ $page->title }}</h1>
    @if ($page->id)
        <div>
            {!! $page->markdown_body !!}
        </div>
    @else
        <div class="well">
            この名前のページはまだ作成されていません
        </div>
        <div>
            <a href="{{ route('pages.create', ['title' => $page->title]) }}">ページを作成</a>
        </div>
    @endif
@endsection

そしてリロード

(´;ω;`)できたブワッ

最後に、上のページの作成リンクには、titleパラメータを仕込んでいるので、受け取り側もそれに対応しておきます。
フォームのタイトルの項目に、titleで渡された文字列があらかじめ入力されるようにします。

app/Http/Controllers/PageController.php
public function create(Request $request)
{
    // ここでインスタンスをつくって
    $page = new Page();
    // titleパラメータを受け取るようにする
    $page->title = $request->title;

    return view('pages.form', [
        'page' => $page,
    ]);
}

そしてブラウザでhttp://wiki.dev/pages/create?title=ないページにアクセス

やったーできたよ
(´;ω;)ブワッ そして (´;ω;)ブワッ

検索について あるいはBYEFORNOW

LaravelのScoutを使った検索を試したいと思っていましたが、今回はちょっと時間切れのためまたの機会に書きたいと思います。お楽しみに!

(ノω・)テヘ

ごめんなさい・・・&ハッピーメリークリスマス!! :christmas_tree:

まとめ

というわけでwiki的なものをつくってみました。
ちょっと駆け足気味だったり雑だったりするところもありましたが、Laravelを使ったアプリ作成の流れや便利さや面白さみたいなものが少しでも伝わったら幸いです。
この記事を読みながら実際につくってみると実はなんだこれ説明通りやっても動かねーよくそなどあると思いますので、ぜひつくってみていただけると楽しいのではないかと思います。

以上となります。TL;DR

ソースコード

githubにうpしてありますのでご参考まで :octocat:

耳寄り情報

今日!SchooでLaravelの授業があるよ

PHPフレームワーク(Laravel)を使った効率的なWebアプリケーション開発

Schooではエンジニアのお友だちを募集しているよ

来年のSchooアドベントカレンダーを一緒に書かないか

(ノω・)テヘ

uzabase
企業活動の意思決定を支える情報インフラの提供
https://www.uzabase.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away