20191012のPHPに関する記事は7件です。

AWS EC2 LAMP環境構築

作成環境

 - AmazonLinux
 - Apache
 - MySQL
 - PHP

前提条件

  • AWSアカウントを持っている
  • EC2インスタンス起動済み
  • Macならターミナル 、WindowsならTeraTermからアクセスしている

  ※EC2インスタンスを立ち上げた時点でLinux環境は完了

インストール

[ec2-user@ip-・・・・・・・・・・・~]$ sudo yum update -y

これから頻繁に使う「yum」コマンドのアップデート
  ※「-y」のオプションは実行の確認全てに「YES」と答える設定

[ec2-user@ip-・・・・・・・・・・・~]$ sudo yum install -y httpd24 php70 mysql-server php70-mysqlnd php70-mbstring

httpd24:Apache本体
php70:PHP本体
mysql-server:MySQL(DB)本体
php70-mysqlnd:MySQLドライバ phpMyAdminなどでMySQLに接続する為に必要

上記をインストール

確認

インストールができているか確認

Apache

[ec2-user@ip-・・・・・・・・・・・~]$ httpd -v
Server version: Apache/2.4.33 (Amazon)
Server built:   May 23 2018 19:02:39

PHP

[ec2-user@ip-・・・・・・・・・・・~]$ php -v
PHP 7.0.30 (cli) (built: May 10 2018 17:39:13) ( NTS )
Copyright (c) 1997-2017 The PHP Group
Zend Engine v3.0.0, Copyright (c) 1998-2017 Zend Technologies

MySQL

[ec2-user@ip-・・・・・・・・・・・~]$ mysql --version
mysql  Ver 14.14 Distrib 5.5.60, for Linux (x86_64) using readline 5.1

起動

Apache

[ec2-user@ip-・・・・・・・・・・・~]$sudo service httpd start
                                              [  OK  ]

WebブラウザからEC2のIPアドレスにアクセスして、Apacheのテストページが表示されていればOK

PHP

[ec2-user@ip-・・・・・・・・・・・~]$sudo vi /var/www/html/phpinfo.php

viコマンドでphpファイルを作成及び、編集

<?php
   echo phpinfo();
?>

phpinfo.phpに上記を記載
AWSコンソールでセキュリティグループの設定し

Webブラウザにて、EC2のIPアドレス/phpinfo.phpにアクセス
phpinfoの表示を確認する

PHPの設定をしたい場合はこちら

MySQL

[ec2-user@ip-・・・・・・・・・・・~]$sudo service mysqld start
Starting mysqld:                                [  OK  ]

MySQLを起動

[ec2-user@ip-・・・・・・・・・・・~]$mysql -u root -p



 mysql >

MySQLにログイン
左側がmysqlになっていれば、ログイン状態

 mysql >exit
Bye

MySQLよりログアウト
左側が[ec2-user@ip-・・・・・・・・・・・~]になっていればOK


以上でLAMP環境の構築は完了
ここにあるのは最低限の設定でのインストールと起動確認だけ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Docker 環境構築(php+nginx+mysql+memcached)

ディレクトリ構成

---docker
|--docker-compose.yml
|--docker
|  |--nginx
|  |    |--Dockerfile
|  |    |--nginx.conf
|  |    |--conf.d
|  |         |--default.conf
|  |
|  |--phpfpm
|  |    |--Dockerfile
|  |    |--php.ini
|  |
|  |--mysql
|  |    |--Dockerfile
|  |    |--my.cnf
|  |
|  |--memcached
|       |--Dockerfile
|
|--volumes
   |--db
   |  |--data
   |
   |--logs
   |--nginx
   |--www
      |--html
      |--webcore

docker-compose.ym

FROM php:7.3-fpm-alpine

RUN apk update \
&& apk add \
    autoconf \
    vim \
    git \
    zip \
    gcc \
    g++ \
    make \
    libmemcached-dev \
    zlib-dev \
    freetype-dev \
    libjpeg-turbo-dev \
    libpng-dev \
    libmcrypt-dev \
    postgresql-dev \
&& pecl install memcached \
&& docker-php-ext-install \
    mbstring \
    json \
    exif \
    mysqli \
    pdo_mysql \
    gd \
    pgsql \
    pdo_pgsql \
    hash

nginx

:latestと:alpineの違い
:latest → bash/shが使用可能
:alpine → shのみ使用可能

Dockerfile

FROM nginx:alpine

conf.d/default.conf

mysql

Dockerfile

#イメージ指定
FROM mysql:5.7

my.cnf

[mysql]
default-character-set=utf8mb4

[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
explicit_defaults_for_timestamp=1
default-time-zone=Asia/Tokyo
max_allowed_packet=32M
skip-symbolic-links=1

[client]
default-character-set=utf8mb4

phpfpm

Dockerfile

FROM php:7.3-fpm-alpine

RUN apk update \
&& apk add \
    autoconf \
    vim \
    git \
    zip \
    gcc \
    g++ \
    make \
    libmemcached-dev \
    zlib-dev \
    freetype-dev \
    libjpeg-turbo-dev \
    libpng-dev \
    libmcrypt-dev \
    postgresql-dev \
&& pecl install memcached \
&& docker-php-ext-install \
    mbstring \
    json \
    exif \
    mysqli \
    pdo_mysql \
    gd \
    pgsql \
    pdo_pgsql \
    hash

php.ini

[Date]
date.timezone = "Asia/Tokyo"

[mbstring]
mbstring.internal_encoding = "UTF-8"
mbstring.language = "Japanese"

[extension]
enabled_dl = On

[memcached]
extension=memcached.so

php-fpm.conf

memcached

Dockerfile

FROM memcached:latest
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

EC2のLaravel6.0環境 VueとLaravelでのTIPS AWS/Laravel連載(10.5)

LaravelのシーディングにFakerを入れる

連載9回目で初期値でhoge, fugaという値を入れました。
実際テストする中だといちいちダミーの名前やタイトル、文章等を考えるのは一苦労です。

そこで便利なのがFaker。
Laravelだと最初からcomposer.jsonに入っているので、install不要です。

そしてシーディングだけでなく、Laravelのfactoryという仕組みを作って汎用性のある書き方をしてみます。

$ php artisan make:factory PostFactory --model=Post
database/factories/PostFactory.php
<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Post;
use Faker\Generator as Faker;

$factory->define(Post::class, function (Faker $faker) {
    return [
        'title'   => $faker->sentence,
        'content' => $faker->paragraph,
    ];
});
database/seeds/PostsTableSeeder.php
...
     public function run()
     {
        $posts = factory(App\Post::class, 3)->create();
     }
$ php artisan db:seed

これで3件分のダミーデータが生成されます。

php artisan tinker
Psy Shell v0.9.9 (PHP 7.2.22  cli) by Justin Hileman
>>> Post::all();
[!] Aliasing 'Post' to 'App\Post' for this Tinker session.
=> Illuminate\Database\Eloquent\Collection {#3084
     all: [
       App\Post {#3085
         id: 1,
         title: "hoge",
         content: "fuga",
         created_at: null,
         updated_at: null,
       },
       App\Post {#3086
         id: 2,
         title: "Repellendus odit qui facilis ea sint.",
         content: "Est eos sed amet quibusdam. Et voluptatem voluptatem et accusantium qui.",
         created_at: "2019-10-12 21:05:10",
         updated_at: "2019-10-12 21:05:10",
       },
       App\Post {#3087
         id: 3,
         title: "Neque rerum ut molestiae aut sequi.",
         content: "Rem exercitationem doloremque facilis et sed qui blanditiis. Dolorem ea quo in voluptatem. Distinctio tempora dolor culpa reprehenderit fuga voluptas omnis. Animi voluptas repudiandae rem quisquam eligendi dolores. Quibusdam iste aut possimus.",
         created_at: "2019-10-12 21:05:10",
         updated_at: "2019-10-12 21:05:10",
       },
       App\Post {#3088
         id: 4,
         title: "Soluta est iure ex sequi a aspernatur.",
         content: "Eos et voluptatem sint tempore. Rerum voluptates quis est fugit voluptas sit voluptatem consequuntur. Voluptas sapiente illo quo optio.",
         created_at: "2019-10-12 21:05:10",
         updated_at: "2019-10-12 21:05:10",
       },
     ],
   }
>>>

GitHub - fzaninotto/Faker: Faker is a PHP library that generates fake data for you

Vue開発の際は

連載10回目でVue.jsを入れました。
デバッグはChromeの開発者ツールだけだといろいろしんどいので、Vue.jsのdevtoolsを入れるのをオススメします。

Vue.js devtools

入れるとこんな感じで、Chromeの開発者ツールに「Vue」タブが追加され、開発が捗ります。

vue.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

EC2のLaravel6.0環境でVue+Rest APIを連携する AWS/Laravel連載(10)

はじめに

前回の記事でLaravelでREST APIを作りました。

EC2のLaravel6.0環境でRest APIを準備する AWS/Laravel連載(9)

今回はAPI経由で投稿一覧表示部分をVue.js+axiosで実装します。

Vue, axiosを入れる

Laravelのpackage.jsonは最初からVue.jsとaxiosが入っているのでnpm installだけでOKです。

$ npm install

Vue部分を書きます。

resources/js/app.js
...
const app = new Vue({
  el: '#app',
  data: {
    posts: []
  },
  methods: {
    fetchPosts: function(){
      axios.get('/api/posts').then((res)=>{
        this.posts = res.data
      })
    }
  },
  created() {
    this.fetchPosts()
  },
});

上記保存したらビルドが必要です。

$ npm run dev

終わるとpublic/js/app.jsに書き出されます。

viewを修正

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

                    @{{ posts.total }}
                    <table class="table">
                        <thead>
                            <tr>
                            <th>ID</th>
                            <th>タイトル</th>
                            <th>本文</th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr v-for="post in posts.data" v-bind:key="post.id" v-cloak>
                                <td>@{{ post.id }}</td>
                                <td>@{{ post.title }}</td>
                                <td>@{{ post.content }}</td></td>
                            </tr>
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

v-forはVue.jsでのfor文です。
app.jsの中で、APIから取ってきた投稿一覧(ページャー付き)をpostsという変数に入れています。
posts.dataの中身(配列)をループしpostという変数に入れ、件数分を表示しています。

連載10.5回という形で、Vueの開発がしやすくなるツールを紹介しています。
ぜひご確認ください。
EC2のLaravel6.0環境 VueとLaravelでのTIPS AWS/Laravel連載(10.5)

この記事の参考:
Laravel × Vue.js × axiosでTODOリストを作るよ! その1

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

EC2のLaravel6.0環境でRest APIを準備する AWS/Laravel連載(9)

はじめに

前回の記事でBladeテンプレートを学びました。

EC2のLaravel6.0環境のBladeテンプレートの基礎を学ぶ AWS/Laravel連載(8)

今回はRest APIを作ります。
ちなみに次回でVue.jsを使いテンプレートに表示をします。

モデル、コントローラー、マイグレーションファイルを作る

$ php artisan make:model -mcr Post

上記コマンドで、

  • app/Post.php(モデル)
  • app/Http/Controllers/PostController.php(コントローラー)
  • database/migrations/20xx_xx_xx_xxxxxx_create_posts_table.php(マイグレーションファイル)

が出来上がります。

ルーティングの追加

routes/api.phpに以下1行を追加します。

routes/api.php
Route::apiResource('posts', 'PostController');

するとAPI用のルーティングが作られます。

$ php artisan route:list --path=posts
+--------+-----------+------------------+---------------+---------------------------------------------+------------+
| Domain | Method    | URI              | Name          | Action                                      | Middleware |
+--------+-----------+------------------+---------------+---------------------------------------------+------------+
|        | GET|HEAD  | api/posts        | posts.index   | App\Http\Controllers\PostController@index   | api        |
|        | POST      | api/posts        | posts.store   | App\Http\Controllers\PostController@store   | api        |
|        | GET|HEAD  | api/posts/{post} | posts.show    | App\Http\Controllers\PostController@show    | api        |
|        | PUT|PATCH | api/posts/{post} | posts.update  | App\Http\Controllers\PostController@update  | api        |
|        | DELETE    | api/posts/{post} | posts.destroy | App\Http\Controllers\PostController@destroy | api        |
+--------+-----------+------------------+---------------+---------------------------------------------+------------+

マイグレーションファイルの修正

xx部分には作成日時が入るので適宜読み換えてください。

20xx_xx_xx_xxxxxx_create_posts_table.php
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('title');
            $table->text('content');
            $table->timestamps();
        });
    }

title, contentという2カラムを追加した状態でテーブルを作ります。
timestampsはcreated_at, updated_atという2カラムが自動で追加され、レコード生成日時がcreated_atに、最終更新日時がupdated_atに入るようになります。原則入れることになるかと思います。

$ php atrisan migrate

シーディングの設定

テーブルはできましたがレコードがありません。
動作確認がしづらいので、初期レコードを生成しましょう。

$ php artisan make:seeder PostsTableSeeder
database/seeds/PostsTableSeeder.php
<?php

use Illuminate\Database\Seeder;

class PostsTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        DB::table('posts')->insert([
            'title' => 'hoge',
            'content' => 'fuga',
        ]);
    }
}
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(PostsTableSeeder::class);
    }
}
$ php artisan db:seed
Seeding: PostsTableSeeder
Database seeding completed successfully.

レコードが発行されたかtinkerで調べてみましょう。

$ php artisan tinker
>>> Post::all();
[!] Aliasing 'Post' to 'App\Post' for this Tinker session.
=> Illuminate\Database\Eloquent\Collection {#3087
     all: [
       App\Post {#3088
         id: 1,
         title: "hoge",
         content: "fuga",
         created_at: null,
         updated_at: null,
       },
     ],
   }

hoge, fugaという決め打ちではなく、FakerとFactoryという仕組みでダミーデータを生成する方法も別記事で紹介しています。
気になる方は以下記事を参照してください。
EC2のLaravel6.0環境 VueとLaravelでのTIPS AWS/Laravel連載(10.5)

コントローラーの設定

<?php

namespace App\Http\Controllers;

use App\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        return Post::latest()->paginate();
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        return Post::create($request->all());
    }

    /**
     * Display the specified resource.
     *
     * @param  \App\Post  $post
     * @return \Illuminate\Http\Response
     */
    public function show(Post $post)
    {
        return $post;
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Post  $post
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Post $post)
    {
        $post->update($request->all());
        return $post;
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  \App\Post  $post
     * @return \Illuminate\Http\Response
     */
    public function destroy(Post $post)
    {
        $deleted = $post->delete();
        return compact('deleted');
    }
}

LaravelはEloquentオブジェクトをreturnするとjsonで返してくれる模様。
上記設定ができたら、Webブラウザでアクセスしてみます。
http://【EC2インスタンスのIP】/api/posts

{"current_page":1,"data":[{"id":1,"title":"hoge","content":"fuga","created_at":null,"updated_at":null}],"first_page_url":"http:\/\/【EC2インスタンスのIP】\/api\/posts?page=1","from":1,"last_page":1,"last_page_url":"http:\/\/【EC2インスタンスのIP】\/api\/posts?page=1","next_page_url":null,"path":"http:\/\/【EC2インスタンスのIP】\/api\/posts","per_page":15,"prev_page_url":null,"to":1,"total":1}

参考:
Laravel × Vue.js × axiosでTODOリストを作るよ! その1

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

エビ卵MAPを作ってみた

孤独のグルメSeason8は中華で始まりましたね。中華料理店の時代です。

ところで、勤め先の会社に無類のエビ卵好きがいまして、お客先等に行く時の昼ごはんは、必ず中華屋でエビ卵炒め定食を食べています(先日は週5日エビ卵コンプリートしたと言って喜んでいました)。
変わり者だなぁと思っていたのですが、この前配属された新人ちゃんもエビ卵大好きと判明。
もしかして、エビ卵市場はとてつもなく大きいかもしれない!
ということで作成したWebアプリが、エビ卵特化型中華料理店マップ。
えびたまっぷ

スクリーンショット 2019-10-09 22.47.17.png

以下、作り方と課題などについて記したいと思います。

システム基盤

開発フレームワーク(言い方が古い?アーキテクチャ?)に何を使うかという話。
このアプリではWordPressサイトを立てて、ページテンプレートとしてアプリを作り、このページテンプレートを読み込む形で固定ページを作る、という方法を採っています。

HeadlessCMS ならぬ HeadonlyCMS

最近はHeadlessCMSという考えを目にしますが、それと真逆の方法です。
すなわち、HeadlessCMSは、コンテンツ作成のしやすさの部分でCMSを活用し、フロントのデザインや機構については独自に作り込もうという話かと思います。
しかし、私の場合、デザインがどうにも苦手、という弱点があり、その部分をカバーする方法として、CMS(のテーマ)を活用したい、という思いがあります。コンテンツの内容はアプリとして作成してしまうので、CMSの機能は全く使わないのですが、ヘッダ・フッタの読み込みや、全体の統一感を出すためのCSSの活用等が可能になります。
さらにもう一つの理由として、アプリを書く際にWordPress内で定義されている関数が使えるということもあります。
関数リファレンス

と言いつつ、実は、今回は、デザインはほとんどテーマの定義は使用しませんでした。
参考にさせていただいた情報で綺麗なCSSが提供されていたからです。
ただ、このアプリの前に作った別のアプリでは、テーマの定義を活用しています。
タイタニック チャレンジ
テーマのデザインを生かしつつ、作り込む部分のデザインもしやすいように、Bootstrap対応をうたっているLightningを使用しました。

以上の、WordPress基盤の使用についは、こちらにも、書いています。
Watson Machine Learning を活用してWordPress サイト内にアプリ作成

WordPressでのページテンプレートの作り方

直接テーマファイルに手を入れるのは推奨されておらず、子テーマを作ります。
子テーマの作り方は、WordPressとして定まっていますが、Lightning
の場合は子テーマ用のデータが提供されているので、それを使用します。
wp-content/themes/lightning
のようなフォルダ構成になっていると思いますので、それと並べて、子テーマディレクトリを作成します。私の場合は今回は、
wp-content/themes/jqapp
となります。そして、その中には、style.css、functions.php、_sidebar-event.php、_module_loop_event.php が含まれる形となっています。
ここに、アプリ用のファイル、ebitamago.php を作成します。
その先頭に、

ebitamago.php
<?php
/**
 * Template Name: ebitamago
 */
?>

を書くことで、テンプレートとして認識され、WordPress管理画面で固定ページを作成する際の「テンプレート」の選択肢に「ebitamago」表示されるようになります。

仮に、ebitamago.php の内容がこれだけの状態で、テンプレートとしてebitamagoを洗濯した固定ページを表示すると、何も表示されません。
若干追記して、

ebitamago.php
<?php
/**
 * Template Name: ebitest
 */
?>
<?php get_header(); ?>

<?php get_template_part( 'module_pageTit' ); ?>
<?php get_template_part( 'module_panList' ); ?>

とすると、ヘッダ部、タイトル、パンくずリストがそのテーマ定義に従って表示されます。
さらにその下に、フォームを作ったり、リストを作ったりするわけですが、テーマによって、CSSの階層構造を見たりしているので、それは、そのテーマの標準に従う必要があります。
lightningで言えば、
wp-content/themes/lightning/page.php
あたりを参照すれば、どのような構成になっているかがわかると思うので、必要な部分をコピーしたりします。

上記のように、今回はあまりテーマを生かしたデザインにはしなかったものの、構成はそれなりに真似して、下記のような形にしました。

ebitamago.php
<?php
/**
 * Template Name: ebitest
 */
?>
<?php get_header(); ?>

<div class="section siteContent">
<div class="container">
<div class="row">
<div class="col-md-12 mainSection" id="main" role="main">

ここにアプリのコントロール等を配置

</div><!-- [ /.mainSection ] -->
</div><!-- [ /.row ] -->
</div><!-- [ /.container ] -->
</div><!-- [ /.siteContent ] -->

店舗情報の取得

今回やりたいのは、まず、現在地の近くに存在する中華屋さんの情報を地図上に表示することです。
日本の飲食店情報のデータベースといえば、やはり、ぐるなび、ホットペッパー、食べログあたりを思いつきますね。
それらのAPIを使用させていただくことを検討します。

まず、食べログはAPIの提供を停止してしまったようです。

次に検討したのは、ホットペッパーAPIです。
ホットペッパー | APIリファレンス | リクルートWEBサービス
このページを見てもわかるように、リクルートが提供してくれているAPIは多数あります。以前、別のAPIも使ったことがあったので、最初は、こちらで作り始めました。
が、思った店が検索されません。
思うに、ホットペッパーは、予約できる店にかなり特化しているように思います。そのため、町中華のような店が入っていないのではないかと思われます。

そこで、今回は、ぐるなびのAPIを使わせていただくことにしました。
そして、ぐるなびAPIによる店舗情報の取得と、Google Map への表示については、下記を全面的に参照させていただきました(以下、「参考サイト」)。
【javascript】現在地の近くのWifiのあるカフェを探すアプリ

ぐるなびAPI

ぐるなび Web Service

レストラン検索APIの利用自体については、APIテストツールなども提供されていたりして、さほど難しくないと思うので割愛します。
要は、以下のURL文字列を呼び出します。
"https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=(自身のアクセスキー)&latitude="+lat+"&longitude="+lng+"&hit_per_page=30&range=2&category_l=RSFST14000"
RSFST14000というのが、中華料理店カテゴリになります。1回の呼び出しで30件まで取得します。デフォルトの10件だと少なく感じるのですが(かくも中華料理店は多い)、一気に30件以上表示させても見るのも大変でしょうから。

問題は、これをどう呼び出すかです。

WordPressアプリ(ページテンプレート)からのjQueryのAjax

本件参考サイトでは、jQueryのAjax呼び出しを使用しています。下記は一部抜粋ですが、全体ソースは参考サイトをご覧ください。

 $.ajax({
  type : "get",
  url  : "https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=【ぐるなびアクセスキー】&wifi=1&latitude="+lat+"&longitude="+lng+"&range=4&category_l=RSFST20000,RSFST18000",
  dataType : 'json',
  success  : function(json){

ただ、この$.ajax、WordPress内ではそのままでは使えません。

jQuery(document).ready(function ($) {

の中に入っている必要があります。ここは情報探すのに時間がかかりました。

GoogleMap への表示

地図の表示には、Google Maps API を使用します。
取ってきた情報に緯度・経度も含まれるので、それを使用して、マーカーを立てていきます。
この辺は、ほとんど参考サイトのまま使わせていただいていますが、いくつか改変しました。

まず、地図移動に対応させます。参考サイトでは、現在地の情報を表示させるのみで、地図を移動してそのエリアで再度検索ということはしていませんが、その機能をつけたいと思いました。
移動の検出は、色々調べてみると、idleイベントが良さそう。
これの書きどころは、非常に苦戦しましたが、

ebitamago.php
    map.addListener('idle', function(e) {
        if (map.getCenter() === undefined){
            return;
        } else {
            set_map(map.getCenter().lat(), map.getCenter().lng());
        };
    });

を、jQuery(document).ready(function ($) { のブロックの最後に書きました。
ポイントは、map自体を非同期で作成しようとしているので、mapが作成される前にmap.addListener を呼び出すことはできない、というところでしょうか。ですので、非同期処理の最後にこれを行う、という形です。
なお、set_mapというのは、参考サイトのget_posの内容を切り出して別functionにしたものです。その中で、ぐるなびAPIを呼び出して、地図にマーカーを立てて、店リストを作る、ということをやっています。

また、マーカーをクリックした際に、参考サイトではページ遷移する形ですが(現在地のみの表示ならそれで良いわけですが)、別ウインドウを立ち上げる形(window.open)にしました。地図を移動して、移動先のマーカーをクリックした後で、戻ってきたらまた現在地の表示になってしまうとちょっとがっかりなので。

参考サイト
google.maps.event.addListener(marker, 'click', (function(url){
    return function(){ location.href = url; };
})(json.rest[i].url));
ebitamago.php
google.maps.event.addListener(marker, 'click', function() {
    window.open(json.rest[i].url);
});

場所検索機能

今現在(2019年10月)熱い(?)キャッシュレス・ポイント還元制度。対象店を探す経済産業省のアプリが話題になりました。ブラウザ版はこちらです。
が、これが残念なことに、場所検索機能がついていない。初期表示で現在地が表示されますが、別の場所の店を探そうと思うと、一生懸命地図をドラッグさせていかないといけない。それはつらいよ・・・
と、言いながら、本アプリも最初に冒頭のエビ卵好きさんに見せたときのバージョンではそうなっていました。恥ずかしい汗をかきながら、すかさず場所検索機能を追加しましたので、是非経済産業省におかれましても、ご検討ください。

基本的には、Places Search Boxの使い方に従うのみです。
まずは、地図領域の直前が分かりやすいと思いますが、inputタグを置きます。下記の、id="pac-input"です。

ebitamago.php
<!-- Map appears here -->
<div id="content">
    <div class="title">
        <img src='https://jqselect.sakura.ne.jp/apps/wp-content/uploads/2019/10/エビ卵-MAP.png' />
    </div>
    <input id="pac-input" class="controls" type="text" placeholder="駅名等で検索..." style="width:100%">
    <div id="map" class="map"></div> <!--mapを表示-->
    <ul id="shop-list"></ul> <!--お店の詳細情報を表示-->
</div>

<script defer src="https://maps.googleapis.com/maps/api/js?key=(APIキー)&libraries=places&callback=initMap">
</script>

ここで、google maps api を読み込んでいる最後の部分で、コールバック処理として、initMapを定義しています。
その処理の中で、地図とinputを結び付けるわけです。

ebitamago.php
var map;
var markers;
function initMap() {
    // Set the default location and initialize all variables
    map = new google.maps.Map(document.getElementById('map'), {
        // center: pos,
        zoom: 15,
        mapTypeControl: false
    });

    markers = new google.maps.MVCArray();

    var input = document.getElementById('pac-input');
    var searchBox = new google.maps.places.SearchBox(input);

    // Listen for the event fired when the user selects a prediction and retrieve
    // more details for that place.
    searchBox.addListener('places_changed', function() {
        var places = searchBox.getPlaces();

        if (places.length == 0) {
            return;
        }

        // Clear out the old markers.
        markers.forEach(function (marker, idx) { marker.setMap(null); });
        markers = [];

        // For each place, get the icon, name and location.
        var bounds = new google.maps.LatLngBounds();
        places.forEach(function(place) {
        if (!place.geometry) {
            console.log("Returned place contains no geometry");
            return;
        }

        // Create a marker for each place.
        markers.push(new google.maps.Marker({
            map: map,
            // icon: icon,
            // title: place.name,
            position: place.geometry.location
        }));

        if (place.geometry.viewport) {
            // Only geocodes have viewport.
            bounds.union(place.geometry.viewport);
        } else {
            bounds.extend(place.geometry.location);
        }
        });
        map.fitBounds(bounds);
    });

メニュー情報の取得

色々書きましたが、ここまでは、ある意味本件参考サイトの微修正レベルではあります。
これから書くメニュー情報の取得・表示こそがこのアプリの独自の部分になります。

そのお店に、エビ卵のメニューが存在するか、残念ながらAPIではメニュー情報は提供されていません。
そこで、スクレイピングさせていただくことにします。
そう思った場合に、ぐるなびサイトは、ある程度メニューページが構造化されていて助かります(ただし、ある程度ですが)。
各店舗のページは、
https://r.gnavi.co.jp/(店舗ID)
という形になっています。
その下に、menu1、menu2・・・というページがあります。ただし、これは何ページ目まであるか分かりません。
メニューページでは、各料理が<li class="menu-item ・・・>または<li class="menu-vitem ・・・>というリストで列挙されます。画像付きの場合はv-itemのようです。
そのリストの中で、<dt class="menu-term">の中に料理名が記載され、さらに下記のような構造の中に価格が記載されます。

ぐるなびのメニューページの抜粋
<li class="menu-vitem cx ">
    <dl>
        <dt class="menu-term">
            エビと玉子の炒め
        </dt>
        <dd class="menu-img t4">
            <a href="//uds.gnst.jp/rest/img/gaamzees0000/s_0n7d.jpg?t=1495128155" class="gallery cboxElement" title="エビと玉子の炒め">
                <p class="figure"><img src="//uds.gnst.jp/rest/img/gaamzees0000/t_0n7d.jpg?t=1495128155&amp;g=157" width="157" height="157" alt="エビと玉子の炒め" title="エビと玉子の炒め"></p>
                <span class="gallery-zoom"><span class="icon-zoom"></span></span>
            </a>
        </dd>
        <dd class="menu-desc"><ul class="menu-plan-icons cx"></ul></dd>
        <dd class="menu-price">
            <table><tbody>
                <tr>
                    <th></th>
                    <td>880円<span>(税込)</span></td>
                </tr>
            </tbody></table>
        </dd>
    </dl>
</li>

ここから写真、名前、価格を取り出します。

CORS回避のためのAPI化

まず、方式として、上記で情報を取得した各店舗のメニューを取得するので、Javascriptでスクレイピングをすることになります。
Javascriptでリアルタイムでスクレイピングする方式は、下記を参考にさせていただきました。
jQuery.ajaxでWebスクレイピングを実装してみた - メン醤のjQuery workshop
基本的にはそれで取得できることは確認できたのですが、ぐるなびページを取得しようとすると失敗します。CORSの話です。これ自体の詳細は割愛します。
上記のサイトで乃木坂のページを取得できているのは、乃木坂ページがCORS対応しているからとしか考えられませんが、そういうものなのでしょうか??

で、ぐるなびが対応していない以上、こちらからはどうしようもないということで、他の方法を考えたりもしましたが、結論としては、今アプリを作っているWordPressサイト内に、もう一つAPIアプリを立てる。これにより、本アプリは同一ドメインのAPIにアクセスする形を取れます。
そして、APiアプリが、実際にスクレイピングを行うという構成にしました。

上記のように、
wp-content/themes/jqapp
に、アプリ自体のテンプレートファイルであるebitamago.phpを作成していますが、これと並べて、ebitamago-api.phpを作成します。

phpでのスクレイピング

このebitamago-api.phpの中で、メニューページの内容を取得します。
phpでwebページの情報を取得する方法として、phpQueryが簡単そうということで、こちらなどを参考にさせていただきました。

phpQuery-onefile.phpが提供されているので、これを参照すれば良いわけですが、WordPressページから呼び出すには

ebitamago-api.php
require_once(dirname(__FILE__)."/phpQuery-onefile.php");

とする必要があります。

ebitamago.phpからリクエスト引数として店舗IDを渡す形として、api側では、その店舗IDのページのメニューページを呼び出します。上記のようにメニューページが何ページあるかわからないので、menu1からmenu5でループします。こういう無駄なアクセスは申し訳ないのですが。しかも、画像の有無でmenu-itemかmenu-vitemかが変わるということで、両方の取得を試みます。ダサいですけど、すみません。
不要ですが、参考ページの痕跡もコメントで残しています。

ebitamago-api.php
require_once(dirname(__FILE__)."/phpQuery-onefile.php");

$restid = $_GET["restid"];
$ebitamago = [];

for($val = 1; $val <= 5; $val++){
    $html = file_get_contents("https://r.gnavi.co.jp/".$restid."/menu".$val."/");

    $doc = phpQuery::newDocument($html);

    foreach($doc[".menu-vitem"] as $row)
    {
        $name = trim(pq($row)->find(".menu-term")->text());


        if( (strpos($name, 'えび') !== false || strpos($name, 'エビ') !== false || strpos($name, '海老') !== false || strpos($name, '蝦') !== false )
            && (strpos($name, 'たまご') !== false || strpos($name, '卵') !== false || strpos($name, '玉子') !== false || strpos($name, 'タマゴ') !== false )){

                //各要素取得
                $key   = trim(pq($row)->find(".menu-term")->text());
                $value = 'https:' . pq($row)->find("img")->attr("src");

                //表示
                // echo $key . "-" . $value . "<br>";
                // echo pq($row)->text();

                $arr["menu_term"] = trim(pq($row)->find(".menu-term")->text());
                $arr["img_src"] = pq($row)->find("img")->attr("src");
                $arr["menu_price"] = trim(pq($row)->find(".menu-price")->text());

                array_push($ebitamago, $arr);


        }
    }
    foreach($doc[".menu-item"] as $row)
    {
        $name = trim(pq($row)->find(".menu-term")->text());
        // echo $name;

        if( (strpos($name, 'えび') !== false || strpos($name, 'エビ') !== false || strpos($name, '海老') !== false || strpos($name, '蝦') !== false )
            && (strpos($name, 'たまご') !== false || strpos($name, '卵') !== false || strpos($name, '玉子') !== false || strpos($name, 'タマゴ') !== false )){

                $arr["menu_term"] = trim(pq($row)->find(".menu-term")->text());
                $arr["img_src"] = pq($row)->find("img")->attr("src");
                $arr["menu_price"] = trim(pq($row)->find(".menu-price")->text());

                array_push($ebitamago, $arr);


        }
    }

}


$json = json_encode($ebitamago);

// JSON用のヘッダを定義して出力
header("Content-Type: application/json; charset=utf-8");
echo $json;
exit();

これを作って、また固定ページのテンプレートに設定して固定ページをebitamago-apiとしておけば、アプリ側では下記のような呼び出しを行って情報を取得することができます。

ebitamago.php
var getEbitamago = (function(restid) {
    var menuurl = '../ebitamago-api/?restid='.concat(restid).concat('');

    $.ajax({
        type: 'GET',
        url: menuurl,
    }).done(function (data) {

        if (data == null){
            return;
        }

        ebitamagoOgj = data;
        $li = '';
        ebitamagoOgj.forEach(ebitama => {

            $li = $li + '<span class="ebitamadata">';
            if (ebitama.img_src != '') {
                $li = $li 
                    +   '<img class="ebitamago-img" src=' + ebitama.img_src + '>';
            }
            $li = $li 
                +   '<span class="ebitamago-name">' + ebitama.menu_term + '</span>'
                +   '<span class="ebitamago-price">' + ebitama.menu_price + '</span>';                
            $li = $li + '</span>';
        });
        $("#ebitamago" + restid + "").html($li);

    }).fail(function(jqXHR, textStatus, errorThrown){
        console.log('ajax fail' + jqXHR.status);
    });
});

スクレイピングを行う際の注意事項

ところで、スクレイピングを行う場合の注意事項として、こちらがよく参照されています。
Webスクレイピングの注意事項一覧
相手側サイトの負荷に配慮するため、及び、法律違反を回避するためにまとめていただいているもので、他にあまり情報がないために貴重な情報となっていると思います。

その中でちょっと気になったのが、「サーバアクセスの間隔を1秒以上空けるようにする。」という記述です。
これに関しては、このような情報もありました。
Webスクレイピングする際のルールとPythonによる規約の読み込み
要するに、1秒だろうが何秒だろうが、相手サーバに負荷をかけない注意が必要ということかと思います。
ですので、まずは、負荷をかけないようにapiがメニューページを見に行く間隔を開けるようにします。
方法は、こちらを参考にさせていただきました。
JQuery.Deferredでwait風メソッド
これをやっておいて、先ほどのapi呼び出し用のgetEbitamagoファンクションを、こんな感じで呼び出すようにしました。

ebitamago.php
$.wait(i*500).done(function() {
    getEbitamago(json.rest[i].url.substring(22, json.rest[i].url.indexOf('?') -1));
});

22とか直書き・・・そして、自分の都合の良いように解釈して、0.5秒ごとのアクセス間隔(しかもその中でメニューページ5回アクセスしている)。

robot.txtに関しては、ぐるなびは提供していないように思うのですが、いかがでしょうか?(そんなこともないのでは、とも思いますが、お探しのページは見つかりませんになってしまうので)

完成

以上で出来上がると思います。ebitamago-api.phpの方はほぼ全部ですが、ebitamago.phpの方はあれこれ汚いので、つぎはぎの抜粋ですみません。

ハマったことや課題など

も、書こうと思ったのですが、たいがい長くなってるし、一回公開してみたいので、一旦締めます。
その辺はブログの方か、追記するか、追々考えますということで。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PHPで【いいねボタン】を実装してみた

最初に

現在通っているプログラミングスクールのPHPカリキュラムの中で教材(市販の入門書)に沿って簡易掲示板を作成。
その簡易掲示板を元に自分で考えて【いいねボタン】機能を追加してみました。

学習後のアウトプットを目的としており、教材の入門書一冊分の知識だけです。
フレームワークは使っていません。
元となる書籍の内容には触れていませんので、同じ書籍を見ていない人には分かりづらいと思います。
きっと…いや、間違いなくもっと綺麗なコードがあるはずですが、同じ初学者の方の参考になれば幸いです。

※教材はこちらです→よくわかるPHPの教科書 【PHP7対応版】

完成イメージ

今回はいいねボタンを簡単なハート文字にしました。
image.png

image.png

DB

既存のテーブルはこちら

membersテーブル
image.png

postsテーブル
image.png

【いいねボタン】機能のために追加したテーブル

likesテーブル
image.png

1. いいねボタンをクリックした時にDBにデータを挿入or削除する

1-1. いいねボタン(仮)を用意する

index.php
<a class="heart" href="index.php?like=<?php echo h($post['id']); ?>">&#9825;</a>

表示部分です。
URLパラメータのlikeに$post['id']を使います。
$post['id']はpostsテーブルのidを取得した値になっています。

いいねボタンをクリックすると$_REQUEST['LIKE']$post['id']の値が入ります。

1-2. いいねを押したメッセージの投稿者を調べる

index.php
if (isset($_REQUEST['like'])) {

  //いいねを押したメッセージの投稿者を調べる
  $contributor = $db->prepare('SELECT member_id FROM posts WHERE id=?');
  $contributor->execute(array($_REQUEST['like']));
  $pressed_message = $contributor->fetch();

isset()で①の$_REQUEST['LIKE']に値が入ったか確認しtrueであれば、いいねを押したメッセージの投稿者を調べます。

1-3. いいねを押した人とメッセージ投稿者が同一人物でないか確認

index.php
//いいねを押した人とメッセージ投稿者が同一人物でないか確認
  if ($_SESSION['id'] != $pressed_message['member_id']) {

$_SESSION['id']はログインした人のmembersテーブルidの値が入っています。

ここでログインしている人が自分の投稿にはいいねを押せないようにします。

1-4. 過去にいいね済みであるか確認

index.php
    //過去にいいね済みであるか確認
    $pressed = $db->prepare('SELECT COUNT(*) AS cnt FROM likes WHERE post_id=? AND member_id=?');
    $pressed->execute(array(
      $_REQUEST['like'],
      $_SESSION['id']
    ));
    $my_like_cnt = $pressed->fetch();

likeテーブルにログイン者が同じメッセージにいいねしてあるかCOUNTで確認。

1-5. いいねのデータを挿入or削除

index.php
//いいねのデータを挿入or削除
    if ($my_like_cnt['cnt'] < 1) {
      $press = $db->prepare('INSERT INTO likes SET post_id=?, member_id=?, created=NOW()');
      $press->execute(array(
        $_REQUEST['like'],
        $_SESSION['id']
      ));
      header("Location: index.php");
      exit();
    } else {
      $cancel = $db->prepare('DELETE FROM likes WHERE post_id=? AND member_id=?');
      $cancel->execute(array(
        $_REQUEST['like'],
        $_SESSION['id']
      ));
      header("Location: index.php");
      exit();
    }

④で調べた$my_like_cnt['cnt']の値で分岐させて、挿入か削除をする。

これでいいねボタンを1回クリックするとlikeテーブルにデータが挿入され、もう一度クリックすると今度は削除されます。(DBで確認しましょう)

2. いいねボタンのハートの表示を変化させる

2-1. ログインしている人がいいねしたメッセージをすべて取得

index.php
//ログインしている人がいいねしたメッセージをすべて取得
$like = $db->prepare('SELECT post_id FROM likes WHERE member_id=?');
$like->execute(array($_SESSION['id']));
while ($like_record = $like->fetch()) {
  $my_like[] = $like_record;
}

ここは取り出すレコードが複数になることがあるので、while文で一つずつ値を取り出します。
取り出した値はもともと配列になっているので、さらに配列(二次元配列)として変数$my_like[]に代入します。

//$my_likeの中身(例)

     Array(
      [0] => Array(
            [post_id] => 17
            [0] => 17
            )
      [1] => Array(
            [post_id] => 19
            [0] => 19
            )

2-2. それぞれの投稿されたメッセージが、ログインしている人がいいねしたメッセージに当てはまるかを調べる

index.php
      $my_like_cnt = 0;
       if (!empty($my_like)) {
        foreach ($my_like as $like_post) {
         foreach ($like_post as $like_post_id) {
          if ($like_post_id == $post['id']) {
           $my_like_cnt = 1;
          }
         }
        }

テキストではもともとforeach文でメッセージ全てを表示させていますが($postがその変数)、その中で上記のコードを入れます。
まず、繰り返されるごとに$my_like_cntの値を初期化するために0を代入します。
$my_likeは二次元配列なので、foreachをネストして値を取り出します。
$like_post_id(いいねしたことのあるメッセージ)と$post['id'](表示しているメッセージ)が同一であれば、$my_like_cntに1を代入します。

$my_like_cntに代入している数字自体に特に意味はありません。

2-3. いいねボタンのハート表示を切り替える

index.php
<?php if ($my_like_cnt < 1) : ?>
<a class="heart" href="index.php?like=<?php echo h($post['id']); ?>">&#9825;</a>
<?php else : ?>
<a class="heart red" href="index.php?like=<?php echo h($post['id']); ?>">&#9829;</a>
<?php endif; ?>

2-2の結果をif文で分岐させていいねボタンの表示を切り替えます。

※ここではCSSは省略しますので、お好きにどうぞ。

これでいいねボタンの表示を変化させることが出来ました。

3. メッセージごとにいいねされた件数を表示する

3-1. メッセージ別のいいねされた件数をDBから取り出す

index.php
$posts = $db->prepare('SELECT m.name, m.picture, p.*, COUNT(l.post_id) AS like_cnt FROM members m, posts p LEFT JOIN likes l ON p.id=l.post_id WHERE m.id=p.member_id GROUP BY l.post_id ORDER BY p.created DESC LIMIT ?, 5');
$posts->bindParam(1, $start, PDO::PARAM_INT);
$posts->execute();

元の投稿内容を取得するところで、一緒にlikeテーブルからも取得するように書き換えます。
ここで追加で取得したいのは、メッセージ別のいいねされた件数です。それがCOUNT(l.post_id) AS like_cntになります。LEFT JOIN likes l ON p.id=l.post_idとしてリレーションを張って、GROUP BY l.post_idとすることでOKです。

3-2. いいねボタンの横に表示させる

index.php
<span><?php echo h($post['like_cnt']); ?></span>

これでメッセージごとにいいねされた件数を表示することが出来ました。

4. いいねボタンをクリックするたびに1ページ目に戻らないようにする

index.php
<?php if ($my_like_cnt < 1) : ?>
<a class="heart" href="index.php?like=<?php echo h($post['id']); ?>&page=<?php echo h($page); ?>">&#9825;</a>
<?php else : ?>
<a class="heart red" href="index.php?like=<?php echo h($post['id']); ?>&page=<?php echo h($page); ?>">&#9829;</a>
<?php endif; ?>

2-3の部分に&page=<?php echo h($page); ?>を追加して上記のように修正する。
そうするとURLパラメータに現在のページ$pageを渡すので、1-5のheaderファンクションの部分を下記のように書き直します。

index.php
header("Location: index.php?page={$page}");

これでいいねボタンをクリックしたあとも元のページが表示されます。

まとめ

index.php
//3-1. メッセージ別のいいねされた件数をDBから取り出す
$posts = $db->prepare('SELECT m.name, m.picture, p.*, COUNT(l.post_id) AS like_cnt FROM members m, posts p LEFT JOIN likes l ON p.id=l.post_id WHERE m.id=p.member_id GROUP BY l.post_id ORDER BY p.created DESC LIMIT ?, 5');
$posts->bindParam(1, $start, PDO::PARAM_INT);
$posts->execute();

//1-2. いいねボタン
if (isset($_REQUEST['like'])) {

  //いいねを押したメッセージの投稿者を調べる
  $contributor = $db->prepare('SELECT member_id FROM posts WHERE id=?');
  $contributor->execute(array($_REQUEST['like']));
  $pressed_message = $contributor->fetch();

  //1-3. いいねを押した人とメッセージ投稿者が同一人物でないか確認
  if ($_SESSION['id'] != $pressed_message['member_id']) {

    //1-4. 過去にいいね済みであるか確認
    $pressed = $db->prepare('SELECT COUNT(*) AS cnt FROM likes WHERE post_id=? AND member_id=?');
    $pressed->execute(array(
      $_REQUEST['like'],
      $_SESSION['id']
    ));
    $my_like_cnt = $pressed->fetch();

    //1-5. いいねのデータを挿入or削除
    if ($my_like_cnt['cnt'] < 1) {
      $press = $db->prepare('INSERT INTO likes SET post_id=?, member_id=?, created=NOW()');
      $press->execute(array(
        $_REQUEST['like'],
        $_SESSION['id']
      ));
      header("Location: index.php?page={$page}");
      exit();
    } else {
      $cancel = $db->prepare('DELETE FROM likes WHERE post_id=? AND member_id=?');
      $cancel->execute(array(
        $_REQUEST['like'],
        $login_user
      ));
      header("Location: index.php?page={$page}");
      exit();
    }
  }
}

//2-1. ログインしている人がいいねしたメッセージをすべて取得
$like = $db->prepare('SELECT post_id FROM likes WHERE member_id=?');
$like->execute(array($_SESSION['id']));
while ($like_record = $like->fetch()) {
  $my_like[] = $like_record;
}
index.php
<?php
  $my_like_cnt = 0;
  if (!empty($my_like)) {
    foreach ($my_like as $like_post) {
      foreach ($like_post as $like_post_id) {
        if ($like_post_id == $post['id']) {
          $my_like_cnt = 1;
        }
      }
    }
  }
  ?>
<?php if ($my_like_cnt < 1) : ?>
  <a class="heart" href="index.php?like=<?php echo h($post['id']); ?>&page=<?php echo h($page); ?>">&#9825;</a>
<?php else : ?>
  <a class="heart red" href="index.php?like=<?php echo h($post['id']); ?>&page=<?php echo h($page); ?>">&#9829;</a>
<?php endif; ?>
<span><?php echo h($post['like_cnt']); ?></span>

完成した表示

image.png

ハートをクリックすると赤くなってカウンターが増えます。

image.png

もう一度クリックすると色が戻ってカウンターが減ります。
image.png

振り返り

今回の実装にあたり個人的に苦戦したのは、
①DB設計
新たなテーブルにどんなカラムを用意すれば良いのか、テーブル間のリレーションはどうなるのか。
②SQL文
3-1では3つのテーブルでリレーションを張ってというような感じで、少し複雑になると思うようにデータを取り出すことが出来ず、解決まで時間がかかった。
③二次元配列
2-1、2-2では二次元配列の扱い方について理解が不十分だった為、時間がかかった。

きっと実務では、Ajaxで非同期処理をしたり、それぞれの処理をクラスで分けたり、色々あると思いますが、少しずつレベルアップして行きたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む