それマグで!

知識はカップより、マグでゆっくり頂きます。 takuya_1stのブログ

習慣に早くから配慮した者は、 おそらく人生の実りも大きい。

ファイル名の最大長の限界を、ストレージのフォーマットタイプごとに調べる

ファイル名の限界を調べる。

windows の人からもらった zip ファイルが 展開できなくて、ファイル名の長さの問題だったので、限界値(最大のファイル名の長さ)をぱぱぱっと調べた。

ファイルシステムごとに、ファイルの文字長(ファイルの文字サイズ)が違っていて、本当に困るんですよ。

日本語だと文字数ほとんど使えないLinux

Linuxはファイルの文字数が255バイトになっているので、日本語UTF-8のとき文字数が極めて少ない。

フォーマット 日本語の最大可能文字数
ext4 85文字
btfs 85文字
NTFS 255文字
exFAT 255文字

UTF-8が3バイト前後というのはわかるのですが、85文字はマジでやばい。少なすぎませんかね。

これは、旧来のシステムと互換性切ってでも文字数はほしいところです。LinuxのSambaとかでファイル名が長くて保存されないってよくある問題。ずっと解決しないので、これから先も解決することな無いんだと思う。

カーネルコンパイル時にファイル名のサイズの上限を変えてコンパイルさせれば、ワンチャンあるけど、それした場合、アップデートどうすんの感。

ちなみに、Linuxから実験してるのでNTFSexFATLinuxでマウントしてます。マウントしてたら最大長突破できるのなら、ext4もマウントオプションで突破できるのではないかと思うんだけどね。

XFSやzfsは調べなかったけど、ex4と結果は同じです、同じ定数(NAME_MAX )見てるので。Linuxでマウントしてる限りは、255が適用されちゃう。

linux で現在の最大長を調べる

255 バイトと決まっているのだから、別に調べなくてもいいのだが。調べたいときは次のようにする。

getconf -a | grep -P  '^NAME_MAX'
NAME_MAX                           255

実験に使用したコード

Linux環境で UTF8 で実行を前提に。

ファイル名の文字列の長さを調べていく。

    pwd = Dir.pwd.to_s
    (1..256).map{|i| 
        begin 
            str = ""*i
            FileUtils.touch(str)
            FileUtils.remove(str)
        rescue => e 
            printf "最大のファイルサイズは %3d バイトで、日本語だと %d 文字\n", 
            (""*(i-1)).bytesize ,  (""*(i-1)).size
            break
        end
    }

ディレクトリについても調べる

    pwd = Dir.pwd.to_s

    (1..256).map{|i| 
        begin 
            pwd = Dir.pwd.to_s
        str = "/あ"*i
        FileUtils.mkdir_p(pwd+str)
        FileUtils.rm_rf(pwd+str)
        puts pwd+str
        rescue => e 
            printf "最大のファイルサイズは %3d バイトで、日本語だと %d 文字\n", 
            (""*(i-1)).bytesize ,  (""*(i-1)).size
            printf "パス名を含めた場合、最大のファイルサイズは %3d バイトで、日本語だと %d 文字\n", 
            (pwd+'/'+""*(i-1)).bytesize ,  (pwd+'/'+""*(i-1)).size
            break
        end
    }

ext4 は 255バイト(UTF-8で85文字)で上限

ext4

ただしディレクトリは別カウント

ext4ディレクト

btrfs も 85文字で上限

btrfs

NTFS は「文字数」で255文字

NTFS

exFAT は「文字数」で255文字

exFAT

linuxのどこで定義されているのか。

cat /usr/include/linux/limits.h
/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef _LINUX_LIMITS_H
#define _LINUX_LIMITS_H

#define NR_OPEN         1024

#define NGROUPS_MAX    65536    /* supplemental group IDs are available */
#define ARG_MAX       131072    /* # bytes of args + environ for exec() */
#define LINK_MAX         127    /* # links a file may have */
#define MAX_CANON        255    /* size of the canonical input queue */
#define MAX_INPUT        255    /* size of the type-ahead buffer */
#define NAME_MAX         255    /* # chars in a file name */
#define PATH_MAX        4096    /* # chars in a path name including nul */
#define PIPE_BUF        4096    /* # bytes in atomic write to a pipe */
#define XATTR_NAME_MAX   255    /* # chars in an extended attribute name */
#define XATTR_SIZE_MAX 65536    /* size of an extended attribute value (64k) */
#define XATTR_LIST_MAX 65536    /* size of extended attribute namelist (64k) */

#define RTSIG_MAX         32

#endif

btrfs とかは回避できなくもない。

btrfs でマウントしたボリュームをを、cifs/smb 経由でvfat でマウントし直せば。。。。できなくはないが後で地獄を見ることになる。

参考資料

cat /usr/include/linux/limits.h

https://serverfault.com/questions/9546/filename-length-limits-on-linux

https://superuser.com/questions/1109959/reconstituting-long-file-names-lfn-from-8-3-on-nas-server-after-system-crash

laravelの設定(config) のキャッシュと実行環境(テスト環境testing/local ) の関係

laravel の env / config の関係について。

env と configキャッシュで testing のデータベースが使われずにパニクった。

TL;DR

.env.testing を使ってる場合、テスト実行する前には、cache:clearする。

laravel の実行環境と設定について

実行環境と設定は、.env ファイルと、config によって変わる。

実行環境

実行環境は、 envで決まる。 env は .env ファイルで決める。

.env ファイル

環境変数(OS/シェル)の実行環境の変数 Environment (通称ENV)の値を上書きする。自分で作る。

.env.testing ファイル

テスト実行時に使うファイル。自分で作る。

設定ファイル。

設定が書き込まれたファイル。

config/xxxx.php ファイル。

laravel 内で使うのは主にこれ。xxxx の部分が名前空間になっている。

config/ と ファイル名(ex:config/database.php)の関係

ファイル名が名前空間プレフィックス・カテゴリ)になるように調整される。

たとえば、database.php の場合は、次のようになる

ファイル名 名前空間
config/database.php database
config/queue.php queue

設定は名前空間を使ってアクセスする

config/queue.php を取得

config('queue.myname');

config と env の関係

殆どの場合、config はロード時に .env を参照するように書かれている。

config/session.php

<?php 
return [
 'lifetime' => env('SESSION_LIFETIME', 120),
];

config() を呼び出し、env() は ENV(+.env)を呼び出すのがわかっっている。

config 設定ファイルの中で env() が多用される。

config と env の呼び出し順(キャッシュなし)

config() が実行されるときに、env (名前, デフォルト値)への呼び出しのショートカットになっている。

呼び出しの順序が次のようになっている。

config() 
    -> env() 
        -> ENV(OS/シェル) 
        ->  デフォルト値

config は初期ロード前に変数にまとめられる。

configは一旦変数にキャッシュされる。というか config/ に散らばるArrayをまとめて一つにしている。var_exportしてますね。

config キャッシュ

config は、毎回ロードを避けるためにキャッシュすることが可能。

config のキャッシュ保存。

キャッシュ保存先は次のとおりになる。

bootstrap/cache/config.php

bootstrapを見ればわかるが、env() の結果で置き換わっているのがわかるでしょう。var_exportしてますね。

.env と .env.testing とキャッシュの関係

config が env() から取ってきて、env() が .env から取ってくるのはわかった。 だったら、.env.testing はいつ使うんだよ。.env.testingはどうなるの。

今使ってるのはどっちなんだよ問題。

キャッシュがあればキャッシュを使います。

env()の結果をconfigとしてキャッシュしてるんですから。testing/dev環境ではキャッシュを使ってはいけません。その理由をコードを動かして見ていきます。

キャッシュがあるときの優先度

先程の優先度の関係は、キャッシュが優先です。

config
   ->キャッシュある
   -> キャッシュ無い
    -> config() 
            -> env() 
                -> ENV(OS/シェル) 
                    ->  デフォルト値

であったとわかりますよね。

実際に試してみるとよくわかりますよね

testing 環境とキャッシュを試してみる、

config をダンプするコマンドを作って試してみます。

ファイルを作ります。

php artisan make:command MySample

コードを書きます。

namespace App\Console\Commands;
use Illuminate\Console\Command;

class MySample extends Command {
  
  protected $signature = 'my:sample';
  
  public function handle () {
    dd(config('my.name'));
  }
}

設定を作成します。

config/my.php

<?php

return ['name' => env("MY_NAME"),];

.envを2つ書きます。

開発用と、テスト用の2つのenv ファイルを用意します。

.env

MY_NAME=takuya-prod

.env.testing ファイルに書きます。

.env.testing

MY_NAME=takuya-testing

キャッシュをクリアして動作を確認します。

確認のために、まずキャッシュをクリアします。

$ php8.0 artisan config:clear
Configuration cache cleared!

通常のconfig 確認

通常環境でコマンドを実行してconfigを見ます。

$ php8.0 artisan my:sample
"takuya-prod"

テスト環境でconfig 確認

testing 環境でコマンドを実行してconfigを見ます。

$ APP_ENV=testing php8.0 artisan my:sample
"takuya-testing"

キャッシュ有りで テスト環境のconfig

通常環境でキャッシュを作成してconfigを見ます。

んーキャッシュ優先ですね。

config を確認してみる。

$ php8.0 artisan my:sample
"takuya-prod"
$ APP_ENV=testing php8.0 artisan my:sample
"takuya-prod"
$ APP_ENV=testing php8.0 artisan my:sample
"takuya-prod"

テスティング環境でキャッシュを作ってconfigを見ます。

testingでもキャッシュ優先ですね。

$ APP_ENV=testing php8.0 artisan config:cache
Configuration cache cleared!
Configuration cached successfully!
$ php8.0 artisan my:sample
"takuya-testing"
$ APP_ENV=testing php8.0 artisan my:sample
"takuya-testing"

ということで、artisan config:cacheは .env.testingと併せて使うと、落とし穴にハマりますよ。ってことで。

まとめ

.env.testing を使ってる場合、テスト実行する前には、cache:clear しましょう。

今回実験に使用したバージョン

$ cat composer.json | grep laravel/framework
    "laravel/framework": "^8.54",
$ php8.0  -v
PHP 8.0.9 (cli) (built: Jul 30 2021 13:09:07) ( NTS )
Copyright (c) The PHP Group
Zend Engine v4.0.9, Copyright (c) Zend Technologies
    with Zend OPcache v8.0.9, Copyright (c), by Zend Technologies

phpでは、config に ini/xml/jsonファイルを使わないの?

設定といえば、json や ini ファイルを想像するかもしれませんが。

parse_ini_file という便利な関数があるのに config を php の配列ので書くのは、config 内で、ソースコードを実行したいからですね。設定生成用にコンソールを作るのがめんどくさいからですね。ini ファイルで設定書くとかしません。ましてやJavaのアプリ見たく、XMLyaml を使った静的設定ファイルをパースするとか、利用者もフレームワーク製作者もパーサー製作者も、全員への時間の無駄でしか無いと思います。

awk がちゃんと動かない→gnu awk を入れる。

WEBのawk サンプルが動かないときの対応

一般的に使われているawk には色々あります。ネットに書かれている「テクニック」は gnu awk が多いです。

あれれ動かんぞ、とおもったら gawk を入れましょう。

sudo apt install gawk

awk のサンプルが動かない例

たとえば、次は、数字を桁区切りでprintf しようとしているところです。

takuya $ awk '{printf"%\047d",$1}' <<< "1000000"
awk: run time error: improper conversion(number 1) in printf("%'d")

gawk なら動きます。

takuya $ gawk '{printf"%\047d",$1}' <<< "1000000"
1,000,000

gawk をデフォルトawk

gawk が気に入ったらデフォルトにしておきましょう。

sudo update-alternatives --config awk
There are 2 choices for the alternative awk (providing /usr/bin/awk).

  Selection    Path            Priority   Status
------------------------------------------------------------
* 0            /usr/bin/gawk    10        auto mode
  1            /usr/bin/gawk    10        manual mode
  2            /usr/bin/mawk    5         manual mode

Press <enter> to keep the current choice[*], or type selection number:

参考資料

https://pooh.gr.jp/?p=9768

https://orebibou.com/ja/home/201707/20170717_001/

debian に p7z-rar を入れる。

debian に p7z-rar を入れる。

rar ファイルを扱いたいなと思ってんけど。

rar ファイルは zip と違って、日本語が化けないし、使ってもそんなに困るアーカイブ形式じゃない。

$  sudo apt install p7zip-rar
Reading package lists... Done
Building dependency tree
Reading state information... Done
Package p7zip-rar is not available, but is referred to by another package.

non-free を有効にする。

cat  /etc/apt/sources.list
deb http://debian-mirror.sakura.ne.jp/debian/ buster main  contrib non-free
deb http://debian-mirror.sakura.ne.jp/debian/ buster-updates main
deb http://security.debian.org/debian-security/ buster/updates main
deb http://ftp.debian.org/debian buster-backports main contrib non-free
$ sudo apt list p7zip*
Listing... Done
p7zip-full/oldstable 16.02+dfsg-6 amd64
p7zip-rar/oldstable 16.02-3 amd64
p7zip/oldstable 16.02+dfsg-6 amd64

インストール

$ sudo apt install p7zip-full --install-suggest

7z がunar より有能

unar で展開出来ない rar ファイルでも、7z なら展開できる。

参考資料

https://mag.osdn.jp/07/02/07/0137212

7-zipコマンドの一覧オプション ba を使って再利用しやすくる

7z にはドキュメント化されてないオプションがあるんだ・・

罠でしょ。

7z でアーカイブファイルをパイプで渡しやすい形式にする。

takuya$ 7z l -ba sample.zip
2021-08-23 15:59:54 .....            2            2  a.txt

7z でアーカイブファイルの中身を見る

通常であれば、 7z l archive.zip の形式を使います。次のような出力結果になりますが、むちゃくちゃ再利用しにくいんですよね

takuya $ 7z l sample.zip

7-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21
p7zip Version 16.02 (locale=en_US.UTF-8,Utf16=on・・・

Scanning the drive for archives:
1 file, 146 bytes (1 KiB)

Listing archive: sample.zip

--
Path = sample.zip
Type = zip
Physical Size = 146

   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2021-08-23 15:59:54 .....            2            2  a.txt
------------------- ----- ------------ ------------  ------------------------
2021-08-23 15:59:54                  2            2  1 files

baはドキュメント化されてない

フォーラムによると、このオプションは使えるけど、ドキュメント化されてないって。 まじか。嘘でしょって感じですね。知ってたら lsar/unar より、7z をメインで使ってましたよ。

-ba - suppress headers; undocumented.

参考資料

https://7ziphelp.com/7zip-command-line https://superuser.com/questions/1020232/list-zip-files-contents-using-7zip-command-line-with-non-verbose-machine-friend

phpに於ける StringIO

phpに於ける StringIO

php にはStringIOが無いと存じます。無いのではなく、必要がないのです。

stringio を実現する

$string_io = new \SplFileObject('php://memory','w+');

これだけです。SplFileObjectが最強です。

もしくは、fopen

$fp = fopen('php://memory','w+');

読み込み書き込み

SplFileObject がストリームのように扱えます。

$string_io = new \SplFileObject('php://memory','w+');
$string_io->fwrite("aaa\n");
$string_io->fwrite("bbb\n");
$string_io->fwrite("ccc\n");
$string_io->rewind();
foreach( $string_io as $line){
   var_dump( trim($line));
}

ただし、trim が必要な点。 fwrite と f-XX が冗長な点。また SplFileObject 自身が大量に継承メソッドを持ち、自分自身がIterable であり、ちょっとわかりにくいんじゃないかと思います。

組み込みで使えるし require 不要でいつでも使えるので便利ではあるのですが。

SplFileObjectというクラス名は、ファイルを連想させコードリーディングの障害になりかねない。そのため変数名を$string_io と意図を明確に書くべきだとおもう。

SplFileObject よりも fopen で php://memory を使ってみたらどうなるか、実装例も作ってみたが、SplFileObjectで十分だと思う。

参考資料

https://www.php.net/manual/en/class.splfileobject.php

sudo時の$HOME/$USER の環境変数の継承について

はじめに

-E環境変数を引き継げるのだけれど、WSLでsudo したときに、Windowsアプリにroot を使ってほしくなく、単にファイルを書き換えたいだけという欲求が出てきたので調べた。結論としてはむやみにやるんじゃない。ってことですけどね。

Home環境変数を維持する。-E

次のように、特に明示しない限り、sudo時に $HOMEをそのままにする。

takuya $ sudo -E  sh -c 'echo $HOME'
/home/takuya
takuya $ sudo -H -E  sh -c 'echo $HOME'
/root

$HOMEが書き換わる仕組み

デフォルトの挙動を見ておく。 素の状態では、sudo 時に 環境変数はリセットされ、HOMEは rootが使われる。

takuya $ sudo sh -c 'echo $HOME'
/root

環境変数を持ち込む

環境変数を維持したまま、sudo を実行する。

sudo -E sh -c 'echo $HOME'
/home/takuya

$HOME以外の環境変数を書き換える -H -E

$HOMEは除外。他の環境変数は維持する。

sudo -E -H sh -c 'echo $HOME'
/root

sudoersとの関係

sudoersのデフォルト設定は次のとおりです。

Defaults        env_reset

env_reset 設定は、sudo時の無継承、環境変数を最小限*1で新規作成し利用する。

このenv_reset を無視した実行が -E

sudo -E

ただし、$HOMEは強制する。 -Eで変数を継承するが、HOME変数は持ち込ませない。

sudo  -E -H  # または -H -E (順不問)

sudoers と -E の関係

-Eを常に有効する 。設定で常に有効にする。

## !が否定。偽になる
Defaults       !env_reset

すると、-Eをつけなくても、環境変数は維持される。

takuya $ export A=123; sudo sh -c 'echo A=$A;echo $HOME'
A=123
/home/takuya

sudoers と -E -Hの関係

sudoers では -Eを常に有効が!env_reset-Halways_set_home に相当する。

環境変数を維持して、ただしHOME変数は持ち込ませない。

# 環境変数を維持
Defaults   !env_reset
# ただし、HOME変数は持ち込ませない。
Defaults   always_set_home 

すると、HOME変数が、持込拒否される。

takuya $ export A=123; sudo sh -c 'echo A=$A;echo $HOME'
A=123
/root

-u オプションと set_logname

ちなみに、-uでユーザー名の書き換えができます。

takuya $ sudo sh -c 'echo $USER:$HOME'
root:/root
takuya $ sudo  -u takuya sh -c 'echo $USER:$HOME'
takuya:/home/takuya

しかし、-uで書き換えたところで、管理者コマンドは実行できません。

takuya$ sudo -E -u takuya apt remove dnsutils
open (13: Permission denied) are you root?

-uset_logname相当なのですが。

sudoersに次のように、設定し、sudo時に$USERを無変更にさせます。

Defaults !set_logname

すると、sudoしても$USERはそのままです。

sudo -E sh -c 'echo $USER:$HOME'
takuya:/home/takuya

そして、-u と同じようにapt install はできませ・・・えええ・・・そのまま apt install はできちゃう・・・

takuya$ sudo -E sh -c 'echo $USER:$HOME'
takuya:/home/takuya
takuya$ sudo -E apt install dnsutils
Setting up dnsutils (1:9.11.5.P4+dfsg-5.1+deb10u5) ...

えええ・・・どうなってるんだ。sudoers怖いね。

sudoers 書き換えは自分の開発環境だけにすること

むやみに書き換えないこと。環境変数をむやみに変えてたりすると、思わぬバグを踏んで、セキュリティを台無しににするので、env_resetなど書き換えはできる限りやらず、

開発環境でも sudo -Eで使ったほうがいいです。

https://www.sudo.ws/alerts/env_add.html

http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-0106

Sudo 1.6.9 before 1.8.5, when env_reset is disabled, does not properly check environment variables for the env_delete restriction, which allows local users with sudo permissions to bypass intended command restrictions via a crafted environment variable.

今回実験に使ったsudoについて

takuya $ wsl --list --verbose
  NAME      STATE           VERSION
* Debian    Running         1
takuya $ cat /etc/debian_version
10.10
takuya $sudo --version
Sudo version 1.8.27
Sudoers policy plugin version 1.8.27
Sudoers file grammar version 46
Sudoers I/O plugin version 1.8.27

参考資料

*1:If set, sudo will run the command in a minimal environment containing the TERM, PATH, HOME, MAIL, SHELL, LOGNAME, USER, USERNAME and SUDO_* variables.

rubyのpopenでエラー出力や終了コードを取得する話。

ruby の popen に関して。

ruby からIO.popenでプロセスを呼び出すときに、「〇〇」が取れない。というブログがいっぱい見つかって、そうじゃないだろ。という気分になったなったのでメモを残すことにする。

そして、Qiitaなどの記事を丸コピした「いかがでしたでしょうか」も溢れているので、検索でちょっと困った。

私は、プロセス起動を頻繁にやっているので、IO.popen を使っているが、本当にわかりにくいので、面倒だけどいまさらだけど、まとめておく。

IO.popen でプロセス起動

IO.popen はプロセスを起動して結果を取得できる。

Rubyは新しく「プロセス」を起動する。文字列で指定された場合は、シェル経由で、配列で指定された場合は子プロセスで起動する。(ただし、文字列で渡してた場合でも単純なコマンドは子プロセスで起動する。リダイレクトやパイプが含まれるコマンド文字列はシェル経由になる)

プロセスを起動するときは、「STDOUTとSTDIN」を指定して起動する。

IO.popenは プロセスとIOをひらく

IOクラスにあることを忘れないで覚えておく。

IOクラスは、出入力を扱う。popenでプロセスと通信経路を開く。

IO(クラス)--->-->in-->子プロセス--out--> IO(クラス)

少しわかりにくいのがIOクラスです。入力も出力もまとめてIOクラスで扱う。入出力をまとめたのがIOクラス。

子プロセスの実行結果を取得する

io = IO.popen(['echo','Hello IO.popen'] ,'r' )
pp io.read
io.close

実行結果

"Hello IO.popen\n"

子プロセスに入力する

子プロセスの標準入力に書き込む場合

io = IO.popen(['cat', '-'] ,'r+' )
io.puts "Hello IO.popen"
io.close_write
pp io.read
io.close

実行結果

"Hello IO.popen\n"

子プロセスへの出入力について。

ここまでの「結果を取得」「プロセスへ入力」をよく見比べてみる。

## 結果を読み込むとき
io = IO.popen(['echo','Hello IO.popen'] ,'r' )
## プロセスにデータを渡し、結果を読み込む
io = IO.popen(['cat', '-'] ,'r+' )

繰り返しになるが、IO.popenはIOクラスである。READもWRITEもどちらも可能です。それがIOクラスである。

IO.popenは ファイルのopen(オープン)と同じ

IO.popenでやってることは、簡単に言えばプロセスへの出入力を開いている。

ファイルの場合をもいだしてみると、ファイルを開くとき open はファイルへの出入力を開いている。

これらは同じことである。同じこととして扱える。プロセスはファイルみたいなものである。

ruby でファイルを開く例

io = open( '/dev/null', 'r+' )
io.puts "Hello"
io.close

ruby のIOでプロセスを開く例

io = IO.popen( 'cat -', 'r+' )
io.puts "Hello"
io.close

これらのコード例を比較する。コードを見ればわかると思うが、ほぼ同じである。

ファイルへの書込読込と、プロセスへの出入力はほぼ同じように扱える。

終了コードの取得

IO.popen で開いたプロセスの終了コードを取るには。$? を使う。

ただし、$?は子プロセスが終了しないと取れない。当たり前だが、終了しないと$?は出現しない。プロセスが終了したら$?が現れる。

では、いつ終了するのか?

それは、子プロセスへの通信経路として確保しているIOインスタンスclose したときです。ソースコードIO#closeしたときです。

IO(クラス)--->-->in-->子プロセス--out--> IO(クラス)

プロセスと、RubyのIOは接続しています。なのでruby側で子プロセスのoutに接続されたIOをCloseしない限り取得できません。

実行例

io = IO.popen( 'cat -', 'r+' )
io.puts "Hello"
pp  $?
io.close 
pp $?

実行結果

$ bundle exec bin/sample.rb
nil
#<Process::Status: pid 6574 exit 0>

ファイルなら、ファイルをcloseしない限りファイルは開いています。

同じように、closeしない限り popenしたプロセスは開いています。生きています。

子プロセスとRubyの間にパイプがある。

子プロセスとruby の間に接続が確立しているので、システム(Linux)が待っていてくれます。ruby 側がCloseして、子プロセスの出力結果を捨てるまで、読み込み可能なデータとしてOSのシステムコールが出力結果を持っててくれています。rubyからプロセスへの書き込みパイプが開いてるときも同じようにruby側の書き込みを待ってていてくれます。そのためにプロセスは終了しません。

popen引数でオートクローズ

read/write の両方がopenして起動するが、オプション指定で起動時にcloseしています。

'r' の場合は、書き込みの用のパイプは null になって、close状態で起動しています。

'w' の場合は、読み込み用のパイプは null になって、close状態で起動しています。

読み込み専用で、子プロセスを開く

io = IO.popen( 'echo hello', 'r' )
pp io.read
pp  $?
io.close 
pp $?
"hello\n"
nil
#<Process::Status: pid 6637 exit 0>
読み込み専用に書き込むと。。。
io = IO.popen( 'echo hello', 'r' )
io.puts "Hello"

子プロセスが起動するときに、システムコールを使うが、そのシステムコールに読み込み用のパイプだけを渡しているので書き込みができない。

IOError: not opened for writing
  bin/sample.rb:34:in `write'
  bin/sample.rb:34:in `puts'
  bin/sample.rb:34:in `<top (required)>'

書き込み専用で子プロセスを開く(暗黙の出力)

では、書き込み専用にプロセスを開いた場合はどうでしょうか。

この場合は、読み込みをしていないのでcat の結果は無くなりそうです。

io = IO.popen( 'cat -', 'w' )
io.puts "Hello"
pp  $?
io.close 
pp $?

実行結果

あれ、、、catの結果が出てきます。

bundle exec bin/sample.rb
Hello
nil
#<Process::Status: pid 6679 exit 0>
takuya@DESKTOP-2ALDRO3:/mnt/c/Users/takuya/Desktop/zip-checker$

暗黙の接続 $stdout

popenで開いたプロセスの出力先を指定しない場合、rubyの標準出力である $stdout へ接続されています。指定しない場合は$stdoutです。

書き込み専用にプロセスを開く。

それでは、暗黙の$stdout 接続をしたくないとき、次のようなコマンドを実行することを思いつくかもしれません。

popen('cat - > /dev/null' )

でも、ちょっとまってください。これはシェル呼び出しでsh コマンドが自分を/dev/nullにつないでいます。ruby 側にありません。

そこで、ruby の popen はIOをつないでいることを思い出してみましょう。

IOをつないでいるのですから、/dev/nullを開くのもruby側でやってみましょう。

io = IO.popen( 'cat -', 'w', :out=>open('/dev/null','w') )
io.puts "Hello"
pp  $?
io.close 
pp $?

実行結果です。

nil
#<Process::Status: pid 6699 exit 0>

どうでしょうか、popenの扱いが少し見えてきた気がしませんか?

暗黙の接続 $stderr

実は、子プロセスのエラー出力も暗黙の了解として、$stderrに接続されています。

ここが、本当にわかりにくい。

暗黙的に popenのエラー出力をは ruby$stderrに接続されています。ここに気づくか気づかないかで、大きく変わってくる。

この暗黙のIO接続に気づくために、先に$stdoutのお話をしました。

エラー出力を捨てる。

子プロセスのエラー出力をを捨ててみましょう。

これも、コマンドでシェル経由にして、次のようなコードが思いつくかもしれません。

popen('ls /no/exits 2> /dev/null' )

でも、これではシェル呼び出しです。

先程のopenを使ったものを応用してみると次のようになります。

io = IO.popen( 'ls /no/exists', 'r', :err=>open('/dev/null','w') )
pp io.read
pp  $?
io.close 
pp $?

ここまでのまとめ

プロセスの起動には、IN/OUTを指定する必要がある。

-->-->(in)子プロセス(out)----> 

IO.popen('cmd')すると、IOクラスに接続される。

IOクラス--->(in)子プロセス(out)---> IOクラス

IO.popen('cmd','r')すると、IOクラスに接続される。 ただし、入力はclosedされた状態で接続される。

IO(closed)--->(in)子プロセス(out)--->IOクラス

IO.popen( 'w' )すると、$stdoutに接続される。

IOクラス--->-->(in)子プロセス(out)--->$stdout

おなじことが、エラー出力にも起きている。

エラー出力を特に指定しない限り、$stderrに接続される

IO(クラス)--->─(in)子プロセス(out)─>$stdout
                  └-──err───> $stderr

ターミナルで起動時との比較

popenは、ターミナルからコマンドを起動したときと同様にプロセスを起動するのだが。popenを使っているとよくわからなくなる。

ターミナルから起動した場合、STDOUT/STDIN/STDERRはどこへ行くのを考える。popenやファイルとよく似ているため、理解の補助になるかもしれない。

ターミナルから echo を起動したとき

$ echo hello world

この場合、パイプは次のようになる。

  • STDIN 使わないので即Close
  • STDOUT ターミナルの出力へ
  • STDERR ターミナルの出力へ

echo を起動したとき、echo の結果は、ターミナルの出力と接続されている。

ターミナル --> in -> echo -> out -> ターミナル

ターミナル(sshなど)はttyでIOを扱うので、こんな感じかな。

tty --> in -> echo -> out -> tty 

popenの場合、これが、rubyになるだけである。

ruby--> in -> echo -> out -> ruby($stdout or IO)

もう、書きませんが、stderr についても同じである。

ruby--> in -> echo -> err -> ruby($stderr)

接続先のはOS(ファイルディスクリプタ)である。

ここで出てきた接続先はファイルディスクリプタです。

プロセスを起動したとき、プロセスを管理しているのはOSです。OSの管理下の接続先のみプロセスは接続(リダイレクト)が可能です。

IOの接続先はOSの管理するファイルディスクリプタなので、IO.popenが接続できるのは、OSの管理下にあるものです。

だから、OS管理下にないRuby内部の文字列(StringIO)などには接続できません

io = IO.popen( 'ls /no/exists', 'r', :err=>StringIO.new('','w') )
ArgumentError: wrong exec redirect action
  bin/sample.rb:33:in `popen'
  bin/sample.rb:33:in `<top (required)>'

次の例では、エラー出力を /dev/nullに接続していますが、これは/dev/nullを開いた、ファイルディスクリプタに接続しているわけです。ここもいくつかrubyが呼び出したときに、OSとの暗黙的な接続が動いてます。このあたりはC言語知らないと、少し辛いかもしれません。

io = IO.popen( 'ls /no/exists', 'r', :err=>open('/dev/null','w') )

簡単に言えば、popenで接続ができるモノは、プロセスから見える範囲にあるモノに限られます。

IO.pipeでつなぐ。

popenは子プロセスが扱える接続先にしか接続できません。それは「ファイル・STDOUT・STDERR」であり、そしてパイプです。

ここまで分かれば、システムコール(OS)管理下にまかせておけば、何でも接続できるとわかる。

その接続先として任意に作れるのがOSの「パイプ」です。OSにパイプを作成してもらうのは、ruby の、IO.pipeが対応している。このメソッドで、OSにパイプを作ってもらうシステムコールを呼び出すことができる。

pp IO.pipe
pp IO.pipe

pipeすると、StringIOとは違い、システムコールによるOS管理下にあるパイプがもらえる。

$ bundle exec bin/sample.rb
[#<IO:fd 9>, #<IO:fd 10>]
[#<IO:fd 11>, #<IO:fd 12>]

fd はファイルディスクリプタを示し、OSの管理するものである。番号は管理番号で、プロセスごとに割り当てられる。

StrngIOは接続できないが「パイプ」なら、popenのエラー出力や標準出力と接続できるのである。IO.pipeの結果は、ファイルディスクリプタであり、open()と同じようにOSファイルとつながっているわけです。IOは奥が深いですね。

子プロセスの出力先を変える

pooen された子プロセスに、IN/OUTがあるとわかった。それらはパイプであり、OSが管理するファイルディスクリプタである。

これらが、よくわかったところで、その出力先を変えてみようと思う。

rubyからファイルディスクリプタを作るには IO.pipe である。

io_r,io_w = IO.pipe
io = IO.popen(['ls', '/a'] , :err => io_w  )
pp io.read
io.close
pp $?

これを、実行すると、エラー出力は消えてしまう。

bundle exec bin/sample.rb
""
#<Process::Status: pid 6850 exit 2>

消えたけど、捨てられたわけはなく、別PIPEに送信されている。

io_r,io_w = IO.pipe
io = IO.popen(['ls', '/a'] , :err => io_w  )
pp io.read
io_w.close
pp io_r.read
io.close
pp $?

closeが増えて見にくいです。

IO.pipeの結果は、それぞれ(r,w) 専用になっている。それぞれつながっているので、 w側をloseしないと、r側で読めないのが面倒ですね。

popenしたIOはclose前に読み込み、一方で、pipeはcloseしないと読めないんでですね。ややこしくなってきた。*1

ファイルとプロセスでの違い

IO.popenは、プロセスを開くとき、プロセス特有の制限を受ける。プロセスはシステムコールで起動する。そのため、プロセスにわたたせるのは、パイプだけである。

エラー出力を取りたいために、知ることが多すぎる。と思います。

popenでは標準エラー出力を取れないと書かれちゃうのも仕方が無いのかもしれません。

でもちゃんとIO使えば取れることがわかりました。良かったね。

まとめ

popenでエラー出力をとる

IO.pipeする。

popenで終了コード(exit status)をとる。

IO.popenをcloseする。

サンプル

err_r,err_w = IO.pipe()
io = IO.popen(['ls','/', '/a'] , :err => err_w  )
##
err_w.close
pp err_r.read
##
pp io.read
io.close

pp $?

出力先を変えるだけなら

io = IO.popen(['ls','/', '/a'] , :err => '/dev/null'  )

もっと詳しく知りたいとき

次の本を読んでください。

関連資料

https://qiita.com/riocampos/items/3d3f540722f91589e00a

https://riocampos-tech.hatenablog.com/entry/20151225/how_to_capture_stderr_by_io_popen_method

https://qiita.com/tyabe/items/56c9fa81ca89088c5627

*1: 正確には、IO#read はEOFまで読み込みを試みるが、#close しないかぎりEOFが来ないので、EOFまちになって止まる。IO#read以外ならclose前でも読める。

改行を維持して 変数をechoする。

改行を維持して echoする。

改行を保持したまま、変数を出力したい ダブルクォーテーションすることで改行が出る

変数定義

STR="
aaa 
"

出力例

echo $STR ## 改行が出ない
echo "$STR" # 改行がでる

変数をダブルクォーテーションでクォートすると、変数内に含まれた改行がそのまま出てきます。ちょっと不思議な感じします。

join コマンドでLEFT OUTER JOIN のNULL 付きで出して、会議ごとの出席者一覧データを作る。

join コマンドでLEFT OUTER JOIN のNULL 付き的なことをやる

会議の出席の記録や、商品の売上のようなデータを横展開して表にしたい時がある。

このようなとき、join コマンドをぱぱっと実行すれば、データのOUTER JOIN的な事ができる。

出席と欠席を join コマンドで

join 2021-08-11.txt   2021-07-11.txt
A100 出席 出席
A101 出席 出席
A102 出席 欠席
A103 出席 欠席
A104 出席 欠席
A105 出席 欠席

しかし、整理されてない場合や、存在しないデータ(NULL・EMPTY)はいちいち書かない。

次のように、出席だけを書く場合が多いと思う。

出席だけを書いている

cat  2021-07-11-出席者のみ.txt
A100 出席
A101 出席

会議の出席者をメモったテキストがあるとする。

これをJOINするときに、NULLに相当する空文字を埋めてあげれば、データとして処理することができる。

出席や提出は出した人だけをメモっていることが多いですよね。まさか未提出を未提出、未払いを未払いとわざわざメモる人は少ないでしょう。

また、フォルダやファイルを探して見つかったものを集計することがおおく、未出現のIDはデータとして出てきません。

先に、欠席を補完する

そこで、未出現をEMPTYやNULLなど、データ欠損を埋めてデータ処理に適した形式にする。

join -a 1 -o auto -e '欠席' id.txt 2021-07-11-出席者のみ.txt
A100 出席
A101 出席
A102 欠席
A103 欠席
A104 欠席
A105 欠席

または、わかりやすいように NULL の文字で埋めてあげる。

join -a 1 -o auto -e 'NULL' id.txt 2021-07-11-出席者のみ.txt
A100 出席
A101 出席
A102 NULL
A103 NULL
A104 NULL
A105 NULL

この処理を冒頭処理と同等に一回で行う

join -a 1  2021-08-11.txt   <(join -a 1 -o auto -e '欠席' id.txt 2021-07-11-出席者のみ.txt)

join コマンドは、 join <( サブコマンド)のカタチで複数回のネストをするこができる。

複数の処理

しかし実際には、サブコマンドでは不便で。

JOINを join *.txt のような、引数を複数ファイルで行うことが出来ない。

事前処理をして、さらに関数を作ってあげれば、join *.txtのような処理で複数ファイルを使うことで実現する。

function multijoin() {
    out=$1
    shift 1
    cat $1 | awk '{print $1}' > $out
    for f in $*; do join $out $f > tmp; mv tmp $out; done
}
multijoin 出席者一覧 *.txt
cat 出席者一覧

NULLを入れてあげるちょっとした事前処理

このような、ちょっとした事前処理をしてあげるのがBashなどシェルコマンドを使うコツだと思うんですが。

失われた世界の知識になりつつありますよね。

この知識を使えば、繰り返しが出てくるデータを上手く集計することができそうな気がす。

たとえば、

  • 授業の提出物ファイル(フォルダ)から、生徒が毎回提出しているか。
  • チャットルームのログから、毎週の出席者を取り出したい
  • 商品の売上データから日々の売上の有無の計算
  • 会議室の、使用履歴とかを展開したり

フォルダごとやファイルごとに同じデータを繰り返し記録していたり、出現を数えてたりそのような古の知識はUNIX哲学だったのだろうが、たぶんもう忘れられた世界。

IPアドレスが、サブネットに含まれるか判定する( python )

python で ip address がネットワークに含まれるか計算したい

このIP 209.85.220.73 この、ネットワーク 209.85.128.0/17 に入るんだっけ。

計算してみる

python の netaddr パッケージを用いて計算する

pip install netaddr
##または pipenv 経由で
pipenv run pip install netaddr

要は、ふたつのIPのネットワークアドレスが一致すればいいわけですからこうすればいい。

from netaddr import * 

a = IPNetwork("209.85.220.73/17")
a.network # => IPAddress('209.85.128.0')
b = IPNetwork("209.85.128.0/17")
b.network # => IPAddress('209.85.128.0')
a.network ==  b.network

ショートカットに

これ、もうワンライナーで動きそうですね。

python -c "import sys;from netaddr import *; \
 
print( \
 IPNetwork(sys.argv[1]).network
  ==  IPNetwork(sys.argv[2]).network) \
" 209.85.220.73/17 209.85.128.0/17

だったら alias でいけるか。

alias in_subnet='python -c "import sys;from netaddr import *; print(IPNetwork(sys.argv[1]).network ==  IPNetwork(sys.argv[2]).network)" '

in_subnet 209.85.220.73/17 209.85.128.0/17

サブネットの計算は暗算できたほうが嬉しい

サブネットが一致するかの計算って、だいたいパターンなので暗算できるんだけど。

たとえば次のようにする。

209.85.220.73/17 を計算するに、

209.85.220.73/17 → 16+1 
209.85.0.0/16 + 220/1 + 0
209.85.0.0/16 + 128 + 0
209.85.0.0/16 + 0.0.128.0 + 0.0.0.0
だから
209.85.220.73/17 → 209.85.128.0/17

範囲もそこから想像がつく

209.85.128.0/17  →  209.85.128.0 ~ 209.85.255.255

なので、209.85.220.73は範囲に含まれる。

これは、つぎの表を丸暗記しておけば、サクッと計算できるわけで。

マスク 1オクテット 2オクテット 3 オクテット 4オクテット
/32 8 8 8 8
/24 8 8 8 0
/16 8 8 0 0
/8 8 0 0 0

IPアドレスのオクテット

1 2 3 4 5 6 7 8
7 6 5 4 3 2 1 0
128 64 32 16 8 4 2 1

これを覚えておけば、だいたい計算できるわ

たとえば、/26の場合

26=24+2 なので、24 までの /255.255.255.0 までは変換しないとわかる。残りの2相当つまり、 0.0.0.64 の足し算だとわかる。

たとえば、 /17 の場合

17 の場合は、16+1 なので、16までの /255.255.0.0 は変換しないとわかる。残りの1相当つまり、 128 が以降がネットワークアドレスとわかる。

209.85.128.0/17 の場合

209.85.128.0/17 
→ 209.85.128.0/16+1
→ 209.85.0.0/16 + 0.0.128.0
→ 209.85.128.0

209.85.220.73/17の場合

209.85.220.73/17
→ 209.85.220.73/(16+1)
→ 209.85.0.0/16 + 0.0.220.73/17
→ 209.85.0.0/16 + 0.0.128.0
→ 209.85.128.0

はたして、暗算での判断と、計算機を用いた場合のどちらが速いんだろう。

journald で今日のログ だけを見たい

今日のログだけを見る

日付を指定して、ログが見れる。

journalctl --since 2021-07-12

もっとかんたんに

date コマンドと組み合わせれば、ぱぱっと実行できますね。

journalctl --since $(date -I)
journalctl --since $(date -I --date 'yesterday' )
journalctl --since $(date -I --date '  3 days ago ' )

もっともっと簡単に

date コマンドが解釈できる形なら何でもいいですね

journalctl --since today
journalctl --since yesterday
journalctl --since "3 days ago"

今日の 17:00 以降ログ

時間も合わせて、さっきのログをみたいと。日付と時刻を合わせて、最新のログだけをみる。

journalctl --since "$(date -I) 17:00:00"

参考資料

https://www.codeflow.site/ja/article/how-to-use-journalctl-to-view-and-manipulate-systemd-logs

Ubuntuでmysql(/var/lib/mysql)を初期化して初期パスワード生成

mysql をリセットする

apt でインストールしたmysql のデータを全消しして、初期化し直す。

mysql を消したくなるとき。

何らかの作業をしていて、いったんmysql をサクッと削除して作り直したい時がある。

しかし、apt はしたくない。

sudo apt purge mysql.server
sudo apt purge mysql

コンテナを使っていると、消去して再インストールしたいが、aptからやり直すと不便。たとえば、次のようなとき。

docker でボリュームを設定したとき

たとえば、docker ubuntumysql を apt インストールしたものを ボリュームで外部と共有するとき。

VOLUME ['/var/lib/mysql']

このとき、docker run -v ./mysql:/var/lib/mysql などととやると、MySQLの初期データが完全に消えて起動しなくなる。

ubuntu をそのままに、mysql を消したいとき

apt で再インストールすればいいのだろうが。バージョンはそのままで管理ファイルをまっさらにしたいとき。 インストールスクリプトを作っているときなど

ネット接続無しで初期化したい。

パスワードがわからないので初期化したい。でもインターネットに接続できない(接続設定してない)のでapt で再インストール出来ない

apt remove / apt install は事情により出来ない。このときにmysqlのデータフォルダだけを再初期化してrootパスワードを再発行できる。

mysql を初期化する、

次のコマンドを使えば、インストールされているMySQLはそのままに、データの格納ディレクトリを再初期化できる。

mysqld --initialize

mysql を /var/lib/mysqlに入れた場合は、次のコマンドで再初期化ができる。

sudo systemctl stop mysql
sudo rm -rf /var/lib/mysql/*
sudo mysqld --initialize
sudo systemctl start mysql

この手順は、apt install mysql されたものをデータベースの保存ディレクトリだけを初期化し直すに使える。

初期化後の作業:パスワード再設定。

初期化コマンド mysqld --initialize は、root@localhostmysql パスワードも初期化されているので、パスワードを再設定する。

先程の初期化コマンド mysqld --initialize実行後に、ランダムなパスワードで初期化されている。ランダムパスワードが生成されているんで注意する。

初期化後の初回パスワードの確認

初期化パスワードを確認する。ログに書かれている。

# cat /var/log/mysql/error.log  | grep -i pass

実際にやった例

root@1626170b71af:/# cat /var/log/mysql/error.log  | grep -i pass
2021-06-22T08:34:32.575385Z 1 [Warning] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.
2021-06-22T08:34:40.773077Z 0 [Note] Shutting down plugin 'sha256_password'
2021-06-22T08:34:40.773089Z 0 [Note] Shutting down plugin 'mysql_native_password'
2021-06-22T08:34:43.290236Z 0 [Note] Shutting down plugin 'sha256_password'
2021-06-22T08:34:43.290244Z 0 [Note] Shutting down plugin 'mysql_native_password'
2021-07-12T08:39:58.067661Z 1 [Note] A temporary password is generated for root@localhost: mGUTPx0fyXSx

パスワードを再設定

初期パスワードはそのままでは使えません。

実行時に You must reset your password と言われて怒られます。

ERROR 1820 (HY000): You must reset your password using ALTER USER statement before executing this statement.

実際に怒られている例。

root@1626170b71af:/# mysql -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 3
Server version: 5.7.34-0ubuntu0.18.04.1

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>  use mysql;
ERROR 1820 (HY000): You must reset your password using ALTER USER statement before executing this statement.

パスワード再設定

rootユーザーでログインしたあと

mysql -p 

パスワードを再設定のALTER TABLEを発行する。

ALTER USER 'root'@'localhost' IDENTIFIED  BY 'MYSQL_ROOT_PASSWORD';
flush privileges;

password を指定するときは、identified by 'XXX' で指定。その後、権限を反映する。

まとめ

  • MySQLのデータフォルダ削除 rm /var/lib/mysql
  • 初期化コマンド mysqld --initialize
  • 生成パスワード確認 cat /var/log/mysql/error.log | grep -i pass
  • 初期パスワード変更 ` ALTER USER 'root'@'localhost' IDENTIFIED BY 'MYSQL_ROOT_PASSWORD'| mysql -p '

これで、いつでもMySQLを初期化してサクッとつくって使い潰せる。

docker-compose で mysql だけのインスタンスを立ち上げなくても、全部入りdocker を作って開発環境を無精できる。

追記

  • 2022/03/18

参考資料

https://stackoverflow.com/questions/8911115/how-to-recover-recreate-mysqls-default-mysql-database https://serverok.in/you-must-reset-your-password-using-alter-user

ssh の .ssh/config 場所を変える。

ssh の鍵の場所を変える。ために.ssh の場所を変えたい。

ログインを許可していないユーザー (www-data, backup,gitlab-wwwなど)のssh の設定場所にいつも苦労する。

~/.ssh を変えたいと思った。→できない

ssh のデフォルトディレクトリの.ssh は、設定で変えられないんですね。

~.ssh の場所はデフォルで固定のようですね。将来のアップデートで変わるんだろうかと思ったけど、今のところ出来ないってことはセキュリティ懸念とかで固定されてるということでしょうね。

man ssh で調べてみる。

man ssh_config 見た感じでは、変えられないんですよ。

他の方法を探してみる。

そこで、他の方法をあれこれ考えてみる。

  • .ssh/id_rsa/.ssh/known_hosts なら変えられる。
  • .ssh/config は include が使える。
  • git 関連なら GIT_SSH_COMMAND を使え

代替方法は、上記が候補に上がる。

IdentityFile / UserKnownHostsFile などは変えられる。

.ssh/config の場所を変えることは出来ないが、

グローバル設定 /etc/ssh/config を使えば、デフォルトのファイルの場所を変えられる。

たとえば、次のファイルを変えられれる。

  • UserKnownHostsFile -- ~/.ssh/known_hosts
  • IdentityFile -- ~/.ssh/id_rsa

グローバル設定に書いておけば、個人用ファイルを気にせずにかくことができる。

設定例 /etc/ssh/ssh_config

Match user www-data 
  UserKnownHostsFile  /etc/confings/www-data/.config/.ssh/known_hosts
  IdentityFile   /etc/confings/www-data/.config/.ssh/id_rsa

~/.ssh/config は変えられない。→ include で代用

~/.ssh/config を変えられないが、 include は使える

それですね。そうですね。include も一つの方法でした。

設定例。

touch /etc/confings/www-data/.config/.ssh/config
echo  Include  /etc/confings/www-data/.config/.ssh/config  >  ~./ssh/config

Includeを使うことで、ユーザの空間ではなく、グローバルな空間に置く事ができる。

変えたいときって、どうせGitでしょ?

ssh の設定を変えたいときって、git cloneや git を使った CI/CDとかでしょ? GIT_SSH_COMMAND を使えばいいと思うよ。

export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa_example" 
git clone example

ci の yaml環境変数を書いておけば、ほとんど場合のCI/CDでの自動テストと自動ビルドのようなgit の鍵トラブルは軽減できますよね。

参考資料

Laravel の通知でカスタムチャネルとカスタム通知を作ってみる

Laravel の通知でカスタムチャネルとカスタム通知を作ってみる

今回の目標laravelの通知を自前で作る。

Laravelのマニュアルを読んでも、スッキリわからなかったので、一度作ってみることにした。

作ったもの

作ったものは整理して、次の場所に設置した。 https://github.com/takuya/php-laravel-slack-post

最初に知っておくこと

laravel で通知の種類(Slack/Mail/SMS)をチャンネル と呼ぶ。

laravel の通知の概要

オブジェクトに指定したチャンネル経由で、イベントの発生を通知する。

少しわかりにくいが、通知内容や通知イベントをクラス化しコードを管理できるようになっている。

「ユーザーに、新規ログインをメールで通知する」とか「ユーザーに、支払い失敗をSMSとメールで通知する」など。

イベント・通知内容によって通知先を切り替える。という運用になる。

通知するもの、通知されるもの、通知チャンネル(ドライバ)

「ユーザーに、新規ログインをメールで通知する」場合、

  • 通知される対象は User オブジェクト
  • 通知する対象は「新規ログイン」
  • 通知は「Mailチャンネル」経由。

このように、通知を3つの役割に分けて考えている。

ユーザーに通知する箇所の概念的構造。

分けておくこととで、通知が完結に記述できる。

通知オブジェクトの関係を開発者視点でコードを書くと次のようになる。

通知を送る例

<?php
$user->notify( new NewLoginDeviceDetected());

ここには、通知チャンネルは一切登場しない。

ただ、ユーザーにイベント発生を通知するんだな。とだけわかる。シンプルで素晴らしい(?)。

シンプルとはいえ、実際に通知をするのはどこでどうやってるのか全然わからない。メール・SMS・Slackなのか、通知チャンネルはコードへ登場しない。

通知するクラスは自分で作る。

例に出したNewLoginDeviceDetected クラスは、開発者が作る「通知クラス」である。artisan make:notification で作る

artisan make:notification NewLoginDeviceDetected

通知内容のクラスは過去形が読みやすい。

通知クラスは、「〇〇が変更された」「〇〇が失敗した」などイベント名にするといい感じになると思う。イベント名なので、英語動詞の過去形が相応しい。

先程の例だと NewLoginDeviceDetected ですね。

通知されるオブジェクトと、通知するイベント

通知されるイベント、通知受けるオブジェクトはそれぞれ指定のメソッドを生やす必要がある。

  • 通知するオブジェクトは use Notifiable;
  • 通知内容オブジェクトは、extends Notification

で作る。

通知受信オブジェクトの例

通知受けるオブジェクトは use Notifiable;を使う。

<?php
class User{
    use Notifiable;
}

Notifiable をつかってどのクラスにもメソッド追加できる。

interface でもいいとおもうけど、traitです。traitなので型指定ができず不便だけど我慢しましょう。

通知されるイベント(通知内容オブジェクト)の規約

通知されるオブジェクトは、extends Notification を使ってメソッドをもらってくる。こちらは継承なのでクラス型指定ができて便利ですね。

コードサンプル(Notificationをつかった通知作成例)

<?php
class NewLoginDeviceDetected extends Notification{
}

Notification を継承しておけばオッケ~です。

どのチャンネルで通知されるのか

どこ経由で通知するのか。これは、viaで指定する。

コードサンプル(Notification設定値より通知経路を指定する)

<?php
class NewLoginDeviceDetected extends Notification{
  public function via( $notifiable ):array {
    $default_available_channels = ['mail','slack','sms'];
    $channel=['slack']
    return array_intersect($channel,$default_available_channels);
  }
}

通知経路の指定は via()の関数本文内で行う。引数の$notifiable はtrait なので型指定ができない。

via()引数の $notifiableの設定から、チャンネルを絞ることもできる。 Userクラスが notifiableで渡されていてUserに設定があるならそれを使って通知経路(チャンネル)を指定することができる

コードサンプル(Userの設定値より通知経路を指定する)

<?php
class NewLoginDeviceDetected extends Notification{
  public function via( $notifiable ):array {
    $default_available_channels = ['mail','slack','sms'];
    return array_intersect(
        $default_available_channels,
        $notifiable->settings->channel
    );
  }
}

定義済みチャンネル(ドライバ)

via()がreturn しているのは String の配列です。

'main''slack' は、laravel に初期導入されてる予約名。

これをチャンネルのdriverというらしいですよ。

チャンネルにはドライバがあります。ややこしいですね。

通知経路を追加する。

Slackじゃなくて、discordがいいとか、流行りのMS Teamsがいいとか、追加した場合は。https://laravel-notification-channels.com/ にあるドライバをcomposer require すれば足せる。

自分で定義した通知経路(通知チャンネル・ドライバ)を使う場合

自作の通知経路を使うこともできる。そのときは、クラス名を名前空間付きで、returnすれば使える。Provierのような事前登録は不要。書けば動く。

<?php
class MyEventDetected extends Notification{
  public function via( $notifiable ):array {
      return [MyChannel::class];
  }
}

すでに定義済みのチャンネルで、足りる事が多いわけです。多分自分で作って使うことはないと思うのですが。

通知内容の文字列を整形(フォーマット)

通知されるオブジェクトと通知するオブジェクトの相互関係は少しわかった。

それでは、通知メッセージはどこでフォーマットされるのか。通知メッセージは、イベント名で作った通知オブジェクトが知っているという前提。

コードサンプル

toSlacke['slack'] に対応したテンプレートフォーマット。

<?php
    
class NewLoginDeviceDetected extends Notification{
  public function toSlack( $notifiable ){
    $message = new SlackMessage();
    $message
      ->from('通知ボット')
      ->to('#通知テスト用')
      ->content("新規ログインがありました");
    return $message;
  }
    
}

ドライバ名(mail/slack)と対応したメソッド(toMail/toSlack)と、それに対応したメッセージオブジェクト(MailMessage/SlackMessage)を使って通知の内容と本文を作成する。

通知内容のクラス読んでも、to${DriverName}() の名前は予測不可能だし、引数の $notifiable(通知を受けるオブジェクト)の型も予測不可能である。 何が来るか全く予想不能で少し使いづらそう。このあたり、今後のlaravel側の改善を期待したい。

通知チャンネルの具体的指定

メールやSMSのように宛先アドレスが1つしかない場合は、コードがシンプルで良き。

Slackにメッセージを投げる例

しかし、Slackのどのチャットルームにメッセージを投げるのか、など細かい指定はどこでやるのかパット見で分かりづらい。

通知を細かく設定する工夫

ここまで、見た来た結果、通知内容と通知経路は1対1で管理したほうがコードの見通しがよく、via で複数のチャンネル名を返却するのは本当に同じものを通知するとき以外は避けたほうが良さそう。

HTMLにするなど、整形が挟まるときは別々にしたほうが管理が楽になるでしょう。通知オブジェクトに細かい指定を作るのが良さそう

<?php
    
class SlackMyRoomNotification extends Notification {
    
}
class SendtoAdminMailNotification extends Notification {
}
interface toSlack {
    public function toSlack($notifiable);
}
interface toMail {
    public function toMail($notifiable);
}
class NewLoginDetectedToSlack extends SlackMyRoomNotification  implements toSlack {
    
}
class NewLoginDetectedToMail extends SendtoAdminMailNotification implements toMail {
    
}

「型」にこだわる必要はないと思うのですが。PHPだしある程度の型に関する寛容性は許しても良さそうなんだけど。

開発環境のツールでメソッド補完をしたいとか、引数の型チェックをしたいとか細かい要望を入れていると本当に難しい。

通知チャンネルの自作の懸念点

ここまで、いろいろ見てきたが 自作通知チャンネルに関するいくつかの懸念点が出てくる

  • toSlackでフォーマット時に$notifiableの設定を使いづらい。
  • 通知されるオブジェクトがないとき、どうするのか。
  • 自作の通知経路はinterfaceもtraitもないので、型指定が不便。
  • toSlack/toMailのようなフォーマットの型指定が不便。

なので、Laravel提供のドライバに併せてフォーマット範囲と定数を指定する必要がある。

自作で通知チャンネルを追加すると、汎用性は捨てて、作り捨てになる感じなりそうです。

自作で通知チャンネルを作ってみる。

自作通知チャンネルを作ってみる。

laravel デフォルトの slack 通知チャンネルは、Webhookを使うので、webhook の管理が煩雑になるので、個人のAPIキーをつかってBot作って投稿したい。

自作通知チャンネルで用意したクラス。

通知を作るのに、クラスが4つ必要ですね。ちょっとした通知なのに大変だ。

  • class SlackApiPostMessage / Slack API を叩いてメッセージを送るクラス
  • class SlackRoom / use Notifiable; された「通知を受け取るオブジェクト」
  • class SampleNotification/ extends Notification された通知内容のオブジェクト
  • class SlackApiChannel / send を実装した、通知するチャンネルオブジェクト
  • class TestSlackMessage / extends Command して artisan から実行

slack API の準備

APIトークンを取得

Slackでは、APIトークンを用いて、とてもかんたんにメッセージを取得、メッセージの投稿ができる仕組みがある。

webhook に比べてTOKENのほうが管理が楽なので私はこちらが好きなのです。

また、Slackの投稿は、Curlでぱぱっとできるので、投稿用URLを作っておけば再利用もかんたんです。

laravel なのでcurkを直接使わずに、Guzzleを使います。

slack の api トークンを取得して、configへ

https://api.slack.com/apps/
  • 自分のSlackのAppのWebページを開く
  • Create an app → full scratch → 名前とワークスペースを決める、
  • app を作ったら、Botを選ぶ。
  • App Homeから Botユーザ名を決める。
  • Bot に Scope を追加
  • Scope は chat:writeまたはchat:write.customize
  • スコープ追加したら、Workspaceにインストール
  • 最後に、書込み可能な、Tokenを作る。

TOKEを設定に保存

laravel の設定は、.env から取り出すのが流儀らしい。

config/slack.php

return [
  'token'=>env('SLACK_TOKEN'),
];

TOKENを環境変数に追加

.env に環境変数として追加する。

.env

SLACK_TOKEN=xoxb-123456-9876543-0heWmS

設定を再キャッシュする

artisan cache:clear

Slack の投稿をテストする。

投稿を送信するのに、わざわざサーバーボタンを作るのは煩わしいので、コマンドから叩く。

artisan make:command SampleSlackMessage

SampleSlackMessage.php

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class SampleSlackMessage extends Command {
  
  protected $signature = 'test:slack_token';
  
  /**
   * Execute the console command.
   * @return int
   */
  public function handle() {
    $cli = new \GuzzleHttp\Client();
    $res = $cli->request(
      "POST",
      'https://slack.com/api/chat.postMessage',
      [
        'form_params'     => [
          'token'   => config('slack.token'),
          'channel' => '#通知テスト用',
          'text'    => '書き込みテスト',
        ],
        'allow_redirects' => false,
      ]);
    $ret = $res->getBody()->getContents();
    
    return true;
    
    return 0;
  }
}

チャットルームに送信してみる。

artisan test:slack_token

無事に投稿できたらSlackのトークンは準備完了です。

SlackAPIを叩く汎用をクラスを作る。

tokenが無事に作れたので、SlackAPIを呼び出すクラスを作っておきます。

mkdir -p app/Services/Slack
touch -p app/Services/Slack/SlackApiPostMessage.php

SlackApiPostMessage.php

<?php
namespace App\Services\Slack;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Notifications\Messages\SlackMessage;

class SlackApiPostMessage {
  
  /**
   * @var string
   */
  protected $endpoint;
  /**
   * @var \Illuminate\Config\Repository|\Illuminate\Contracts\Foundation\Application|mixed
   */
  protected $token;
  /**
   * @var string
   */
  protected $channel;
  /**
   * @var string
   */
  protected $content;
  
  /** @var SlackMessage */
  protected $message;
  
  public function __construct() {
    $this->endpoint = 'https://slack.com/api/chat.postMessage';
    $this->token = config('slack.token');
  }
  
  public function content( string $message ) {
    $this->content = $message;
    
    return $this;
  }
  
  public function to( string $channel ) {
    $this->channel = $channel;
    
    return $this;
  }
  
  public function send() {
    $this->send_to_api();
    
    return $this;
  }
  
  protected function send_to_api() {
    
    $params = $this->jsonBuilder();
    try {
      $cli = new Client();
      $res = $cli->request(
        "POST",
        $this->endpoint,
        [
          'form_params'     => $params,
          'allow_redirects' => false,
        ]);
      $ret = $res->getBody()->getContents();
      return true;
    } catch (ClientException $e) {
      return false;
    }
  }
  
  protected function jsonBuilder(){
    $params = $this->message_to_paramters();
    $params = array_merge($params,array_filter([
      'token'=>$this->token,
      'channel' => $this->channel,
      'text'    => $this->content,
    ]));
    return $params;
  }
  protected function message_to_paramters() {
    $message = $this->message;
    $optionalFields = array_filter(
      [
        'channel'      => data_get($message, 'channel'),
        'icon_emoji'   => data_get($message, 'icon'),
        'icon_url'     => data_get($message, 'image'),
        'link_names'   => data_get($message, 'linkNames'),
        'unfurl_links' => data_get($message, 'unfurlLinks'),
        'unfurl_media' => data_get($message, 'unfurlMedia'),
        'username'     => data_get($message, 'username'),
      ]);
  
    return array_merge([
        'text' => $message->content,
        //'attachments' => $this->attachments($message),
      ],
      $optionalFields);
  
  }
  
  public function setMessage( SlackMessage $message ) {
    $this->message = $message;
  }
}

通知チャンネルをつくる

ここからが大変な、通知チャンネルの作成ですね。TOKENとSlack投稿がわかっただけで、まだ何もクラスを作ってません・・・

  • class SlackApiPostMessage / Slack API を叩いてメッセージを送るクラス
  • class SlackRoom / use Notifiable; された「通知を受け取るオブジェクト」
  • class SampleNotification/ extends Notification された通知内容のオブジェクト
  • class SlackApiChannel / send を実装した、通知するチャンネルオブジェクト
  • class TestSlackMessage / extends Command して artisan から実行

通知を送る箇所を作っておく

通知を、実際に送る箇所はこの様になる。

$room = new SlackRoom('#通知テスト用');
$room->notify(new SampleNotification('通知内容メッセージとか'));

通知を送る箇所を、コマンドとして作る

artisan make:command TestSlackMessage

TestSlackMessage

class TestSlackMessage extends Command {
  protected $signature = 'test:slack_notification';
  public function handle () {
    $room = new SlackRoom('#通知テスト用');
    $room->notify(new SampleNotification('通知内容メッセージとか'));
    
    return 0;
  }
}

通知を受け取るクラスを作る

通知を受け取るクラスは、UserなどEloquent Model にすることが多いと思うが、今回は汎用性を考えて、SlackRoomという名前にして、Slackのチャットルームが通知を受け取るという流れになるようなクラスの相互関係の設計とする。

artisan make:model '\App\Models\SlackRoom'

SlackRoom.php

class SlackRoom {
  
  use Notifiable;
  
  /** @var string */
  protected $channel_name;
  
  /**
   * SlackRoom constructor.
   * @param $channelName_in_slack
   */
  public function __construct( $channelName_in_slack ) {
    $this->channel_name = $channelName_in_slack;
  }
  
  /**
   * @return string
   */
  public function getChannelName():string {
    return $this->channel_name;
  }
}

通知チャンネルを作る

mkdir -p app/Channels 
touch -p app/Channels/SlackApiChannel.php

SlackApiChannel.php

namespace App\Channels;

use Illuminate\Notifications\Notification;
use \App\Services\Slack\SlackApiPostMessage;

class SlackApiChannel {
  
  public function send( $notifiable, Notification $notification ) {
    
    
    $formatter = 'toSlack';
    $cli->content('ここはメッセージ')
      ->to('#通知テスト用')
      ->send();
    $cli->send();
    
  }
}

通知内容クラスを作る

通知されるオブジェクトに対して、通知される内容を示すオブジェクトを作る

artisan make:notification SampleNotification

SampleNotification.php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;

class SampleNotification extends Notification {
  
  use Queueable;
  public function via( $notifiable ):array {
    return [SlackApiChannel::class];
  }
}

通知する。

すべてができたら、 notification /notifiable を使って通知する。

artisan test:slack_notification

全体の流れ

  • SlackRoom クラスが notify する
  • notify されるのは、 SampleNotification クラス
  • SampleNotification が SlackChannel クラスを返す。
  • SlackChannel クラスの send が実行される
  • send 中で、SlackAPIを呼び出す。

この間に受け渡されるのはSampleNotificationインスタンスです。

SampleNotification内部で、SampleNotification自身がSlack用・Mail用に自分のToStrngを用意しておく、しかし、自作の場合は、toXXXは存在せず呼び出されない。

メリットは?

コードがスッキルする。Notificationをクラス別につくれば、再利用しやすい。

逆に、通知経路が一つしかなく、通知するべき内容もかぎられているのであれば、そこまで無理して使う必要はないかもしれない。