20200805のPHPに関する記事は17件です。

直感的に使えるPHPコマンドラインオプションパーサーを作った

https://packagist.org/packages/d0riven/php-flags#0.1

TL;DR;

自分が欲しいPHPコマンドラインオプションパーサーがなかったので作った。
このライブラリはドキュメントを見なくても使えるくらいシンプルかつ型チェックが行えるコマンドラインオプションパーサーである。
PHPライブラリを作ったことで色々と周辺技術や最新のCIなどを学べて良かった。

作ろうと思った背景

AuraCliをプロジェクトで採用しており、個人的にそれに違和感はなかった。
しかし、PHPに慣れていないメンバーからわかり辛いという声が上がった。

理由は shortやlongの表現, :, ::, \*, などの記号が何を意味しているかがドキュメントみないと分からない という物だった。
当初は「いや、それくらいドキュメント見るでしょ…」と思ったのだがgoなどのオプションパーサー(e.g. flag, kingpin, go-flags)に比べるとたしかに以下の使い辛さを感じる。

  • 宣言とデータ取得のそれぞれでオプション名を記述する必要があり手間
  • 型が保証されていないので自分で検証する必要がある
  • short記法やlong記法、必須オプションかどうかなどがドキュメントを見ないと良くわからない

つまりこういうPHPのオプションパーサーが存在あればそちらを使えばいいと考えた。

  • オプションの宣言と同時にオブジェクトが返ってきてパース後にはそこに値が格納される
    • これにより、宣言とデータ取得でオプション名を重複して記述する必要がなくなる
  • オプションの宣言で型が定義可能でパース時に違反していたら例外で落としてくれる
  • メソッドの補完でどんなオプションが定義可能か、どんなオプションが設定されているかがひと目で分かる
    • goのkingpinのFlagが自分が期待しているもの
    • ドキュメントを読まなくてもUsageを見れば使い方がぱっと分かる程度のもの
  • シンプルにオプションをパースしてくれればよくサブコマンドの定義などは不要
    • ドキュメントが大量に書かれているものは理解に時間が掛かってしまので除外

ということで、 https://qiita.com/tanakahisateru/items/785a56fb6950d8a52006https://github.com/ziadoz/awesome-php#command-line あたりを参考にして既存のPHPのコマンドラインオプションパーサーに自分の求めるものがあるか見てみた。

上のものが近しかったりするが、二重定義が必要だったり、型が保証されていなかったり、shortやlongの定義がgetoptっぽかったりと微妙に手が届かない。

結果、作ったほうが早いなと思い作った。

php-flags

go-flagsから持ってきたものの定義の仕方はkingpinという…
あとは大体README.mdに書いてあることを日本語になおして書く。

特徴

  • フラグや引数の定義を関数によって定義する
    • コード補完の関数一覧を見れば何ができるのかが大体分かる
    • 結果的にドキュメントを見る必要がない
  • option名の二重記述が必要ない
    • オプションの宣言と同時にオブジェクトが返ってきてパース後にはそこに値が格納される
  • オプションと引数のみしか扱わずシンプル
    • ハードなCLIコマンドをPHPで今どき書かないでしょ、と思っているので
  • ヘルプの自動生成

コード補完の関数一覧を見れば何ができるのかが大体分かる

以下のようにコード補完でユーザ定義可能なものの一覧が出てきて関数名と返り値を見れば何ができるかが分かる。

image.png

image.png

image.png

image.png

image.png

使い方

例えばpingのコマンドラインの定義をこのCLIで行った場合はこんな感じになる。
これ以上このライブラリの説明はしない。
これで分からないようならつまり自分が作ったライブラリはわかり辛かったという話なため。

もし使いたいと思ったらgithubのレポジトリにもう少し詳細な使い方が書かれているので見てほしい。

<?php
use PhpFlags\Parser;
use PhpFlags\Spec\ApplicationSpec;

// example ping
$spec = ApplicationSpec::create();
$spec->version('1.0.0')->clearShort();
$count = $spec->flag('count')->short('c')->default(-1)
    ->desc('Number of times to send an ICMP request. The default of -1 sends an unlimited number of requests.')
    ->validRule(function($count) {
        return $count >= -1;
    })
    ->int('request count');
$timeout = $spec->flag('timeout')->short('t')->default(5)
    ->desc('Timeout seconds for ICMP requests.')
    ->validRule(function($timeout) {
        return $timeout >= 0;
    })
    ->int('request count');
$verbose = $spec->flag('verbose')->short('v')
    ->desc('verbose output.')
    ->bool();
$host = $spec->arg()
    ->desc('IP of the host for the ICMP request.')
    ->validRule(function($ip){
        return preg_match('/^(([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/', $ip);
    })
    ->string('host');
try {
    Parser::create($spec)->parse($argv);
} catch (PhpFlags\InvalidArgumentsException $e) {
    echo $e->getMessage(), PHP_EOL;
    exit(1);
} catch (PhpFlags\InvalidSpecException $e) {
    echo $e->getMessage(), PHP_EOL;
    exit(1);
}
echo "  count: ", $count->get(), PHP_EOL;
echo "timeout: ", $timeout->get(), PHP_EOL;
echo "verbose: ", $verbose->get() ? 'true' : 'false', PHP_EOL;
echo "   host: ", $host->get(), PHP_EOL;

実際にコマンドラインオプションが渡されるとこんな値が取得できる。

# オプションが定義されてなければデフォルト値を使用する
$ php ping.php 127.0.0.1
  count: -1
timeout: 5
verbose: false
   host: 127.0.0.1

# 対応するオプションがコマンドラインに記載されていたら、その値が取得される
   $ php ping.php -c 3 -t 10 -v 127.0.0.1
or $ php ping.php -c=3 -t=10 -v 127.0.0.1
  count: 3
timeout: 10
verbose: true
   host: 127.0.0.1

# validRuleに指定されたルールに違反するならInvalidArgumentExceptionが投げられる
$ php ping.php -t=foo 127.0.0.1
The values does not matched the specified type. expect_type:int, given_type:string, value:foo

$ php ping.php -t=-1 127.0.0.1
invalid by validRule. flag:--timeout, value:-1

定義されたspecによってヘルプが自動的に生成される。

# --helpか-hでヘルプが出力される
   $ php ping.php --help
or $ php ping.php -h
Usage:
  php ping.php [FLAG]... (host)

FLAG:
  -c [request count], --count[=request count]
          Number of times to send an ICMP request. The default of -1 sends an unlimited
          number of requests.

  -t [timeout second], --timeout[=timeout second]
          Timeout seconds for ICMP requests.

  -v, --verbose
          verbose output.

ARG:
  host
          IP of the host for the ICMP request.

小話

BDDなテスティングフレームワークのkahlanを採用してみた

describe-itなBDDのテストをフラグや引数定義後のパース結果のテストを表現したかった。
phpunitで表現できる方法もあったが、contextがなかったので使うのはやめた。
kahlanを使ってみることにした。

個人的には使ってみて以下の理由から微妙だと感じた。

  • 呼び出し方が特殊なのでphpstanで大量のエラーが出るのでphpstanをかけられない
  • fixtureで渡したい値が $this-> で状態を保持して渡す必要があるため、無駄に状態を持つ
  • クラス内で $this-> を呼び出しているわけではないため、型ヒントが使えず結果補完をするのに一手間いる
  • 実行時にファイルをコピーしてから実行してしまうせいなのか(ちゃんとコードは読んでない)、xdebugのremote-debugのパスの一致が取れず工夫しないとデバッグできない

今後保守を継続していくことを考えると、最初のphpunitの方が良かったとやや後悔している。
が使ってみないとこういうのは分からないので仕方ない。

traitでmixinなコードをがっつり書いた

今の環境だとtraitをフルに使う必要もなく、微妙なtraitの使い方をしているものも見ているので、ちゃんとtraitを使ってみたこと正直なかった。
今回だと共通の振る舞いを定義することが多くそれをtraitで共通化したりできたので良かった。
ただ本当にちゃんと使えているかは自信がないので、LaravelとかのFWのコードを読んでみて決めた方が良さそうだとは思っている。

最後に

久々にPHPのライブラリを作ったのとオープンソース作法がわからなかったり、最近流行りのCIのgithub actions使ってみたりで色々と学びは多かった。
このライブラリが最終的に使われなくても車輪の再発明とPHPのツール周りを一通り触れたので良かった。

ちなみにコントリビュートは大歓迎なのでForkしてPR投げてもらえると嬉しい。

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

Iteratorパターンを使うメリットとPHP実装例

※この投稿はJava言語で学ぶデザインパターン入門を読んだ感想と復習を兼ねたまとめです

Iteratorパターンは、デザインパターンの1つで、集合に含まれる要素を1つずつ取り出したいときに使用されるようです。

次のようなクラス図で説明されていました。

スクリーンショット 2020-08-06 8.32.51.png

Aggregateは集合という意味で、配列などの要素の集まりを指すようです。コンテナオブジェクトとも呼ばれるようでした。

Iteratorは反復子と訳され、Iterateする(繰り返す)ものを意味します。これは実装例を見たほうがわかりやすかったです。

$books = $bookShelf->getBooks();
for ($i = 0; $i < count($books); $i++) {
    echo $books[$i]->getName() . "\n";
}

上記はIteratorパターンを使用せず、for文を利用して各要素を取得した例です。

本棚(BooKShelf)からすべての本(Books)を取り出し、先頭から順番に本のタイトルを出力しています。

これをIteratorパターンで置き換えると次のようになります。

$iterator = $bookShelf->iterator();
while ($iterator->hasNext()) {
    echo $iterator->next()->getName() . "\n";
}

本棚はiterator()でIteratorインスタンスを生成しています。

反復子は、hasNext()で次の要素があるかどうかを調べ、next()で次の要素を取得しています。

また、関数名からはわかりづらいですが、next()で反復子の現在地を次に進めています。

class BookShelfIterator implements IteratorInterface
{
    private $bookShelf;
    private $index;

    public function __construct(BookShelf $bookShelf)
    {
        $this->bookShelf = $bookShelf;
        $this->index = 0;
    }

    public function hasNext(): bool
    {
        return $this->index < $this->bookShelf->getLength();
    }

    public function next(): object
    {
        $book = $this->bookShelf->getBookAt($this->index);
        $this->index++;

        return $book;
    }
}

Iteratorパターンは、このように集合の要素を1つずつ取り出して処理したいときに使われるようです。

集合の内部仕様に依存しない反復処理が書ける

Iteratorパターンを利用する最大の利点は、集合の要素を列挙する手段を独立させることで、集合の内部仕様に依存しないIteratorを提供することにあるようです。

集合の内部仕様ときいて、最初はどういう意味かわかりませんでしたが、たとえば集合が配列(添字が0から始まる数字)から連想配列(キーとして文字列などを使う)に変更になった場合を想像するとわかりやすかったです。

集合の内部仕様が、配列から連想配列に変わると、さきほど挙げた次のfor文を修正しなければなりません。

// $booksが連想配列の場合は修正しなければならない
$books = $bookShelf->getBooks();
for ($i = 0; $i < count($books); $i++) {
    echo $books[$i]->getName() . "\n";
}

しかしIteratorパターンで実装していれば、呼び出し元のロジックは変更しなくてもよくなります。

// $booksが連想配列になってもIteratorだけ修正すればよい
$iterator = $bookShelf->iterator();
while ($iterator->hasNext()) {
    echo $iterator->next()->getName() . "\n";
}

このように、集合の内部仕様に依存せず、各要素を列挙できるようになることがIteratorパターンの最大の利点のようです。

呼び出し元に修正を加えずに列挙方法を変更できる

さきほどは集合の内部仕様が変更になる例でしたが、集合はそのままだけれど、要素の列挙方法を変更したい場合にもIteratorパターンは有効です。

たとえば、集合の要素を先頭から順方向に列挙するのではなく、後方から前に進めたり、その他の複雑な順序で列挙したい場合などです。要素の列挙方法を定義しているのはIteratorなので、Iteratorを修正すれば、呼び出し元に手を加えずに列挙順序を変更することが可能です。

// 逆方向に要素を列挙するように変更した例
class BookShelfIterator implements IteratorInterface
{
    private $bookShelf;
    private $index;

    public function __construct(BookShelf $bookShelf)
    {
        $this->bookShelf = $bookShelf;
        $this->index = $this->bookShelf->getLength() - 1; // 最後の要素から開始する
    }

    public function hasNext(): bool
    {
        return $this->index > 0; // indexが0以上であれば次の要素が存在する
    }

    public function next(): object
    {
        $book = $this->bookShelf->getBookAt($this->index);
        $this->index--; // 要素を列挙した後はindexを1減らす

        return $book;
    }
}

このようにIteratorクラスの実装を変更するだけで、要素の列挙方法を変更することができます。

上記の例では既存のIteratorを修正していますが、列挙方法の異なるIteratorを複数作ることも可能です。インタフェースは共通しているので、呼び出し元で別のIteratorに切り替えることも簡単にできます。複数の箇所で、複数のパターンで集合要素が列挙されるような場合には、特にIteratorパターンは有効に思えました。

Iteratorパターンのメリット

Iteratorパターンは、集合の内部仕様と要素の列挙手段を独立させることを目的としていました。

それにより、次のようなメリットが得られます。

  • 集合の内部仕様が変更されても、呼び出し元の処理を修正する必要がなくなる
  • 要素の列挙方法を変更したい場合も、呼び出し元の処理を修正する必要がない
  • 集合に対して複数の列挙方法を提供したい場合も、簡単に切り替えることができる

最後に、Iteratorパターンの実装例(PHP)を載せておきます。

https://github.com/yuta-o-note/gof-design-pattern

<?php

interface AggregateInterface
{
    public function iterator(): IteratorInterface;
}

interface IteratorInterface
{
    public function hasNext(): bool;

    public function next(): object;
}

class Book
{
    private $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    public function getName()
    {
        return $this->name;
    }
}

class BookShelf implements AggregateInterface
{
    private $books;

    public function getLength(): int
    {
        return count($this->books);
    }

    public function appendBook(Book $book): void
    {
        $this->books[] = $book;
    }

    public function getBooks(): array
    {
        return $this->books;
    }

    public function getBookAt(int $index): Book
    {
        return $this->books[$index];
    }

    public function iterator(): IteratorInterface
    {
        return new BookShelfIterator($this);
    }
}

class BookShelfIterator implements IteratorInterface
{
    private $bookShelf;
    private $index;

    public function __construct(BookShelf $bookShelf)
    {
        $this->bookShelf = $bookShelf;
        $this->index = 0;
    }

    public function hasNext(): bool
    {
        return $this->index < $this->bookShelf->getLength();
    }

    public function next(): object
    {
        $book = $this->bookShelf->getBookAt($this->index);
        $this->index++;

        return $book;
    }
}

function main()
{
    $bookShelf = new BookShelf();
    $bookShelf->appendBook(new Book('book-A'));
    $bookShelf->appendBook(new Book('book-B'));
    $bookShelf->appendBook(new Book('book-C'));
    $bookShelf->appendBook(new Book('book-D'));

    $iterator = $bookShelf->iterator();
    while ($iterator->hasNext()) {
        echo $iterator->next()->getName() . "\n";
    }

    $books = $bookShelf->getBooks();
    for ($i = 0; $i < count($books); $i++) {
        echo $books[$i]->getName() . "\n";
    }
}

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

Laravel+Vue.jsで作成したSPAサイトでOGP対応

はじめに

個人開発でLaravel5.5とVue.jsを使用して作成したSPAサイトがあり、長らく放置していたのですが、最近勉強を兼ねて少しリファクタリングをしようかなと思いました。

ラブライブ専用掲示板

上記が作成したサイト(掲示板)になります。
治すべき部分はたくさんあるのですが、まず気になったのがTwitterカードの表示です。
特に、掲示板のスレッドURLをツイートした際に、デフォルトの情報が表示されてしまうのが見た目の部分で致命的でした。
image.png

まずやったこと

最初にapiでスレッドの情報を読み込んだ後のタイミングでquerySelectorを使用して動的にmetaタグを変更しました。

//APIでデータ取得後
document.title = this.thread_header[0].title + ' | LoveLiveBBS'; 
document.querySelector("meta[property='og:title']").setAttribute('content', this.thread_header[0].title + ' | LoveLiveBBS');
document.querySelector("meta[property='description']").setAttribute('content', this.thread_response[0]['writing']);
document.querySelector("meta[property='og:description']").setAttribute('content', this.thread_response[0]['writing']);

当たり前ですが、これでは書き換わる前のmetaが読み込まれてしまうので結果は変わりませんでした。。。

今回は/thread/[thread_id]ページのみの修正ということで、その為だけにわざわざプリレンダリングやSSRはしたくないなと思い、以下のような対応を行いました。

Laravelのbladeファイルでの対応

/thread/[thread_id]にアクセスされたときにLaravel側でデータを取得してmetaタグに設定する方法をとりました。

bladeファイルの作成

修正前は初回アクセス時にのみ使用するspa.blade.phpのみが存在していました。
スレッドページ用のthread.blade.phpを作成しました。

resources/views/threadPage.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
    //コントローラーで作成したデータを表示
    <title>{{ $title }}</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta name="csrf-token" content="{!!csrf_token()!!}">
    <link href="https://fonts.googleapis.com/css?family=Kosugi+Maru&display=swap&subset=japanese" rel="stylesheet">
  //コントローラーで作成したデータを表示
    <meta name="description" content="{{ $description }}">
    <meta property="og:url" content="https://lovelivebbs.jp" />
  //コントローラーで作成したデータを表示
    <meta property="og:title" content="{{ $title }}" />
    <meta property="og:type" content="website">
  //コントローラーで作成したデータを表示
    <meta property="og:description" content="{{ $description }}" />
    <meta name="twitter:card" content="summary" />
    <meta name="twitter:site" content="@lovelivebbs" />
  //コントローラーで作成したデータを表示
    <meta property="og:site_name" content="{{ $title }}" />
    <meta property="og:locale" content="ja_JP" />
</head>
<body>
<div id="app">
    <app></app>
</div>

<script src="{{ mix('js/app.js') }}"></script>
</body>
</html>

コントローラーにスレッドページ用のメソッドを追加

spa.blade.phpを返す役割だけのコントローラーにthreadPageメソッドを追加しました。

app/Http/Controllers/SpaController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Response;
use App\Thread;
use Exception;
use Illuminate\Support\Facades\Log;

class SpaController extends Controller
{
    public function index()
    {
        return view('spa');
    }

    /**
     * スレッドページのみmetaタグをbladeファイルで設定
     * @param $id
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
     */
    public function threadPage($id) {
        $meta = [
            'title' => 'LoveLive!BBS!',
            'description' => '当サイトはラブライブシリーズ専用掲示板です。'
        ];
        try {
            $threadDetail = Thread::where('id', $id)->first();
            if (is_null($threadDetail)) {
                return view('threadPage', $meta);
            } else {
                //取得したデータをmetaタグに設定
                $response1 = Response::where('thread_id', $id)->first();
                $meta['title'] = $threadDetail->title . ' | LoveLive!BBS!';
                $meta['description'] = $response1->writing;
                return view('threadPage', $meta);
            }
        } catch (Exception $exception) {
            Log::error('thread page exception');
            return view('spa');
        }
    }
}

DBから取得した値をまとめてbladeファイルに渡しています。

ルーティングの追加

routes/web.php
+ Route::get('/thread/{id}', 'SpaController@threadPage');

Route::get('/{any}', 'SpaController@index')->where('any', '.*');

結果

上記の変更を加えた後に、Twitterにスレッドのリンクを張ってみました。
image.png

無事、スレッドタイトルと内容が反映されました!???
こうなると次はOGPの画像生成をしたくなりますね。

あまり良いやり方ではないかもしれませんが、なんとかなりました。
もっといい方法があったら教えてください。

後このサイトですが、残念ながら全然使われてないのでラブライブが好きな方はぜひ書き込みだけでもしてみてください。。。(切実)
ラブライブ専用掲示板
ラブライブ専用掲示板:ABOUTページ

よろしくお願いします。?

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

都内の行きつけの居酒屋新規開拓サービスをリリースしたらダダ滑りしたので反省会・考察します【Laravel】

行きつけの居酒屋を新規開拓できるWEBサービスを作ったので振り返ります

初投稿です。
酔いどれ(@yoido_re)です。

この記事は先月7月末にプレリリースした個人開発のサービス、東京歩くマップについて、リリースから約1週間が経ったのでリリースまでの道のりから現状について皆さんに宣伝も兼ねてお話します。

東京歩くマップ - TokyoAlc.Map 行きつけの居酒屋を新規開拓しよう!
https://alcmap.tokyo/

スクリーンショット 2020-08-04 20.41.09.png

リリースした東京歩くマップとは

タイトルにも掲げている通り、東京歩くマップとは、東京都内に限定した行きつけの居酒屋を新規開拓するために、ユーザ同士が行きつけの居酒屋を共有し合うユーザ投稿型のサービスです。

背景

なぜ作ったの?

東京歩くマップのトップにも掲げています。

東京歩くマップ(東京Alc.マップ)とは
東京にある星の数ほどの居酒屋から自分好みの居酒屋を見つけるのは至難の技
一度気に入った居酒屋を見つけるとその居酒屋ばかりに固執してしまい、バリエーションが増やせない
レビューサイトで人気のお店には並んでまでして行こうとは思わない
そんな面倒くさがりな酒呑み達のためだけの行きつけの居酒屋共有サービス

食べ○グや他の口コミサイトとの差別化

以下2点です。
1点目。
★1の居酒屋を掲載したところで価値がないと考え、そもそも行きつけでない居酒屋はわざわざ掲載しない。本当に行きつけの居酒屋のみを掲載します。

2点目。
食べログのように、★星の数(評価)が高い=行きつけとは限らない。ウマいからと言って行きつけの居酒屋になるとは限らず、行きつけとなった背景には店主・店員さんとの素っ気ない会話、お店の入りやすさ等、別のパラメータが存在するのでは?と考察しました。

スクリーンショット 2020-08-04 20.40.36.png

最終的に行き着いたのは、その居酒屋へ行くリピート頻度(画像右上)が高いほど行きつけの居酒屋という定義をしました。
食べログや他の口コミサイトにはない、新たな指標値を見つけサービスの差別化ができたぞ!!!とサービス開発に火が付きました!

サービス思想

行きつけの居酒屋を探す方法を考察

行きつけの居酒屋を探す方法として、簡単に思いつくものに以下の手段が挙げられます。

  • WEBで探す
  • 街をブラブラして偶然入った店を気に入る
  • テレビの紹介
  • アプリ(食べログ)で評価の良い店を探す
  • 友人に聞いて勧めてもらう

上記についてツイッターで主要3つのものについてアンケートを取ると意外な結果になりました。
(全部でアンケート取ればよかったと反省)

スクリーンショット 2020-08-04 21.02.30.png

Twitterの結果から、美味しいお店をアプリで探している人と違い、酒呑み達にとってはその場でサクッと調べたお店に行きたがる傾向にあるのでは?と考察しました。
そこでアプリは断念。WEBサービスとして、酒呑みが集まるプラットフォームを構築しようと発案しました。
加えてTwitterアカウントを用いて投稿者を非匿名にすることで、友人に聞いたかの如く信憑性の高い行きつけの居酒屋を知ることができるのでは??とTwitterアカウントとの連携を採用。

ターゲティング(ペルソナ)

個人サービスにおいては、ブルーオーシャンでターゲットが狭ければ狭いほどウケると信じてます。そこで自分も該当する以下の3つをターゲットにします。

  • 都内在住
  • Twitterやってる人
  • 行きつけのお店がない人・行きつけのお店にハマりすぎて新規開拓できない人

SEOキーワードは以下を想定
行きつけ + 居酒屋 +(駅名 | 都内 | 新規開拓) 

サービス開発

システム構築

バックエンド

  • Amazon Lightsail
  • Laravel 7.2.x
  • PHP 7.3.x
  • MySQL
  • (Amazon S3はサービスが普及したら利用予定)
  • GoogleMapAPI
  • TwitterAPI
  • 最寄り駅API

フロントエンド

  • BootStrap
  • Semantic UI

スマホユーザが圧倒的に多いと想定し、スマホサイズに重きを置いたレスポンシブデザインにしました。

実装機能

限りなくスモールスタートです。
機能性≠ユーザの流入数、機能性=再帰率です。どんなに素晴らしい機能を実装してもユーザの目に触れなければ意味がないので、まずはコンセプトに沿った必要最低限の機能を実装しました。

実装機能 概要
Twitter認証 できるだけユーザ登録型のサービスは作りたくないのですが、非匿名での紹介が行きつけの居酒屋への信憑性につながると考えました
居酒屋の投稿機能 Twitter認証でユーザ登録したユーザのみ、居酒屋を登録可能
居酒屋の照会機能 誰でも照会可能
コメント機能&写真投稿機能 活気が出れば良いなと思い、簡単だったので設けました
未実装機能 理由
投稿の編集・削除 必須ではないと判断、ユーザが増えたら実装
マイページ ユーザが増えないうちに実装しても意味がない

投稿画面

スクリーンショット 2020-08-04 21.33.36.png

GoogleMapAPIを採用し、居酒屋の名前を正規化します。更に住所情報等のメタデータも同時に取得し、最寄駅を算出。
これにより、ユーザの入力を最小限に収めました。

照会画面

スクリーンショット 2020-08-04 21.36.03.png
スクリーンショット 2020-08-04 21.36.36.png
スクリーンショット 2020-08-04 21.37.13.png

反省

ユーザが増えない

リリースしてまだまだ浅いので焦るには早いかも知れませんが、以下2点を原因と考察。(≠真の原因)

SEOが弱い

検索流入が0。検索にすらヒットしていない・・・
東京メトロが運営しているトーキョーウォーキングマップとサービス名が類似しているのも反省。自ら検索妨害されに行ってしまいました・・・

そもそも”行きつけ”のキーワードで検索する人ってどのくらいいるんだろう・・?

マーケティングできていない

現状、身内・Twitterのフォロワーにしか宣伝できていません。投稿が増えたら広告も考えているのですが、投稿の少ないうちに広告を打っても閲覧者には響かないです。

投稿が増えない

恐らく、作ったサービスの質は悪くない。実際に回りの人に使ってもらうとそこそこの評価は貰えています。
それはあくまでも閲覧者側の目線。

投稿者側にメリットがないじゃん!!って意見も多々ありますが、そもそもログインされた形跡がなく、投稿画面にすら飛んでいない!!WHY!!!

投稿したくなるようなサービスでない?

投稿者側に現在の機能では何のメリットもない。投稿者-閲覧者の関係がWin-Winでないとサービスは成り立ちません。そこを全く考えていませんでした。
投稿に対していいね機能を設けるのも一つの手ですが、ユーザが少ないうちにその手は有効なのでしょうか・・・・

良かった点

  • 採用した技術はほとんど初めての利用です。初めてのPHPフレームワーク(Laravel)、初めてのLightsail。十分に使いこなせたと実感しています。

  • 5月から着手し、普段は仕事をしつつ、空いた時間のみで3ヶ月という期間サボりなしでリリースできたのは良かったと思います。
    (実稼働は1人月ほど?)

  • 開発着手前に、最低限必要な機能と2次開発機能を振り分けていた点、リリース時期やマイルストーンを決め打ちしていたことから順調にリリースまで迎えられました。

  • もともとWEBデザイン大好き人間なので満足行くデザインに仕上がりました。

  • 結果にはつながっていない話ですが、エンジニアは技術に没頭しがちでビジネス思考を忘れがちになります。今回は技術よりも如何にユーザ獲得に繋げられるか、ユーザビリティ向上するにはどうするか、常に意識していました。意識だけしていました。
    もちろん結果には(略

まとめ

今の所順調に失敗していますが、考案したサービスを無事リリースできたという点で非常に達成感を感じていると同時に自信につなげることができています。

また、実際にユーザが限りなく少ないわけではなく再帰率も高いことから、如何に継続して投稿を続けられるかがサービス継続の決め手になると思っています。

最後に再度、宣伝になりますが、東京歩くマップ、非常に有益な居酒屋情報が投稿されていますので、新規開拓したい方、サービス応援してくださる方は、ぜひ閲覧・投稿よろしくお願いいたします。

東京歩くマップ - TokyoAlc.Map 行きつけの居酒屋を新規開拓しよう!
https://alcmap.tokyo/

長々とご視聴ありがとうございました。


普段はLaravelや日常のブログも書いてます。(走り出したたこ焼きの開発
Twitterもぜひ。(@yoido_re

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

ユーザー情報をセッションに格納する方法

ユーザー情報をセッションに格納する方法

phpを使用したポートフォリオ作成時にログイン画面を実装しました。
ログイン後、ユーザー情報に紐づいた処理を行うため、ユーザー情報をセッションに格納しました。ログイン → ログアウトまでの中で、セッションに格納に必要な記述をカンタンにまとめました。

そもそもセッションとは

こちらのサイトの言葉がわかりやすいので引用します。Cookieとの違いも記載しており、わかりやすいです。

セッションとは、コンピュータのサーバー側に一時的にデータを保存する仕組みのことです。
PHPでセッションを使う方法【初心者向け】

手順

  1. ログインするときにユーザー情報を格納する
  2. ログイン成功後、セッションからユーザー情報取得
  3. ログアウトしてセッションを削除

ものすごく簡単に書きました。

1. ログインするときにユーザー情報を格納する

記載したコードはこちらです。今回私はユーザーIDのみを格納しました。
※まだローカル環境で開発中なので画面遷移はlocationです。

ログイン成功後の画面に遷移する前にセッションに値を格納しました。

//セッションにユーザーID $user_id を格納
session_start();
$_SESSION['user_id'] = $user_id;

//ログイン成功後の画面に遷移
header("Location: ./index.php");

2. ログイン成功後、セッションからユーザー情報取得

ログイン後遷移したファイルにユーザーIDを取得するコードを記載。

//セッションからユーザーID取得
session_start();
$user_id = $_SESSION["user_id"];

セッションからユーザーIDを取得、 $user_id という変数に格納しました。
こちらの値を元に、画面上にデータを表示させたりします。

3. ログアウトできるようにする

ログアウト つまり 格納したセッションを削除します。

ログイン後の画面上に「ログアウト」ボタンを設置し、ログイン画面に遷移するようにしました。その際にセッションを削除します。

・ログイン後の「ログアウト」ボタン ※ボタン押すとログイン画面に移動

  <form action="../users/index.php" method="post">
      <input type="hidden" name="action" id="action" value="destroy"/>
      <input type="submit" value="ログアウト" />
  </form>

・ログイン画面の処理

//ログアウト セッションの値を削除
if ($_POST["action"] == "destroy") {
    unset($_SESSION["user_id"] );
}

まとめ

こちらに記載した内容以外にも、ログイン失敗したときなども考慮が必要ですが、いったんログイン成功パターンの流れを記載しました。セッションでの記述は思った以上にシンプルでスムーズに記載できたので、今後も活用していきたいと思います。

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

[Laravel]Composerエラー 「Do not run Composer as root/super user!〜」

Composerを使ってライブラリを使ってインストールをしたとことろ以下のエラーが出ました。

Do not run Composer as root/super user! See https://getcomposer.org/root for details

composerを使ったインストールをrootユーザーでしようとしたため、エラーが出たみたいです。
なぜrootユーザーでインストールは好ましくないかについては以下を参照してください。
https://getcomposer.org/doc/faqs/how-to-install-untrusted-packages-safely.md

root以外のユーザーを作成します。Linuxのコマンドのuseraddでアカウントを設定します。

// rootユーザーになる
$ sudo su -

// ユーザーを追加
$ useradd -m 名前

// 追加したユーザーに移動
$ su 名前

名前$ composer コマンド

これでroot以外のユーザーになったため、composerでインストールができるようになりました

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

LaravelでToDoアプリを作ったときの備忘録

定期的にQiitaで記事を投稿(アウトプット)することが大事かなと思い、間違い等もあると思うが、書いていく。

下の記事を参考にToDoアプリを作る際の備忘録を残していく。
下の備忘録は、全てこちらの記事に載っているのでそちらを参考にする。
HyperTextCandy LaravelによるToDoアプリ作成記事

環境

  • Windows10
  • Xampp
  • MySQL
  • PHP7
  • Laravel7
  • VsCode使用

準備

$ laravel new ToDoで新しくToDoプロジェクトができあがった。かんたん。
$ php artisan serveでロケットが出ることを確認。オッケーちゃんとできてる。

色々

  • コントローラ作成
  • マイグレーション作成
  • モデルクラスの作成
  • シーダーの作成
    • $ php artisan make:seeder FoldersTableSeederでシーダーを作成
    • シーダーの中にテストデータを記述する。参考文献を参照。ただし、Carbon::now()はLaravel7では最初からパッケージ化されているのでnow()でいける。
    • $ php artisan db:seed --class=FoldersTableSeederでテストデータをテーブルに入れる。「successfully」がでればオッケー。
  • タスクを選択しているかしていないか
    • viewのaタグに三項演算子を用いてactiveを付与する。{{ $current_folder_id === $folder->id ? 'active' : '' }}
  • tinkerの利用
    • $ php artisan tinkerすると、コマンドラインからアプリの機能を見ることができる。例えば>>> $folder = \App\Folder::find(1);すると、Folderテーブルからid=1を拾ってきて表示してくれる。

アクセサ

これまでの学習ではよくわかっていなくてスルー気味だったので、ここで確認。
モデルクラス内で、get[HogeHoge]Attributeを定義すると、[モデル名]->[hoge_hoge]でアクセスできるとのこと。

viewで条件分岐してたけど、modelのなかに組み込むというのもありだよね。

FormRequestクラスの作成

フォーム入力時には、バリデーションが必要ですよね。ちゃんと学びましょう。
$ php artisan make:request CreateFolder
app/Http/Requests フォルダに CreateFolder.phpが作られる。
このCreateFolderにバリデーションルールを記述する。
その後、
Controllerでメソッドの引数で渡すRequestをCreateFolderクラスに変更する。FormRequestクラスは、Requestクラスと互換性あり。
つまり、
public function index(Request $request)の部分を、public function index(CreateFolder $request)とすることで、FormRequestクラスである、「CreateFolder」を使ってバリデーションしたあとにフォームを$requestで受け付けることができる。

バリデーション違反のときのメッセージ

バリデーション違反の場合のメッセージは、resources/lang/en/vallidation.phpの中に、連想配列で記述されている。
それを日本語にするには、jaディレクトリを作って、上のファイルをそのままコピーして、該当する部分を変えていく。楽したいなら、誰かが作ってくださったものを利用するのが良いかも。

共通のバリデーションメッセージはここにあるが、別個に作る場合は、FormRequestクラスの中に、messages()メソッドを作ってreturnしてやるといい。

編集画面のTips

oldメソッドの第一引数に、inputのname属性を指定、第二引数にはデフォルト値を指定する。そうすると、編集画面時には、もうすでに入力値があればそれを出力してくれる。

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

LaravelでToDoアプリを作る

定期的にQiitaで記事を投稿(アウトプット)することが大事かなと思い、間違い等もあると思うが、書いていく。

下の記事を参考にToDoアプリを作る際の備忘録を残していく。
HyperTextCandy LaravelによるToDoアプリ作成記事

環境

  • Windows10
  • Xampp
  • MySQL
  • PHP7
  • Laravel7
  • VsCode使用

準備

$ laravel new ToDoで新しくToDoプロジェクトができあがった。かんたん。
$ php artisan serveでロケットが出ることを確認。オッケーちゃんとできてる。

色々

  • コントローラ作成
  • マイグレーション作成
  • モデルクラスの作成
  • シーダーの作成
    • $ php artisan make:seeder FoldersTableSeederでシーダーを作成
    • シーダーの中にテストデータを記述する。参考文献を参照。ただし、Carbon::now()はLaravel7では最初からパッケージ化されているのでnow()でいける。
    • $ php artisan db:seed --class=FoldersTableSeederでテストデータをテーブルに入れる。「successfully」がでればオッケー。
  • タスクを選択しているかしていないか
    • viewのaタグに三項演算子を用いてactiveを付与する。{{ $current_folder_id === $folder->id ? 'active' : '' }}
  • tinkerの利用
    • $ php artisan tinkerすると、コマンドラインからアプリの機能を見ることができる。例えば>>> $folder = \App\Folder::find(1);すると、Folderテーブルからid=1を拾ってきて表示してくれる。

アクセサ

これまでの学習ではよくわかっていなくてスルー気味だったので、ここで確認。
モデルクラス内で、get[HogeHoge]Attributeを定義すると、[モデル名]->[hoge_hoge]でアクセスできるとのこと。

viewで条件分岐してたけど、modelのなかに組み込むというのもありだよね。

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

プロフィールの更新履歴を保存する仕組みを作るにはどのようにしたら良いのか。手順をまとめてみた。(自分用)

はじめに

※ Profile画面を作っていますので、profile(自分用)として色々記載しています。

実際のアプリケーション開発はある機能が1つのテーブルを使うだけで完結することは、ほとんどありません。

複数のテーブルを使ってシステムを実現します。
リレーショナルデータベースにはこのようなテーブル同士の関連を扱う機能が備わっており、
Eloquentで扱うことができます。
編集履歴は「Profileをいつ変更したか」「日付と時刻を記録し、参照することができる」機能のことです。

編集画面でデータを更新するタイミングで histories というテーブルにデータを登録し、
編集画面でその一覧を見られるように実装します。

では、流れに沿って記述していきます。

1.編集履歴テーブルの作成と関連付け

Migrationファイルの雛形を作成します。

$ php artisan make:migration create_histories_table

Migrationファイルを次のように編集いたします。

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateHistoriesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('histories', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('profile_id');
            $table->string('edited_at');

            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('histories');
    }
}
?>

Migrationを実行します。

$ php artisan migrate

Modelの雛形を作成します。

$ php artisan make:model History

app/History.php で History Modelを下記のように実装します。

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class History extends Model
{
    protected $guarded = array('id');

    public static $rules = array(
        'profile_id' => 'required',
        'edited_at' => 'required',
    );
}
?>

Profile Modelとの関連を定義するために、app/Profile.php へ以下の内容を追記します。

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Profile extends Model
{
    protected $guarded = array('id');

    public static $rules = array(
        'name' => 'required',
        'gender' => 'required',
        'hobby' => 'required',
        'introduction' => 'required',
    );

    // Profileモデルに関連付けを行う
    public function histories()
    {
    return $this->hasMany('App\History');
    }
}
?>

Profileモデルに関連付けを定義することで、

Profileモデルから $profile->histories() のような記述で簡単にアクセスすることができます。

2.編集履歴の記録と参照

ProfileController の update Actionで、Profile Model を保存するタイミングで、
同時にHistory Model にも編集履歴を追加するように実装します。

update Action を次のように変更。

<?php
public function update (Request $request)
{
    // validationをかける
    $this->validate($request, Profile::$rules);
    // Profile Model から データを取得する
    $profile = Profile::find($request->id);
    // 送信されてきたフォームデータを格納する
    $profile_form = $request->all();
    unset($profile_form['_token']);
    // 該当するデータを上書きして保存する
    $profile->fill($profile_form)->save();

    retern redirect('admin/profile');
}
?>

3.Viewを実装する

resources/views/admin/profile/edit.blade.php に以下を記述します。

{{-- layouts/profile.blade.phpを読み込む --}}
@extends('layouts.profile')


{{-- profile.blade.phpの@yield('title')にProfileの編集'を埋め込む --}}
@section('title', 'Profileの編集')

{{-- profile.blade.phpの@yield('content')に以下のタグを埋め込む --}}
@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 mx-auto">
            <h2>MyProfile編集画面</h2>
            <form action="{{ action('Admin\ProfileController@update') }}" method="post" enctype="multipart/form-data">
                @if (count($errors) > 0)
                <!-- errors内に個数があるならば配列中の個数を返す -->
                <ul>
                    @foreach($errors->all() as $e)
                    <!-- $errors内をループし、その中身を$eで表示する -->
                    <li>{{ $e }}</li>
                    @endforeach
                </ul>
                @endif
                <div class="form-group row">
                    <label class="col-md-2">氏名</label>
                    <div class="col-md-10">
                        <textarea class="form-control" name="name" rows="1">{{ old('body') }}</textarea>
                    </div>
                </div>
                <div class="form-group row">
                    <label class="col-md-2">性別</label>
                    <div class="col-md-10">
                        <input type="radio" name="gender" value="male">男性
                        <input type="radio" name="gender" value="male">女性
                    </div>
                </div>
                <div class="form-group row">
                    <label class="col-md-2">趣味</label>
                    <div class="col-md-10">
                        <textarea class="form-control" name="hobby" rows="10">{{ old('body') }}</textarea>
                    </div>
                </div>
                <div class="form-group row">
                    <label class="col-md-2">自己紹介欄</label>
                    <div class="col-md-10">
                        <textarea class="form-control" name="introduction" rows="12">{{ old('body') }}</textarea>
                    </div>
                </div>
                <input type="hidden" name="id" value="{{ $profile_form->id }}">
                {{ csrf_field() }}
                <input type="submit" class="btn btn-primary" value="更新">
            </form>
        </div>
    </div>
</div>
@endsection

次に Routing を実装する
routes/web.php に追加するRouting設定です。

<?php
Route::get('profile/edit', 'Admin\ProfileController@edit');
Route::post('profile/edit', 'Admin\ProfileController@update');
?>

編集画面に実際にブラウザでアクセスしてみましょう。
一覧画面にある表の1番右側に、編集リンクがあります。
このリンクから編集画面に遷移し、実際にデータが編集できるか試してみましょう。

4.データの削除

データの削除は、データの更新と同じくモデルを取り出してから削除を指示します。

5.Controller を実装する

ProfileController に delete Action を追加してください。

<?php

public function delete(Request $requsest)
{
    // 該当する Profile Modelを取得
    $profile = Profile::find($request->id);
    // 削除する
    $profile->delete();
    return redirect('admin/profile');
}

?>

次はデータの削除について考えていきます。
一般的に削除に対応するアクション名はdeleteになります。

データをセーブするときは、$profile->save(); で save メソッドを利用しましたが、
データの場合はdelete()メソッドを使います。

6.Routing を実装する

routes/web.php に追加する Routing 設定です。

Route::get('profile/delete', 'Admin\ProfileController@delete');

削除機能は画面を持たず、id で指定されたモデルをすぐに削除します。
コントローラの最後の画面で一覧画面にリダイレクトしているため、
削除機能を持っていません。

そのため、ビューテンプレートは不要となる。
今回は以上です!
完全自分用のノートになってしましたすいません。
それでは
明日は今日以上のパフォーマンスを٩( ᐛ )و

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

WordPress のタグ・クラウド・ウィジェットのフォント・サイズを変更する方法

問題

WordPress 標準のタグ・クラウド・ウィジェットは、フォント・サイズの指定を、以下のようにインラインで行っています。

<div class="tagcloud">
    <a href="https://example.com/tag/Wordpress" class="tag-cloud-link tag-link-13 tag-link-position-1" style="font-size: 16.4pt;" aria-label="WordPress (2個の項目)">WordPress</a>
    <a href="https://example.com/tag/PHP" class="tag-cloud-link tag-link-17 tag-link-position-2" style="font-size: 22pt;" aria-label="PHP (3個の項目)">PHP</a>
    <a href="https://example.com/tag/JavaScript" class="tag-cloud-link tag-link-19 tag-link-position-3" style="font-size: 8pt;" aria-label="JavaScript (1個の項目)">JavaScript</a>
</div>

このため CSS のみでは、フォント・サイズの変更は以下のように固定で指定することしかできません1

.tag-cloud-link {
    font-size: 16px !important;
}

解決策(PHP)

調べてみると、widget_tag_cloud_args フィルターで指定できるようである2

functions.php
add_filter(
    'widget_tag_cloud_args',
    function( $args ) {
        $args += array(
            'smallest' => 0.6,
            'default'  => 0.9,
            'largest'  => 1.2,
            'unit'     => 'rem',
        );
        return $args;
    }
);

おまけ:解決策(JavaScript)

最初、このフィルターの存在に気づかずに、JavaScript でやろうとして書いたコード。これでも意図通りに機能しますが、上記方法が使えるので、他所のサイトにユーザー・スクリプトを適用する場合ぐらいしか使い道は思いつきませんが……。

// タグ・クラウド・ウィジェットの文字サイズを調整
( () => {
    const tags = document.getElementsByClassName( 'tag-cloud-link' );
    Object.keys( tags ).forEach( key => {
        // HTMLCollection や NodeList は配列じゃないので、そのまま forEach メソッドは使えない 

        const minFontSize = 0.6;
        const maxFontSize = 1.2;
        const fontSizeUnit = 'rem';
        const defaultMinFontSize = 8; // pt
        const defaultMaxFontSize = 22; // pt

        const tag = tags[key];
        const originalFontSize = tag.style.fontSize;
        const fontSizeIncrementPercentage =
                ( parseInt( originalFontSize )  - defaultMinFontSize ) /
                ( defaultMaxFontSize - defaultMinFontSize );

        tag.style.fontSize =
            minFontSize +
            fontSizeIncrementPercentage * ( maxFontSize - minFontSize ) +
            fontSizeUnit;
    } );
} )();

参考サイト

  1. WordPressタグクラウドをカスタマイズ:フォントサイズ指定・並び替え – PIXELISTE
  2. Modify tag cloud widget font size • CSSIgniter
  3. テンプレートタグ/wp tag cloud - WordPress Codex 日本語版
  4. NodeListをforEachしたいときのパターン - yuhei blog

  1. .tag-link-size-1 みたいなクラス名をつけてくれていれば、CSS のみで対応できるのに……。 

  2. 「WordPress タグクラウド フォントサイズ」で Google 検索すると、この方法が載っている参考サイト 1 よりも上位に、wp-includes/category-template.php を編集している(ダメゼッタイ)記事や、上記のように CSS で固定しているだけの記事が引っかかってしまうので、この記事を新規に起こしました。 

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

ローカルWebサーバーからSesamiをそこそこ早く施錠・解錠する。

スマートロック「Sesami」を使っている人なら感じたことがあるでしょう。
クラウドにアクセスするのが遅すぎる…と。
かといってBluetoothに接続するも遅い…と。
これだったらリアル鍵を取り出した方が早いんじゃね…と。

SesamiはWebAPIを公開してくれています。
なのでそれをPHPからcURLで叩いてそこそこ早く施錠・解錠したいと思います!

準備

・SesamiとWifiアクセスポイント
今このページを見ている人なら既に持ってると思いますが一応

・無線LANルーター
スマホでローカルWebサーバーにアクセスするのに要ります。

・PHPでcURLが動かせるローカルWebサーバー
構築方法はググって下さい。
IPアドレスはスマホからアクセスできるように固定しといて下さい。
自分はRaspberryPiにNginxを入れてます。WebサーバーにできるNASとかもありますし何でもいいと思います。

・SesamiIDとAPIキー
下記URLを参照しSesamiIDとAPIキーを取得して下さい。
APIキー取得方法とセサミIDの確認方法

PHPファイルをローカルWebサーバーに配置

SesamiIDとAPIキーは書き換えて下さい。

・施錠用PHP

lock.php
<?php
$url = 'https://api.candyhouse.co/public/sesame/<SesamiID>';
$apikey = '<APIキー>';

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);

$data = array('command'=>'lock');
$data_json = json_encode($data);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: ' . $apikey, 'Content-Type: application/json'));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, $data_json);

curl_exec($ch);
curl_close($ch);
?>

・解錠用PHP

unlock.php
<?php
$url = 'https://api.candyhouse.co/public/sesame/<SesamiID>';
$apikey = '<APIキー>';

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);

$data = array('command'=>'unlock');
$data_json = json_encode($data);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: ' . $apikey, 'Content-Type: application/json'));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, $data_json);

curl_exec($ch);
curl_close($ch);
?>

上記PHPファイルを作成したらドキュメントルートに配置します。
/var/www/html/lock.php
/var/www/html/unlock.php

施錠・解錠方法

スマホのWifiで無線LANルーターに接続し、ブラウザから
http://<ローカルWebサーバーのIPアドレス>/lock.php
にアクセスします。すると体感3秒位で施錠してくれます。

解錠も同様の手順でunlock.phpにアクセスします。

(ショートカットを作っておくと便利!)

参考文献

Sesami API

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

ローカルWebサーバーからSesameをそこそこ早く施錠・解錠する。

スマートロック「Sesame」を使っている人なら感じたことがあるでしょう。
クラウドにアクセスするのが遅すぎる…と。
かといってBluetoothに接続するも遅い…と。
これだったらリアル鍵を取り出した方が早いんじゃね…と。

SesameはWebAPIを公開してくれています。
なのでそれをPHPからcURLで叩いてそこそこ早く施錠・解錠したいと思います!

準備

・SesameとWifiアクセスポイント
今このページを見ている人なら既に持ってると思いますが一応

・無線LANルーター
スマホでローカルWebサーバーにアクセスするのに要ります。

・PHPでcURLが動かせるローカルWebサーバー
構築方法はググって下さい。
IPアドレスはスマホからアクセスできるように固定しといて下さい。
自分はRaspberryPiにNginxを入れてます。WebサーバーにできるNASとかもありますし何でもいいと思います。

・SesameIDとAPIキー
下記URLを参照しSesameIDとAPIキーを取得して下さい。
APIキー取得方法とセサミIDの確認方法

PHPファイルをローカルWebサーバーに配置

SesameIDとAPIキーは書き換えて下さい。

・施錠用PHP

lock.php
<?php
$url = 'https://api.candyhouse.co/public/sesame/<SesameID>';
$apikey = '<APIキー>';

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);

$data = array('command'=>'lock');
$data_json = json_encode($data);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: ' . $apikey, 'Content-Type: application/json'));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, $data_json);

curl_exec($ch);
curl_close($ch);
?>

・解錠用PHP

unlock.php
<?php
$url = 'https://api.candyhouse.co/public/sesame/<SesameID>';
$apikey = '<APIキー>';

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);

$data = array('command'=>'unlock');
$data_json = json_encode($data);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: ' . $apikey, 'Content-Type: application/json'));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, $data_json);

curl_exec($ch);
curl_close($ch);
?>

上記PHPファイルを作成したらドキュメントルートに配置します。
/var/www/html/lock.php
/var/www/html/unlock.php

施錠・解錠方法

スマホのWifiで無線LANルーターに接続し、ブラウザから
http://<ローカルWebサーバーのIPアドレス>/lock.php
にアクセスします。すると体感3秒位で施錠してくれます。

解錠も同様の手順でunlock.phpにアクセスします。

(ショートカットを作っておくと便利!)

参考文献

Sesame API

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

PHPで、ネストの深さがわからない配列中に特定のキーが含まれているかを調べる

意外とドンピシャのコードが見つからなかったので、備忘録として書いておきます。

function hasKey($array, $key)
{
    $hasKey = false;
    array_walk_recursive($array, function ($eachItem, $eachKey) use(&$hasKey, $key) {
        $hasKey |= $eachKey === $key;
    });
    return (bool)$hasKey;
}

array_reduce() が使えるのかと思ったんですが、ネストの下層まで潜ってreduceしてくれるわけではないようでした。

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

Google Calendar APIでイベント登録時に参加者を設定した時だけエラーになる

環境

  • PHP:7.4.7
  • google/apiclient:2.7.0

前提

  • サービスアカウントのキーファイル取得済み
  • G Suiteドメイン全体の委任を有効化済
  • https://www.googleapis.com/auth/calendarスコープのアクセス許可済み
  • イベント参加者は自ドメイン内の人

症状

参加者のいるイベント登録しようとすると以下のエラーメッセージが表示される。参加者がいないと問題なく登録される。

Service accounts cannot invite attendees without Domain-Wide Delegation of Authority.

原因

サービスアカウントそのままでは登録できない仕様らしい。
impersonate(日本語だとユーザーアカウントの成り代わり?)が必要だった。

サンプルコード

sample.php
/**
* @param string $calendarID イベント登録するカレンダーのID
* @param Google_Service_Calendar_Event $event イベント情報
*/
public function insertEvent($calendarID, $event) {
  $client = new Google_Client();
  $client->setApplicationName('Sample Calendar');
  $client->setScopes(array(Google_Service_Calendar::CALENDAR));
  $client->setAuthConfig('キーファイルのパス'));
  $client->setSubject('hoge@example.com');     // ←これが必要だった。
  $service = new Google_Service_Calendar($client);
  $service->events->insert($calendarId, $event);
}

参考

https://issuetracker.google.com/issues/141704931

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

PHPって使いづらい・・・

愚痴を連ねた記事です。(あくまで個人の感想です。)

生産性低くね・・・?

とふと思いました。仕事で半年程度ですが、使った感想を書いていきます。

PHPの特徴

PHPの特徴はWEB関係のフレームワークが多く存在し、WordPressなどもよく用いられます(WordPressはCMSですが・・・)。案件ではCAKE PHPやLaravelを用いて開発をしていましたし、WEB関係の実装はやりやすいとは感じました。

動的型付言語であり変数型をあまり考えずに変数を実装できるので、あまり頭を使わずに変数を利用できます。実際、文字列型と数値型をあまり意識せずに値を入れられるのは便利でした。

ただ生産性がちょっとね・・・

  • ロクなIDEが無い

    • Eclipse、VSCode、NetBeansと使いましたが、定義ジャンプが無かったり自動インポートが無かったりと不便でした。特にVS使ってて自動インポート無いのは辛い・・・。VSStudioでは使えたのに。
  • 動的型付けめんどくさ!

    • 数値と文字列考えなくて良いとは言ったが、引数型は合わせないといけない。これはフレームワークのせいかもしれませんが、i18nFormat使っていたら、string型は入らないとエラー吐きました。FrozenTime型しか取らないのね・・・:cry:

終わり

とまあ愚痴ってみたわけですが、お前が使いこなせてねーよで終わるかなと思います笑。
ただASP.Netを使った時は割とスラスラ書けて書き味も悪くなかったので、やはり言語の問題と思います。PHPじゃなくて他の言語、できればC#使いたいなぁと思いました。

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

SkyWayによるビデオ・音声通話の技術概要

この記事は「マイスター・ギルド:暑中見舞!夏のアドベントカレンダー2020」3日目の記事です。

初めに...

コロナの流行が始まったとき、「Stay Home」対策で自宅に閉じ込められたとき、ITワーカーの私たちは特に自宅からリモートで仕事をすることが可能でラッキーでした。
残念ながら、私たちのほとんどはリモートでの作業に慣れておらず、最初は上司、同僚、お客さん等とリモート通信が特に困難でした。
特殊なツールを使用しても改善されましたが、同じオフィスで作業するほど自然ではありません。
弊社のMeister Guildでもその新しい作業環境に答えるツールを探して色々なツールを使ってみた:

  • Zoom:ヴァーチャル背景を使える
  • Discord:完全に無料
  • Remo:ヴァーチャルルームに入れる、共有ホワイトボードもある
  • Spatial Chat:距離によると声の高さが変わる

ビデオ会議ができるツールはほかにもあります:

各ツールが得点と弱点を持つけど「これ!」ってなるツールがなかったので「私たちの理想なビデオ会議のツール作れるかな?」と思って調査することになりました。

ビデオ会議のツール作りの調査

そのようなツールを一から開発するのはとても大変な仕事になるので、開発をスピードアップするWebRTCフレームワークを探しました。
日本製で無料プランあり、NTTコミュニケーションズが作成したWebRTCフレームワークを見つけました:image.png
ユーザー認証をテストするために、認証付きのLaravelアプリケーションを作成し、ユーザーのメールをbase64でエンコーディングしてPeerIDとして使用しました。

SkyWayとは

ホームページによるとSkyWayは:

ビデオ・音声通話の機能をアプリケーションに簡単に実装できる、
マルチプラットフォームなSDK & フルマネージドなAPIサービスです。

無料プランで下記のSDKを使える:
- Javascript SDK
- iOS SDK
- Android SDK
- WebRTC Gateway
- APIキー認証

有料プランで録音SDK管理APIも使えるんですが例えば録音も録画も普通のMedia Capture and Streams API (Media Streams)でできる。

Javascriptサンプル:

ビデオ会議

SkyWay Room example

P2Pビデオ通話

SkyWay P2P Media example

P2Pテキスト通話

SkyWay P2P Data example

録音と録画

WebRTC samples MediaRecorder

P2Pビデオ通話

通話の相手は一人です。

基本

CDNからSDKをインポートする:

headタグ内
  <script src="https://cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>

カメラ映像を表示するvideo要素を追加する:

bodyタグ内
  <video id="my-video" width="400px" autoplay muted playsinline></video>

カメラ映像・マイク音声を取得する:

bodyタグ下部のscriptタグ内
  let localStream;

  // カメラ映像取得
  navigator.mediaDevices.getUserMedia({video: true, audio: true})
    .then( stream => {
    // 成功時にvideo要素にカメラ映像をセットし、再生
    const videoElm = document.getElementById('my-video')
    videoElm.srcObject = stream;
    videoElm.play();
    // 着信時に相手にカメラ映像を返せるように、グローバル変数に保存しておく
    localStream = stream;
  }).catch( error => {
    // 失敗時にはエラーログを出力
    console.error('mediaDevice.getUserMedia() error:', error);
    return;
  });

通話の相手のは「peer」と呼ばれてる。
通話出来るように自分のPeerオブジェクトを作成して相手のPeerオブジェクトと繋がる。

Peerオブジェクトの作成

Peerオブジェクトを作成するときに引数のIDを渡さない場合はランダムなIDが生成される:

scriptタグ内
        const peer = new Peer({
            key: '<SkyWayのAPIキー>',
            debug: 3
        });

PeerオブジェクトのIDはpeer.idで取得できる。

またはメールアドレスなどからIDを生成できる。
例えばLaravelのコントローラーでbase64にエンコード:

app/Http/Controllers/Controller.php
    public function index()
    {
        $user = Auth::user();
        return view('videochat',['user'=>['email'=>rtrim(base64_encode($user->email),"=")]]);
    }

ページでPeerオブジェクトに渡す:

scriptタグ内
            const peer = new Peer('{{$user['email']}}',{
                key: '<SkyWayのAPIキー>',
                debug: 3
            });

発信

相手のカメラ映像を表示するvideo要素を追加する:

bodyタグ内
  <video id="their-video" width="400px" autoplay muted playsinline></video>

相手へ発信してリスナーで接続することを待つ:

scriptタグ内
// 発信処理
const mediaConnection = peer.call('<相手のPeerID>', localStream);
setEventListener(mediaConnection);

接続ができたときにビデオ要素を設定する:

scriptタグ内
let remoteStream;
// イベントリスナを設置する関数
const setEventListener = mediaConnection => {
  mediaConnection.on('stream', stream => {
    // video要素にカメラ映像をセットして再生
    const videoElm = document.getElementById('their-video')
    videoElm.srcObject = stream;
    remoteStream = stream;
    videoElm.play();
  });
}

着信

相手側はPeerオブジェクトのcallイベントを待って着信の時ビデオ要素を設定する:

scriptタグ内
//着信処理
peer.on('call', mediaConnection => {
  mediaConnection.answer(localStream);
  setEventListener(mediaConnection);
});

映像・音声はオン・オフ等

マイク音声オフ

ミュートする:

scriptタグ内
    localStream.getAudioTracks().forEach(track => track.enabled = false);
音声オフ

相手の音声を削音する:

scriptタグ内
    remoteStream.getAudioTracks().forEach(track => track.enabled = false);

※ 音全部消したいときマイク音声もオフしなければならない。

カメラ映像オフ

カメラの映像を消す:

scriptタグ内
    localStream.getVideoTracks().forEach(track => track.enabled = false);
反響キャンセリング

反響を消す:

scriptタグ内
    localStream.getAudioTracks().forEach(track => {
        let constraints = track.getConstraints();
        constraints.echoCancellation = true;
        track.applyConstraints(constraints);
    });

ビデオ会議

ビデオ会議はビデオ通話との違いは2つ:
- 相手の数は一人以上になる
- roomオブジェクトで他のユーザーの存在(presence)が確認できる

基本

CDNからSDKをインポートする:

headタグ内
  <script src="https://cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>

カメラ映像を表示するvideo要素を追加する:

bodyタグ内
  <video id="js-local-stream"></video>

カメラ映像・マイク音声を取得する:

bodyタグ下部のscriptタグ内
  // カメラ映像取得
const localStream = await navigator.mediaDevices
    .getUserMedia({
      audio: true,
      video: true,
    })
    .catch(console.error);

  // Render local stream
  const localVideo = document.getElementById('js-local-stream');
  localVideo.muted = true;
  localVideo.srcObject = localStream;
  localVideo.playsInline = true;
  await localVideo.play().catch(console.error);

相手達のカメラ映像を表示する要素を追加する:

bodyタグ内
    <div class="remote-streams" id="js-remote-streams"></div>

Peerオブジェクトの作成

PeerオブジェクトのIDを生成する。
例えばLaravelのコントローラーでbase64にエンコード:

app/Http/Controllers/Controller.php
    public function index()
    {
        $user = Auth::user();
        return view('videochat',['user'=>['email'=>rtrim(base64_encode($user->email),"=")]]);
    }

ページでPeerオブジェクトに渡す:

scriptタグ内
            const peer = new Peer('{{$user['email']}}',{
                key: '<SkyWayのAPIキー>',
                debug: 3
            });

roomオブジェクト

Peerオブジェクトが生成された後でroomに参加する:

scriptタグ内
  peer.on('open', () => {
    const room = peer.joinRoom('test', {
      mode: 'sfu',
      stream: localStream,
    });
  });

※ roomは2つのタイプがある:'sfu'(通信がサーバーを通す)と'mesh'(通信が直接にPeerへ発信する)。

roomのイベント

open

roomに入ったとき:

scriptタグ内
    room.once('open', () => {
      ...
    });
close

roomを出たとき:

scriptタグ内
    room.once('close', () => {
      // テキスト通信を止める
      sendTrigger.removeEventListener('click', onClickSend);
      // 相手達のビデオストリームを止める
      Array.from(remoteVideos.children).forEach(remoteVideo => {
        remoteVideo.srcObject.getTracks().forEach(track => track.stop());
        remoteVideo.srcObject = null;
        remoteVideo.remove();
      });
    });
peerJoin

一人がroomに入ったとき:

scriptタグ内
    room.on('peerJoin', peerId => {
      ...
    });
peerLeave

一人がroomを出たとき:

scriptタグ内
    room.on('peerLeave', peerId => {
      // ストリームを閉じてvideo要素を消す
      const remoteVideo = remoteVideos.querySelector(
        `[data-peer-id=${peerId}]`
      );
      remoteVideo.srcObject.getTracks().forEach(track => track.stop());
      remoteVideo.srcObject = null;
      remoteVideo.remove();
    });
stream

roomに入った一人のストリームを表示:

scriptタグ内
    room.on('stream', async stream => {
      // video要素を生成
      const newVideo = document.createElement('video');
      newVideo.srcObject = stream;
      newVideo.playsInline = true;
      // peerLeaveイベントのときにストリームを見つけるためにpeerIdを付ける
      newVideo.setAttribute('data-peer-id', stream.peerId);
      remoteVideos.append(newVideo);
      await newVideo.play().catch(console.error);
    });

data

メッセージを着信したとき:

scriptタグ内
    room.on('data', ({ data, src }) => {
      // メッセージと発信者を表示
      messages.textContent += `${src}: ${data}\n`;
    });

テキスト発信

メッセージを発信するとき:

scriptタグ内
    sendTrigger.addEventListener('click', onClickSend);

    function onClickSend() {
      // websocketでroomの皆さんにメッセージを起こる
      room.send(localText.value);
      // メッセージと発信者を表示
      messages.textContent += `${peer.id}: ${localText.value}\n`;
      // インプットを消す
      localText.value = '';
    }
  });

映像・音声はオン・オフ等

マイク音声オフ

ミュートする:

scriptタグ内
    localStream.getAudioTracks().forEach(track => track.enabled = !audioInStatus);
音声オフ

相手の音声を削音する:

scriptタグ内
    remoteVideos.forEach(video => {
      video.srcObject.getAudioTracks().forEach(track => track.enabled = !audioOutStatus);
    });

※ 音全部消したいときマイク音声もオフしなければならない。

カメラ映像オフ

カメラの映像を消す:

scriptタグ内
    localStream.getVideoTracks().forEach(track => track.enabled = false);
反響キャンセリング

反響を消す:

scriptタグ内
    localStream.getAudioTracks().forEach(track => {
        let constraints = track.getConstraints();
        constraints.echoCancellation = true;
        track.applyConstraints(constraints);
    });

音声通話

navigator.mediaDevices.getUserMedia()の引数でメディアのタイプ(画像・音声・両方)等を選択できる:

scriptタグ内
  // カメラ映像取得
const localStream = await navigator.mediaDevices
    .getUserMedia({
      audio: true,
      video: false,
    })

画面共有

自分の画面をMediaStreamとして取得できる

scriptタグ内
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });

このメディアストリームを使って画面共有機能が実現できる。

終わりに...

WebRTCを使用できるのでウェブアプリケーションの元に使用できるフレームワークと思いました。

その調査をして私の理想なリモートワークのツールについていっぱいなアイデアが生まれて社長が本気で開発始めようのは本気になって欲しいです。

参考

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

【Unity】UnityWebRequestで画像ファイルを送信してPHPで受け取る最小実装

UnityWebRequestで画像ファイルを送信しPHPで受け取る最小の実装を備忘録として残します。

Unity側

前提条件として画像ファイルはバイト配列として取得している状態を作ります。

以前執筆したコチラの記事でTextureをPNGにエンコードしたバイト配列作成までを紹介しているので、参考になるかもしれません。
【Unity】RawImageのTextureをPNGにエンコードする方法

IEnumerator Send(byte[] bytes)
{
    var form = new WWWForm();
    // "file"はPHP側で$_FILESから取得するキー ex)$_FILES["file"]
    form.AddBinaryData("file", bytes, "image.png", "image/png");
    var req = UnityWebRequest.Post("http://localhost:8080/api.php", form);
    yield return req.SendWebRequest();
}

PHP側

api.php
$fileName = $_FILES["file"]["name"];
// api.phpと同じディレクトリに保存されます
move_uploaded_file($_FILES["file"]["tmp_name"], $fileName);

最後に

エラーハンドリングを考慮するとUnityもPHPも記述することはまだまだありますが、本記事では肝となる部分の実装のみを紹介しました。

環境

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