[laravel8.x] hyn/multi-tenant その2

fqdnとは”Fully Qualified Domain Name”の略
トップレベルドメイン(TLD)までのすべてのラベルを含むドメイン名

routes/tenants.php

use Illuminate\Support\Facades\Route;

Route::get('/', function(){
	return view('tenants.home');
});

resources/views/tenants/home.php

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Document</title>
</head>
<body>
	<h1>Welcom!</h1>
	<p>This will be your <b>dashboard</b> for every tenant in your system.</p>
</body>
</html>

config/tenancy.php
L make sure that path would be right.

    'routes' => [
        'path' => base_path('routes/tenants.php'),
        'replace-global' => false,
    ],

### creating first tenant
$ php artisan make:command tenant/create
app/Console/Commands/tenant/create.php

namespace App\Console\Commands\tenant;

use Illuminate\Console\Command;

use Illuminate\Support\Str;

use Hyn\Tenancy\Models\Hostname;
use Hyn\Tenancy\Models\Website;
use Hyn\Tenancy\Repositories\HostnameRepository;
use Hyn\Tenancy\Repositories\WebsiteRepository;

class create extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    // protected $signature = 'command:name';
    protected $signature = 'tenant:create {fqdn}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Given a unique tenant name, creates a new tenant in the sytem';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {

        $fqdn = sprintf('$s.$s', $this->argument('fqdn'), env('APP_DOMAIN'));

        $website = new Website;
        $website->uuid = env('TENANT_WEBSITE_PREFIX') . Str::random(6);
        app(WebsiteRepository::class)->create($website);

        $hostname = new Hostname;
        $hostname->fqdn = $fqdn;
        $hostname = app(HostnameRepository::class)->create($hostname);
        app(HostnameRepository::class)->attach($hostname, $website);
        // return 0;
    }
}

.env

TENANT_WEBSITE_PREFIX=tenancy_demo_

php artisan tenant:create demo

mysql> show tables;
+————————+
| Tables_in_tenancy_demo |
+————————+
| failed_jobs |
| hostnames |
| migrations |
| password_resets |
| users |
| websites |
+————————+
6 rows in set (0.31 sec)

mysql> select * from websites;
+—-+———————+———————+———————+————+——————————–+
| id | uuid | created_at | updated_at | deleted_at | managed_by_database_connection |
+—-+———————+———————+———————+————+——————————–+
| 1 | tenancy_demo_NcmmUL | 2021-08-08 04:39:43 | 2021-08-08 04:39:43 | NULL | NULL |
| 2 | tenancy_demo_IbSPrJ | 2021-08-08 04:47:13 | 2021-08-08 04:47:13 | NULL | NULL |
+—-+———————+———————+———————+————+——————————–+
2 rows in set (0.37 sec)

conf.dの名前解決がうまくいっていないような印象。
あれ? マルチテナントの場合、サブドメインの設定ってするんだっけ?

[Laravel 8.x] Multi-Tenant implementation with hyn/multi-tenant

### プロジェクト作成
$ composer create-project –prefer-dist laravel/laravel tenancy-demo
$ cd tenancy-demo
$ composer require hyn/multi-tenant

### mysql driverインストール
$ composer require tenancy/db-driver-mysql
Problem 1
– tenancy/db-driver-mysql[v1.3.0, …, 1.x-dev] require doctrine/dbal ^2.9 -> found doctrine/dbal[v2.9.0, …, 2.13.x-dev] but the package is fixed to 3.1.1 (lock file version) by a partial update and that version does not match. Make sure you list it as an argument for the update command.
– Root composer.json requires tenancy/db-driver-mysql ^1.3 -> satisfiable by tenancy/db-driver-mysql[v1.3.0, 1.x-dev].

doctrine/dbalを2系でインストールする必要がありそう。

$ composer require “doctrine/dbal:2.*”
$ composer require tenancy/db-driver-mysql
今度は上手くいきました。

config/app.php

    'providers' => [
        // 省略
        Hyn\Tenancy\Providers\TenancyProvider::class,
        Hyn\Tenancy\Providers\WebserverProvider::class,

    ],

file update
$ php artisan vendor:publish –tag tenancy

config/database.php
L connectionでmysqlをコピペしてsystemを追加します。

    'connections' => [

        'system' => [
            'driver' => 'mysql',
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('DB_DATABASE', 'tenancy'),
            'username' => env('DB_USERNAME', 'hoge'),
            'password' => env('DB_PASSWORD', 'fuga'),
            'unix_socket' => env('DB_SOCKET', ''),
            'charset' => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix' => '',
            'prefix_indexes' => true,
            'strict' => true,
            'engine' => null,
            'options' => extension_loaded('pdo_mysql') ? array_filter([
                PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
            ]) : [],
        ],
    ],

.env

TENANCY_HOST=127.0.0.1
TENANCY_PORT=3306
TENANCY_DATABASE=tenancy_demo
TENANCY_USERNAME=hoge
TENANCY_PASSWORD=fuga

mysql> create database tenancy_demo;
Query OK, 1 row affected (0.01 sec)

$ php artisan migrate –database=system
Migrating: 2017_01_01_000003_tenancy_websites
Migrated: 2017_01_01_000003_tenancy_websites (16.88ms)
Migrating: 2017_01_01_000005_tenancy_hostnames
Migrated: 2017_01_01_000005_tenancy_hostnames (44.02ms)
Migrating: 2018_04_06_000001_tenancy_websites_needs_db_host
Migrated: 2018_04_06_000001_tenancy_websites_needs_db_host (12.21ms)

### Trying your installation
multi tenantのテストを行うには、apacheかlaravel Valetでテストする必要がある
localhost:[any_port] では動かない
tenant.system.extension でないとダメ

なんだとおおおおおおおおおおおおお
どうりでphp artisan serveでvagrant環境で動かないと思った。
うーん、困った。さくらVPSでテストするか。
というか、Laravel Valetってなんだ??

### laravel valetとは?
macOSミニマリスト向けのLaravel開発環境
https://readouble.com/laravel/8.x/ja/valet.html

うーむ、結局apacheでやらないと駄目そうだな。。

[Laravel 8.45.1] マルチテナントアーキテクチャ

Laravelでのマルチテナントは、tenancy/tenancyを使用する。
tenancy/tenancyと、ベースの機能のみで必要な分は後から追加するtenancy/frameworkがある。
tenancy/framework推奨

$ composer require tenancy/framework
$ php artisan make:model Organization -m

create_organizations_table.php

public function up()
    {
        Schema::create('organizations', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->timestamps();
        });
    }

$ php artisan migrate

Organization.php

use Illuminate\Http\Request;
use Tenancy\Identification\Contracts\Tenant;

class Organization extends Model implements Tenant
{
    use HasFactory;

    protected $fillable = [
    	'name',
    	'subdomain',
    ];

    protected $dispatchesEvents = [
    	'created' => \Tenancy\Tenant\Events\Created::class,
    	'updated' => \Tenancy\Tenant\Events\Updated::class,
    	'deleted' => \Tenancy\Tenant\Events\Deleted::class,
    ];

    public function getTenantKeyName(): string {
    	return 'name';
    }

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

    public function getTenantIdentifier(): string {
    	return get_class($this);
    }
}

Tenancy\Identification\Concerns\AllowsTenantIdentification を使うだけでもできる
-テナントが追加されたら、テナント用のデータベースを作成してマイグレーション
-テナントが更新されたらマイグレーション実行
-テナントが削除されたらデータベース削除
-テナントを識別するため、Resolverに登録

app/Providers/AppServiceProvider

use Tenancy\Identification\Contracts\ResolvesTenants;

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        //
        $this->app->resolving(ResolvesTenants::class, function(ResolvesTenants $resolver){
            $resolver->addModel(Organization::class);
            return resolver;
        });
    }
}

MySQLのデータベースドライバをインストール
$ composer require tenancy/db-driver-mysql

affects connections : テナントのDBにテナントのDBにアクセスできるようにする
$ composer require tenancy/affects-connections

tenancyの拡張パッケージは Event/Listner で構成
$ php artisan make:listener ResolveTenantConnection
/listeners/ResolveTenantConnection.php

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Tenancy\Identification\Contracts\Tenant;
use Tenancy\Affects\Connections\Contracts\ProvidesConfiguration;

class ResolveTenantConnection
{
    public function handle(Resolving $event)
    {
        //
        return $this;
    }

    public function configure(Tenant $tenant): array {
        $config = [];

        event(new Configuring($tenant, $config, $this));

        return $config;
    }
}

/app/Providers/EventServiceProvider.php

use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [

        \Tenancy\Affects\Connections\Events\Resolving::class => [
            App\Listeners\ResolveTenantConnection\ResolveTenantConnection::class,
        ],
        Registered::class => [
            SendEmailVerificationNotification::class,
        ],
    ];
}

$ composer require tenancy/hooks-migration
$ php artisan make:listener ConfigureTenantMigrations

use Tenancy\Hooks\Migration\Events\ConfigureMigrations;
use Tenancy\Tenant\Events\Deleted;

class ConfigureTenantMigrations
{
    public function handle(ConfigureMigrations $event)
    {
        //
        if($event->event->tenant){
            if($event->event instanceof Deleted){
                $event->disable();
            } else {
                $event->path(database_path('tenant/migrations'));
            }
        }
    }
}

EventServiceProvider.php

use Illuminate\Support\Facades\Event;
use App\Listeners\ConfigureTenantMigrations;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
        $this->app->resolving(ResolvesTenants::class, function(ResolvesTenants $resolver){
            $resolver->addModel(Organization::class);
            return resolver;
        });
    }

    protected $listen = [
        \Tenancy\Hooks\Migration\Events\ConfigureMigrations::class => [
            ConfigureTenantMigrations::class,
        ],
    ];
}

$ php artisan make:listener ConfigureTenantSeeds

use Database\Tenant\Seeders\DatabaseSeeder;
use Tenancy\Hooks\Migration\Events\ConfigureSeeds;
use Tenancy\Tenant\Events\Deleted;
use Tenancy\Tenant\Events\Updated;

class ConfigureTenantSeeds
{
    public function handle(ConfigureSeeds $event)
    {
        //
        if($event->event->tenant){
            if($event->event instanceof Deleted || $event->event instanceof Updated){
                $event->disable();
            } else {
                $event->seed(DatabaseSeeder::class);
            }
        }
    }
}

EventServiceProvider.php

    protected $listen = [

        \Tenancy\Affects\Connections\Events\Resolving::class => [
            App\Listeners\ResolveTenantConnection\ResolveTenantConnection::class,
        ],
        \Tenancy\Hooks\Migration\Events\ConfigureMigrations::class => [
            ConfigureTenantMigrations::class,
        ],
        \Tenancy\Hooks\Migration\Events\ConfigureSeeds::class => [
            ConfigureTenantSeed::class
        ],
        Registered::class => [
            SendEmailVerificationNotification::class,
        ],
    ];

.env.testing

TENANT_SLUG=test

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=test_central
DB_USERNAME=root
DB_PASSWORD=password

あれ? これでどうやって実装するのかようわからんな。。

[Laravel7.x] マルチテナントアーキテクチャで構築する2

8系がうまくいかないので、7系でやります。

$ composer create-project –prefer-dist laravel/laravel tenancy “7.*”
$ cd tenancy
$ php artisan -V
Laravel Framework 7.30.4

config/database.phpと.envを編集
$ composer require “hyn/multi-tenant:5.6.*”
$ php artisan vendor:publish –tag=tenancy
$ php artisan migrate –database=system
mysql> use tenancy
mysql> show tables;
mysql> describe users;
mysql> describe hostnames;
+————————-+—————–+——+—–+———+—————-+
| Field | Type | Null | Key | Default | Extra |
+————————-+—————–+——+—–+———+—————-+
| id | bigint unsigned | NO | PRI | NULL | auto_increment |
| fqdn | varchar(255) | NO | UNI | NULL | |
| redirect_to | varchar(255) | YES | | NULL | |
| force_https | tinyint(1) | NO | | 0 | |
| under_maintenance_since | timestamp | YES | | NULL | |
| website_id | bigint unsigned | YES | MUL | NULL | |
| created_at | timestamp | YES | | NULL | |
| updated_at | timestamp | YES | | NULL | |
| deleted_at | timestamp | YES | | NULL | |
+————————-+—————–+——+—–+———+—————-+

### テナントの作成
テナントを作成すると同時にデータベースにテーブルが作成されるが、テーブルの作成には前準備が必要となる。
テナント用のテーブルを作成するmigration fileはmigrations/tenant以下に作成する
https://tenancy.dev/docs/hyn/5.6/migrations

config/tenancy.php

'tenant-migrations-path' => database_path('migrations/tenant'),

'uuid-limit-length-to-32' => env('LIMIT_UUID_LENGTH_32', true),

$ cd database/migrations
$ mkdir tenant
$ ls
2014_10_12_000000_create_users_table.php
2014_10_12_100000_create_password_resets_table.php
2017_01_01_000003_tenancy_websites.php
2017_01_01_000005_tenancy_hostnames.php
2018_04_06_000001_tenancy_websites_needs_db_host.php
2019_08_19_000000_create_failed_jobs_table.php
tenant
// usersとpassword_resetsもtenantの中に入れる
$ cp 2014_10_12* tenant

### テナント作成
https://tenancy.dev/docs/hyn/5.6/creating-tenants
routes/web.php

use Hyn\Tenancy\Models\Hostname;
use Hyn\Tenancy\Contracts\Repositories\HostnameRepository;
use Hyn\Tenancy\Models\Website;
use Hyn\Tenancy\Contracts\Repositories\WebsiteRepository;


Route::get('create_tenant', function () {

    $website = new Website;
    app(WebsiteRepository::class)->create($website);

    $hostname = new Hostname;
    
    $hostname->fqdn = 'test.localhost';
    $hostname = app(HostnameRepository::class)->create($hostname);

    app(HostnameRepository::class)->attach($hostname, $website);
    
	return redirect('/');

});

$ php artisan serve –host 192.168.33.10 –port 8000
http://192.168.33.10:8000/create_tenant

mysql> select * from hostnames;
+—-+—————-+————-+————-+————————-+————+———————+———————+————+
| id | fqdn | redirect_to | force_https | under_maintenance_since | website_id | created_at | updated_at | deleted_at |
+—-+—————-+————-+————-+————————-+————+———————+———————+————+
| 1 | test.localhost | NULL | 0 | NULL | 1 | 2021-02-13 09:07:52 | 2021-02-13 09:07:52 | NULL |
+—-+—————-+————-+————-+————————-+————+———————+———————+————+
1 row in set (0.00 sec)

mysql> select * from websites;
+—-+———————————-+———————+———————+————+——————————–+
| id | uuid | created_at | updated_at | deleted_at | managed_by_database_connection |
+—-+———————————-+———————+———————+————+——————————–+
| 1 | 214594e5f86a418bbd990b6583d37131 | 2021-02-13 09:07:52 | 2021-02-13 09:07:52 | NULL | NULL |
+—-+———————————-+———————+———————+————+——————————–+
1 row in set (0.00 sec)
mysql> show databases;

OK, なんとなくマルチテナントの仕組みはわかったかも。
vagrantで開発している場合、mac側で名前解決せなあかんね。

[Laravel 8.x] Composerのパッケージ作成手順しようと思ったが…

composer.json

{
    "name": "laravel/laravel",
    "type": "project",
    "description": "The Laravel Framework.",
    "keywords": [
        "framework",
        "laravel"
    ],
// 省略

composer.jsonから、name -> “laravel/laravel” となっていることがわかる。

composer.json

"autoload": {
        "psr-4": {
            "App\\": "app/",
            "Database\\Factories\\": "database/factories/",
            "Database\\Seeders\\": "database/seeders/",
            "Laravel\\Laravel\\" : "app/Providers/" // 追加
        }
    },


    "extra": {
        "laravel": {
            "dont-discover": [],
            "providers": [
                "Laravel\\Laravel\\HogeServiceProvider"
            ]
        }
    },
[/code]

$ php artisan make:provider HogeServiceProvider
app/Providers/HogeServiceProvider.php

namespace Laravel\Laravel; // 修正
use Illuminate\Support\ServiceProvider;

class HogeServiceProvider extends ServiceProvider
{
    public function register()
    {}
    public function boot()
    {
        //
        dump("What's up Laravel!");
    }
}

composer.json

"repositories": [
        {
            "type": "path",
            "url" : "packages/username/hoge",
            "symlink": true
        }
    ],
    "require": {
        // 省略
        "hpscript/hoge": "dev-master"
    },

$ composer update

[RuntimeException]
The `url` supplied for the path (packages/username/hoge) repository does not exist

うーん、なんかちゃうな。。
packageの作り方を良く理解してないようだ。

[Laravel8.16.0] CSVをimportする

1.fopenして、fputcsvで作成する手法。

public function csv(){
        $ary = [
            ["山田", "12", "O",""],
            ["田中", "20", "A",""],
            ["吉田", "18", "AB",""],
            ["伊藤", "19", "B", "エンジニア"]
        ]; 

        foreach($ary as $key => $value){
             array_splice($ary[$key], 1, 0, "");
        }

        $column = ["名前", "無記入", "年齢", "血液型", "備考"];
        array_unshift($ary, $column);

        $filename = "hogehoge.csv";
        $f = fopen($filename, "w");
        stream_filter_prepend($f,'convert.iconv.utf-8/cp932');
        if($f) {
            foreach($ary as $line){
                fputcsv($f, $line);
            }
        }
        header('Content-Type: application/octet-stream');
        header('Content-Length: '.filesize($filename));
        header('Content-Disposition: attachment; filename=hogehoge.csv');

        readfile($filename);
    }

2. StreamedResponseを使う書き方

public function csv(){
        $data = User::all(); 
        $response = new StreamedResponse(function() use($data){
                $stream = fopen('php://output', 'w');
                stream_filter_prepend($stream, 'convert.iconv.utf-8/cp932//TRANSLIT');
                fputcsv($stream, ['id','name','email',]);
                foreach($data as $user){
                     fputcsv($stream, [$user['id'],$user['name'],$user['email'],]);
                }
                fclose($stream);
        });
        $response->headers->set('Content-Type', 'application/octet-stream');
        $response->headers->set('Content-Disposition', 'attachment; filename="user.csv"');
        return $response;
    }

やってることは一緒なんだけど、StreamedResponseの方が良い。

[Laravel8.16.0] バッチ処理を実装する

$ php artisan make:command TestBatch

-> app/Console/Commands に TestBatch.phpが出来ます。

protected $signature = 'batch:test';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        echo "hello";
    }

$ php artisan list
batch
batch:test Command description

$ php artisan batch:test
hello

あとはsudo vi /etc/crontab で、$ php artisan batch:testを実行すればいいだけ。
そうだ、思い出した。

Laravel7.x、EC2でphp artisan down –allow=${ip}が効かない時

本番環境でメンテナンスモードにして、特定のIPだけ操作を許可したい場合は、

$ php artisan down --allow=${ip}

と書く。EC2で実行したところ、指定したipでもメンテナンスモードの画面に。

EC2 ELB: ELBを使って、SSL対応している

ELBを挟んでいるため、ip取得のclassにHTTP_X_FORWARDED_FORを追加する
$ sudo vi vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/CheckForMaintenanceMode.php

public function handle($request, Closure $next)
    {
        if ($this->app->isDownForMaintenance()) {
            $data = json_decode(file_get_contents($this->app->storagePath().'/framework/down'), true);

            // 追加
            if (isset($data['allowed']) && IpUtils::checkIp($request->server->get('HTTP_X_FORWARDED_FOR'), (array) $data['allowed'])) {
                return $next($request);
            }

            if (isset($data['allowed']) && IpUtils::checkIp($request->ip(), (array) $data['allowed'])) {
                return $next($request);
            }

            if ($this->inExceptArray($request)) {
                return $next($request);
            }

            throw new MaintenanceModeException($data['time'], $data['retry'], $data['message']);
        }

        return $next($request);
    }

※HTTP_X_FORWARDED_FOR: リバースプロキシを挟んだアクセス元のIPを取得する

これで、メンテナンスモード時に、特定IPのみ操作できるようになります。
$ php artisan down –allow=${myIP}
$ php artisan up

Laravel 6.x command(スケジューラ)の使い方

### make:command
$ php artisan make:command TestCommand

app/Console/Commands/TestCommand.php

protected $signature = 'command:testcommand';
public function handle()
    {
        //
        echo "test command execute!";
    }

app/Console/Kernel.php

 protected $commands = [
        //
        \App\Console\Commands\TestCommand::class,
    ];

    /**
     * Define the application's command schedule.
     *
     * @param  \Illuminate\Console\Scheduling\Schedule  $schedule
     * @return void
     */
    protected function schedule(Schedule $schedule)
    {
        // $schedule->command('inspire')
        //          ->hourly();
        $schedule->command('command:testcommand')->daily();
    }

### command実行
$ php artisan list;
$ php artisan command:testcommand;

$ crontab -e

実用性から考えると、バッチ処理はcronで直書きの方が楽そうですが、Githubなどで全てソースコードで管理したい場合は使えるかもしれません。どちらが良いかは好みやコンセンサスでしょうか。

Laravel Logの設定

### .env
Log channelはdefaultでstackに設定されている

LOG_CHANNEL=stack

### config/logging.php
stackで、channelsは’single’に設定されています。

'channels' => [
        'stack' => [
            'driver' => 'stack',
            'channels' => ['single'],
            'ignore_exceptions' => false,
        ],

        'single' => [
            'driver' => 'single',
            'path' => storage_path('logs/laravel.log'),
            'level' => 'debug',
        ],
        // 省略
    ],

### ログの出力先
storate/logs/laravel.log

###コマンドラインで出力
less +F storage/logs/laravel.log

### controllerからログ出力
$ php artisan make:controller –resource LogController

Route::get('log', 'LogController@index');
public function index()
    {
        //
        \Log::info('ログ出力test');
        return 'test';
    }

heplerを使う場合

logger()->info('something has happened');

laravel.log

[2020-02-24 14:13:10] local.INFO: ログ出力test 
[2020-02-24 14:22:10] local.INFO: something has happened  

### ログレベル
emergency, alert, critical, error, warning, notice, info, debug

ログの設定は、運用体制と併せて柔軟に検討する必要がある。