見出し画像

お知らせアプリを作ろう(Appendix) #Laravelの教科書

お知らせアプリ機能で質問をいただきましたので追加します。

リクエストの内容は

お知らせ情報を別のサイトなどから利用することは出来るのか?

というものでした。

お知らせ機能に特化したアプリという形で考えると、Twitterのタイムラインのように会社サイトに埋め込んだ形で作ることも考えられます。

どのように実現するのか、簡単に実現出来るものから、少し高度なものまでいくつかのパターンを説明していきたいと思います。

# iframeを使った表示方法

Twitterやinstagramを会社やブランドに埋め込んでいるサイトを見たことがありますか?

公式サイトにお知らせの機能がないものなど、Twitterの公式アカウントを埋め込んでお知らせ代わりに使っていることがあります。

スクリーンショット 2020-07-01 21.24.12

某ゲームの公式サイト

このように別のサイト(公式サイト)から別のサイト(Twitter)のコンテンツを表示するにはiframeを使うのが一般的です。

このような使い方を「iframeを利用した埋め込み」と表現することが多いです。

お知らせアプリのユーザーページを改修し、埋め込むためのiframeを表示する機能を作成していきましょう。


# 埋め込みURLの設計

現在、ユーザーページは次のようなURLで表示しています。

http://localhost:8000/u/hanako/

このページを埋め込みコンテンツとして改修していきましょう。

そのまま埋め込みのURLに使うのではなく、埋め込み表示とそれ以外で見た目を変えることが出来るように、URLを分けます。

「/embed/hanako/」という形にURLを分けることもできますが、今回はシンプルに、パラメーターに「?embed」とつける形で考えます。

http://localhost:8000/u/hanako/?embed

このURLを埋め込むためのiframeのコードは次のようになります。

<iframe src="http://localhost:8000/u/hanako/?embed"></iframe>

実際はどこかのサーバーに設置するので以下のようなHTMLになります。

<iframe src="http://news.example.com/u/hanako/?embed"></iframe>


# 埋め込み用のHTMLコードの表示の追加

埋め込みのためのHTMLをそのまま書かせるは少し難しいため、ユーザーページの下に埋め込むためのサンプルを表示する機能を追加しましょう。

まずはテンプレートを編集して、埋め込み用HTMLを表示するための場所を作成します。

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">Dashboard</div>
               <div class="card-body">
                   @if (session('status'))
                       <div class="alert alert-success" role="alert">
                           {{ session('status') }}
                       </div>
                   @endif
                   <div class="mb-3">
						<a href="{{ url('/news/create') }}" class="btn btn-primary">記事作成</a>
					</div>
					<ul class="list-group">
						@foreach ($news_list as $news)
						<li class="list-group-item">
							<a href="{{ url('news/edit/' . $news->id) }}">
								<h5>{{ $news->title }}</h5>
							</a>
							<div class="row">
								@if($news->thumbnail_url || $news->image_url)
								<div class="col">
									@if($news->thumbnail_url)
									<img src="{{ Storage::url($news->thumbnail_url) }}" style="width: 150px;"/>
									@elseif($news->image_url)
									<img src="{{ Storage::url($news->image_url) }}" style="width: 150px;"/>
									@endif
								</div>
								@endif
								<div class="col">
									<p>{{ $news->description }}</p>
									<p>create: {{ $news->created_at->format("Y-m-d H:i:s") }}</p>
									<p class="text-right">
										<a class="btn btn-outline-secondary" href="{{ url('u/' . $user->display_name . '/' . $news->id) }}">確認</a>
									</p>
								</div>
							</div>
						</li>
						@endforeach
					</ul>
					<div class="mt-3">
						{{ $news_list->links() }}
					</div>

+					<div class="mt-3">
+						<h4>埋め込み方法</h4>
+						<textarea class="form-control">{{ $embed_html }}</textarea>
+						<p>こちらのコードをサイトに埋め込んでください</p>
+						
+						<h4>埋め込みプレビュー</h4>
+						{!! $embed_html !!}
+						
+					</div>
					
               </div>
           </div>
       </div>
   </div>
</div>
@endsection

埋め込みHTMLの組み立てはViewでは行わず、Controllerで組み立てて「$embed_html」で渡すようにします。

{!! $embed_html !!}

実際にユーザーページで埋め込んだ時の振る舞いを確認出来るようにするため、作成したHTMLをそのまま表示しています。{!!〜!!}}で囲まれた部分はそのままHTMLが表示されます。

次にControllerを編集して埋め込み用のHTMLを組み立てて、Viewに渡すようにします。

app/Http/Controllers/HomeController.php

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\NewsEntry;
use Auth;
class HomeController extends Controller
{
   /**
    * Create a new controller instance.
    *
    * @return void
    */
   public function __construct()
   {
       $this->middleware('auth');
   }
   /**
    * Show the application dashboard.
    *
    * @return \Illuminate\Contracts\Support\Renderable
    */
   public function index()
   {
		$user = Auth::user();
		$news_list = $user->newsEntry()->orderBy("id", "desc")->paginate(10);
		//$news_list = NewsEntry::where("user_id", Auth::id())->orderBy("id", "desc")->paginate(10);

		//埋め込み用HTMLの組み立て
+		$embed_html = [];
+		$embed_html[] = '<div style="width: 100%; height: 300px; margin:0; padding: 0px; border: none; max-width: 100%; min-width: 180px;">';
+		$embed_html[] = '<iframe src="'.url('/u/' . $user->display_name . '/?embed').'" style="width:100%;height:100%; border:none;"></iframe>';
+		$embed_html[] = '</div>';

		return view("home", [
			"news_list" => $news_list,
			"user" => $user,
+			"embed_html" => implode("", $embed_html)
		]);
   }
}

<div style="width: 100%; height: 300px; margin:0; padding: 0px; border: none; max-width: 100%; min-width: 180px; "><iframe src="http://localhost:8000/u/hanako?embed" style="width:100%;height:100%; border:none;"></iframe></div>

このようなHTMLを組み立てて渡しています。PHPでHTMLを組み立てる場合、配列を作ってからimplode()で一つにまとめるやり方が楽です。

ブラウザでホーム画面(http://localhost:8000/home)を開いて動作の確認をしてみましょう。

スクリーンショット 2020-07-01 21.52.16



# 埋め込みコンテンツのデザイン修正

現在の表示では埋め込んだ場所に不要なヘッダーやログインメニューが表示されてしまっています。

埋め込み先のユーザーページのコントローラーを修正し、?embedの有無でデザインを変更する仕組みを作りましょう。(-が削除する行、+が追加する行です)

app/Http/Controllers/UserPageController.php

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\User;
use App\NewsEntry;
class UserPageController extends Controller
{

-	function show($name){
+	function show(Request $request, $name){
		
		$user = User::where("display_name", $name)->first();
		if(!$user){
			return abort(404);
		}
		$news_list = $user->newsEntry()->orderBy("id", "desc")->paginate(10);
		
+        if($request->has("embed")){
+			return view("user_page_embed", [
+				"news_list" => $news_list,
+				"user" => $user
+			]);
+		}

		return view("user_page", [
			"news_list" => $news_list,
			"user" => $user
		]);
	}

-	function showDetail($name, $id){
+	function showDetail(Request $request, $name, $id){
		
		$news = NewsEntry::find($id);
		if(!$news){
			return abort(404);
		}
		$user = $news->user;
		//display_nameが違う(不正なアクセス!)
		if($user->display_name != $name){
			return abort(404);
		}

+		if($request->has("embed")){
+			return view("user_news_detail_embed", [
+				"news" => $news,
+				"user" => $user
+			]);
+		}

		return view("user_news_detail", [
			"news" => $news,
			"user" => $user
		]);
	}
}
function show(Request $request, $name)
function showDetail(Request $request, $name, $id)

今までは関数の引数が$nameだけでしたが、embedの有無をチェックするために$requestが必要になりました。

そのため、関数の定義をこのように書き換えます。「show($request, $name)」では動作せず、「show(Request $request, $name)」のようにRequestの型を明記する必要があるので注意してください。


if($request->has("embed"))

?embedの有無を確認するには$request->has()関数が便利です。

次に作成したテンプレート、user_page_embedを作成します。

user_page_embedはuser_page.blade.phpをコピーして作成します。中身はほとんど同じですが、埋め込みコンテンツ用に一部書き換えが必要です。(* が書き換えた場所)

@extends('layouts.app')
@section('content')
<div class="container">
	<div class="card">
		<div class="card-header">
			{{ $user->name }}からのお知らせ
		</div>
		<div class="card-body">
			<ul class="list-group">
				@foreach ($news_list as $news)
				<li class="list-group-item">
*					<a href="{{ url('u/' . $user->display_name . '/' . $news->id . '/?embed') }}">
						<h5>{{ $news->title }}</h5>
					</a>
					<div class="row">
						@if($news->thumbnail_url || $news->image_url)
						<div class="col">
							@if($news->thumbnail_url)
							<img src="{{ Storage::url($news->thumbnail_url) }}" style="width: 150px;"/>
							@elseif($news->image_url)
							<img src="{{ Storage::url($news->image_url) }}" style="width: 150px;"/>
							@endif
						</div>
						@endif
						<div class="col">
							<p>{{ $news->description }}</p>
							<p>create: {{ $news->created_at->format("Y-m-d H:i:s") }}</p>
						</div>
					</div>
				</li>
				@endforeach
			</ul>
			<div class="mt-3">
*				{{ $news_list->appends(["embed" => 1])->links() }}
			</div>
			
		</div>
	</div>
</div>
<style type="text/css">
#app nav.navbar {
	display:none
}
</style>
@endsection


ポイントはすべてのURLに/?embedを追加することです。

<a href="{{ url('u/' . $user->display_name . '/' . $news->id . '/?embed') }}"><h5>{{ $news->title }}</h5></a>

記事詳細のリンクにも/?embedを追加しています。

{{ $news_list->appends(["embed" => 1])->links() }}

ページャーのURLに追加する場合はappends()関数を使います。

<style type="text/css">
#app nav.navbar {
    display:none
}
</style>

ナビゲーションバーを隠すためにCSSを追加します。


次に埋め込みコンテンツ用に記事詳細テンプレートを作成します。

user_news_detail_embed.blade.phpuser_news_detail.blade.phpをコピーして一部を書き換えて作成します(*が書き換え箇所)。

resources/views/user_news_detail_embed.blade.php

@extends('layouts.app')
@section('content')
<div class="container">
	<div class="card">
		<div class="card-header">
			{{ $user->name }}からのお知らせ
		</div>
		<div class="card-body">
			<h5>{{ $news->title }}</h5>
			@if($news->image_url)
			<div>
				<img src="{{ Storage::url($news->image_url) }}" style="width: 100%"/>
			</div>
			@endif
			<p>create: {{ $news->created_at->format("Y-m-d H:i:s") }}</p>
			
			<div>
				{!! nl2br(e($news->body)) !!}
			</div>
			<div class="mt-3 text-center">
*				<a href="{{ url('u/' . $user->display_name )}}?embed">一覧に戻る</a>
			</div>
			
		</div>
	</div>
</div>
<style type="text/css">
#app nav.navbar {
	display:none
}
</style>
@endsection
<a href="{{ url('u/' . $user->display_name )}}?embed">一覧に戻る</a>

一覧に戻るリンクに?embedを追加しています。

<style type="text/css">
#app nav.navbar {
display:none
}
</style>

一覧と同じようにナビゲーションバーは非表示にします。

テンプレートの修正まで終わったら、ブラウザでプレビューを確認してみましょう。上手くナビゲーションバーが消えているか確認しましょう。

スクリーンショット 2020-07-01 22.14.37


表示範囲が狭い場合は、埋め込み用のHTMLを修正することで対応します。

<div style="width: 100%; height: 300px;

<div style="width: 100%; height: 600px;

このあたりのデザインは設置場所に応じてカスタマイズが必要となります。


実際にこの埋め込み機能を使うには、サーバーにアップする必要がありますが、動作確認用にローカル環境にインストールしたWordPressに組み込んで見るとこんな感じの表示になります。

スクリーンショット 2020-07-01 22.23.38

いい感じですね。


# Ajaxでの表示

応用例となりますが、外部のサイトからAjaxを使ってコンテンツを取得するパターンもあります。例えば次のようなコードです。

<script type="text/javascript">
$(function(){
	$.get("http://localhost:8000/u/hanako?embed", function(content){
		$("body").append($(content));
	})
});
</script>

こちらのコードはそのままでは動きません。CORSという制約があるためAjaxでは読み込めないようになっています。

理由は、この書き方を許してしまうと、どのコンテンツも自由に表示されてしまうからです。

CORSの制約を回避するには、コントローラーを修正して埋め込みページの場合は別ドメインからも読み込めるように設定する必要があります。

app/Http/Controllers/UserPageController.php

if($request->has("embed")){
	$view = view("user_page_embed", [
		"news_list" => $news_list,
		"user" => $user
	]);
	
	return response($view)->header('Access-Control-Allow-Origin', '*');
}

return view()で返していた箇所がresponse($view)に書き換わっています。

header('Access-Control-Allow-Origin', '*')

「Access-Control-Allow-Origin」ヘッダーはコンテンツのAjax表示を許可するための設定です。特定のドメインからのみ許可することもできますが、ここでは「*」を指定し、どこからでも読み込めるようにしたものです。

この段階で表示まではできますが、実際に動かすと画像が上手く表示されません。原因はStorage::url()で画像を表示すると「/」から始まる絶対URLが出力されることです。

Ajaxでの読み込みの場合、画像などの表示もすべてhttp〜から始まる絶対URLにする必要があります。

Storage::url()を更にurl()で囲むと、http〜から始まる絶対URLに書き換えることができます。

テンプレートで画像を使っている場所は、次のように書き換えます。

<img src="{{ Storage::url($news->thumbnail_url) }}" style="width: 150px;"/>

<img src="{{ url(Storage::url($news->thumbnail_url)) }}" style="width: 150px;"/>


# AjaxとJSONを使う

HTMLの組み立てをLaravel側でなく、埋め込み先が自由に出来るようにするAPI的な作り方もあります。

<script type="text/javascript">
$(function(){
	$.getJSON("http://localhost:8000/u/hanako?embed&output=json", function(data){
		console.log(data);
	})
});
</script>

LaravelではJSONを返却するのは非常に簡単に作ることができます。

app/Http/Controllers/UserPageController.php

if($request->input("output") == "json"){
	return response()->json([
		"news_list" => $news_list,
		"user" => $user
    ])->header('Access-Control-Allow-Origin', '*');
}

output=jsonが指定された場合はJSONを返す、という関数です。View()に渡す配列をそのまま指定しています。

Ajaxでの取得を許可するためのheader()の指定はこの場合も必要です。

スクリーンショット 2020-07-01 23.07.43

このスクリプトを実際に動かすとこのような表示になります。ユーザー名だけでなくemailなども出てしまっているので、実際に使う場合は見えてしまっても大丈夫なデータに限定する必要があります。

コンテンツの埋め込み側はこのJSONを使ってHTMLを作成していきます。

Laravel + VueやLaravel + Reactの実装では、このようにLaravelでJSONを出力し、VueやReactでHTMLを作るといった作り方をします。


# まとめ

Ajaxの利用は制限も多く、JSONをそのまま出すとセキュリティ的に問題になることもあるため、サイトに表示するだけであればiframeを使うのが良いと思います。

Ajaxを利用した読み込みは下記に注意しましょう。
・Access-Control-Allow-Originを設定する
・画像なども絶対パスにする


Webアプリの世界では、このような他のサイトへの埋め込み前提で作ることもあります。例えばGoogleマップが埋め込まれたサイトなどはしばしば見ると思います。

Googleマップでもこのようなiframeでの埋め込みが提供されています。一度手を動かして作ってみるとウェブアプリがどのように作られているのか、理解する手助けになりますし、作れることの幅がぐっと広がります。

おつかれさまでした!

完成まで突っ走る意気込みです。サポートしていただけると非常に嬉しいです。応援よろしくお願いします。