HHeLiBeXの日記 正道編

日々の記憶の記録とメモ‥

Laravel 11環境でLaravel-Snappyを使ってPDF出力をする

はじめに

PDFを出力するためのライブラリ「Snappy」をLaravel 11で使えるようにするための手順のメモ。

事前準備

今回は、Windows 11上のVirtualBoxにAlmaLinux 9.3をインストールした環境での手順を示す。

まずはLaravel 11でのプロジェクト作成。

$ cd /var/www/html
$ composer create-project laravel/laravel snappy-sample
$ sudo chown -R apache:apache snappy-sample
$ cd snappy-sample

Wkhtmltopdf/Wkhtmltoimageのインストール

Snappyでは、このライブラリが必要になるので、先にインストールする。

$ composer require h4cc/wkhtmltopdf-amd64 0.12.x
$ composer require h4cc/wkhtmltoimage-amd64 0.12.x

laravel-snappyのインストール

続いて、Laravel-Snappyのインストールを行う。

$ composer require barryvdh/laravel-snappy

サービスプロバイダーの登録

インストール完了後、bootstrap/providers.php にサービスプロバイダーの登録を行う。 以下のように、「Barryvdh\Snappy\ServiceProvider::class」を追記する。

<?php

return [
    App\Providers\AppServiceProvider::class,
    Barryvdh\Snappy\ServiceProvider::class,
];

追記が完了したら、以下のコマンドを実行して設定ファイルを生成する。

$ php artisan vendor:publish --provider="Barryvdh\Snappy\ServiceProvider"

config/snappy.php という設定ファイルが生成されるので、これを以下のように編集する (Wkhtmltopdf/Wkhtmltoimage が /usr/local/bin 以下にインストールされている場合には編集は不要だし、環境変数 WKHTML_PDF_BINARY・WKHTML_IMG_BINARY にそれぞれフルパスを設定しても構わない)。

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Snappy PDF / Image Configuration
    |--------------------------------------------------------------------------
    |
    | This option contains settings for PDF generation.
    |
    | Enabled:
    |    
    |    Whether to load PDF / Image generation.
    |
    | Binary:
    |    
    |    The file path of the wkhtmltopdf / wkhtmltoimage executable.
    |
    | Timeout:
    |    
    |    The amount of time to wait (in seconds) before PDF / Image generation is stopped.
    |    Setting this to false disables the timeout (unlimited processing time).
    |
    | Options:
    |
    |    The wkhtmltopdf command options. These are passed directly to wkhtmltopdf.
    |    See https://wkhtmltopdf.org/usage/wkhtmltopdf.txt for all options.
    |
    | Env:
    |
    |    The environment variables to set while running the wkhtmltopdf process.
    |
    */
    
    'pdf' => [
        'enabled' => true,
        'binary'  => base_path('vendor/h4cc/wkhtmltopdf-amd64/bin/wkhtmltopdf-amd64'),
        'timeout' => false,
        'options' => [],
        'env'     => [],
    ],
    
    'image' => [
        'enabled' => true,
        'binary'  => base_path('vendor/h4cc/wkhtmltoimage-amd64/bin/wkhtmltoimage-amd64'),
        'timeout' => false,
        'options' => [],
        'env'     => [],
    ],

];

IPAフォントのダウンロード

以下のサイトから、ZIPファイルをダウンロードする。

本記事執筆時点では“IPAexfont00401.zip”が最新なので、こちらを利用する。

ダウンロードしたら、解凍してstorage/fontsにTTFファイルをコピーする。

$ mkdir storage/fonts
$ unzip IPAexfont00401.zip
$ cp IPAexfont00401/*.ttf storage/fonts/

コントローラーの作成

$ php artisan make:controller SnappyController

app/Http/Controllers/SnappyController.php が作成されるので、これを以下のように編集する。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Barryvdh\Snappy\Facades\SnappyPdf as SnappyPDF;

class SnappyController extends Controller
{
    public function viewPdf()
    {
        $pdf = SnappyPDF::loadView('snappy.pdf');
        return $pdf->stream();
    }
}

ビューの作成

内容は何でも良いが、以下のようなビューファイルを作る。 コントローラーでの記述に合わせて、ビューファイルのパスは「resources/views/snappy/pdf.blade.php」とする。

$ mkdir resources/views/snappy
$ vi resources/views/snappy/pdf.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
        <title>PDF出力</title>
    </head>
    <style>
        @font-face {
            font-family: ipaexg;
            font-style: normal;
            font-weight: normal;
            src: url('{{ storage_path('fonts/ipaexg.ttf') }}');
        }
        html, body {
            font-family: ipaexg, sans-serif;
        }
    </style>
    <body>
        こんにちは
        <br>
        Hello Snappy
    </body>
</html>

ルーティングの設定

routes/web.php にルーティングの設定を追記する。

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/snappy/pdf', 'App\Http\Controllers\SnappyController@viewPdf');

表示

以上で必要なファイルがそろったので、Webブラウザで以下のURLにアクセスすると、PDFが表示される。 (URLはご自身の環境に合わせてね)

http://192.168.56.nn/snappy-sample/public/snappy/pdf

参考

Apache MINAでSFTP接続

はじめに

必要に迫られてFTPS接続するJavaプログラムをSFTP接続するように書き換える際に、Apache MINAを使って動く形に書き下されたサンプルが見つからなかったので、そのメモ。

要件としてパスワード認証を使うというのもあった。

事前準備

必要なライブラリをダウンロードする。

Apache MINA / Apache SSHD

今回のメインライブラリ。

今回は、「Apache MINA 2.2.3」「Apache SSHD 2.11.0」を使用した。

Apache Commons IO

I/O周りを扱う上では何かと便利。

今回は、「Commons IO 2.16.1」を使用した。

メインプログラム

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.TimeZone;

import org.apache.commons.io.IOUtils;
import org.apache.sshd.client.SshClient;
import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.sftp.client.SftpClient;
import org.apache.sshd.sftp.client.SftpClient.Attributes;
import org.apache.sshd.sftp.client.SftpClient.CloseableHandle;
import org.apache.sshd.sftp.client.SftpClient.DirEntry;
import org.apache.sshd.sftp.client.SftpClientFactory;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class SftpFile {
    private final String filename;
    private final String longFilename;
    private final Attributes attributes;
    public SftpFile(DirEntry entry) {
        filename = entry.getFilename();
        longFilename = entry.getLongFilename();
        attributes = entry.getAttributes();
/*
        System.out.println("===" + filename + "/" + longFilename + "===");
        Attributes attrs = entry.getAttributes();
        System.out.println("    type = " + attrs.getType());
        System.out.println("    size = " + attrs.getSize());
        System.out.println("    owner = " + attrs.getOwner());
        System.out.println("    group = " + attrs.getGroup());
        System.out.println("    userId = " + attrs.getUserId());
        System.out.println("    groupId = " + attrs.getGroupId());
        System.out.println("    permissions = " + attrs.getPermissions());
        System.out.println("    accessTime = " + attrs.getAccessTime() + " (" + (attrs.getAccessTime() != null ? attrs.getAccessTime().toMillis() : 0) + ")<=>(" + System.currentTimeMillis() + ")");
        System.out.println("    createTime = " + attrs.getCreateTime() + " (" + (attrs.getCreateTime() != null ? attrs.getCreateTime().toMillis() : 0) + ")<=>(" + System.currentTimeMillis() + ")");
        System.out.println("    modifyTime = " + attrs.getModifyTime() + " (" + (attrs.getModifyTime() != null ? attrs.getModifyTime().toMillis() : 0) + ")<=>(" + System.currentTimeMillis() + ")");
        System.out.println("    acl = " + attrs.getAcl());
        System.out.println("    extensions = " + attrs.getExtensions());
        System.out.println("    regularFile = " + attrs.isRegularFile());
        System.out.println("    directory = " + attrs.isDirectory());
        System.out.println("    symbolicLink = " + attrs.isSymbolicLink());
        System.out.println("    other = " + attrs.isOther());
*/
    }
    public long getTimestamp() {
        return attributes.getModifyTime().toMillis();
    }
    public boolean isDirectory() {
        return attributes.isDirectory();
    }
    public String getFilename() {
        return this.filename;
    }
    public String getLongFilename() {
        return this.longFilename;
    }
}
class SftpConfig {
    public final String username;
    public final String password;
    public final String host;
    public final int port;
    public final String srcDir;
    public final String destDir;
    public SftpConfig(String username, String password, String host, int port, String srcDir, String destDir) {
        this.username = username;
        this.password = password;
        this.host = host;
        this.port = port;
        this.srcDir = srcDir;
        this.destDir = destDir;
    }
}
public class SftpTest {
    public static void main(String args[]) {
        Logger logger = LoggerFactory.getLogger(SftpTest.class);
        logger.debug("Logger is start.");

        try (SshClient client = SshClient.setUpDefaultClient()) {
            client.start();

            SftpConfig[] config = {
                new SftpConfig("YOUR_NAME", "YOUR_PASSWORD", "localhost", 22, "test", "test"),
            };

            for (int i = 0; i < config.length; ++i) {
                try (ClientSession session = client.connect(config[i].username, config[i].host, config[i].port).verify(60 * 1000).getSession()) {
                    System.out.println("session started");
                    session.addPasswordIdentity(config[i].password);
                    session.auth().verify(60 * 1000);

                    try (SftpClient sftp = SftpClientFactory.instance().createSftpClient(session)) {
                        Iterable<DirEntry> entries;
                        try (CloseableHandle handle = sftp.openDir(config[i].srcDir)) {
                            entries = sftp.listDir(handle);
                            for (DirEntry entry: entries) {
                                SftpFile file = new SftpFile(entry);
                                System.out.println(file.getFilename());
                                System.out.println(file.getLongFilename());
                TimeZone defaultTZ = TimeZone.getDefault();
                TimeZone.setDefault(TimeZone.getTimeZone("GMT"));
                DateFormat df = new SimpleDateFormat("YYYY-MM-dd'T'HH:mm:ssZ");
                                System.out.println(df.format(file.getTimestamp()));
                                System.out.println(df.format(System.currentTimeMillis()));
                // デフォルトタイムゾーンを元に戻す。
                TimeZone.setDefault(defaultTZ);
                                String filename = file.getFilename();
                                logger.info(filename);
                                if (filename.equals(".") || filename.equals("..")) {
                                    continue;
                                }
                                if (file.isDirectory()) {
                                    continue;
                                }
                                try (InputStream is = sftp.read(config[i].srcDir + "/" + filename)) {
                                    File dir = new File(config[i].destDir);
                                    if (!dir.exists()) {
                                        dir.mkdirs();
                                    }
                                    FileOutputStream os = new FileOutputStream(new File(config[i].destDir, filename));
                                    IOUtils.copy(is, os);
                                } catch (IOException e) {
                                    e.printStackTrace();
                                    logger.error(config[i].srcDir + "/" + filename + ": No such file", e);
                                }
                            }
                        }
                    }
                }
            }
        } catch (IOException e) {
            // TODO 自動生成された catch ブロック
            e.printStackTrace();
        }
    }

}

ビルドスクリプト

#! /bin/bash

CP=.
CP=${CP}:apache-mina-2.2.3/dist/mina-core-2.2.3.jar
CP=${CP}:apache-mina-2.2.3/lib/commons-logging-1.0.3.jar
CP=${CP}:apache-mina-2.2.3/lib/slf4j-api-1.7.36.jar
CP=${CP}:apache-sshd-2.11.0/lib/sshd-core-2.11.0.jar
CP=${CP}:apache-sshd-2.11.0/lib/sshd-common-2.11.0.jar
CP=${CP}:apache-sshd-2.11.0/lib/sshd-sftp-2.11.0.jar
CP=${CP}:apache-sshd-2.11.0/lib/slf4j-jdk14-1.7.32.jar
CP=${CP}:commons-io-2.16.1/commons-io-2.16.1.jar

javac -cp ${CP} SftpTest.java
java -cp ${CP} SftpTest

実行結果

冒頭に警告がいくつか出るのだけど、今回は割愛。

.
drwxr-xr-x    2 YOUR_NAME YOUR_NAME       27 Apr 29 03:16 .
2024-04-28T18:16:47+0000
2024-04-28T18:38:40+0000
Apr 29, 2024 3:38:40 AM SftpTest main
INFO: .
..
drwx------    3 YOUR_NAME YOUR_NAME       74 Apr 29 03:16 ..
2024-04-28T18:16:18+0000
2024-04-28T18:38:40+0000
Apr 29, 2024 3:38:40 AM SftpTest main
INFO: ..
SftpTest.java
-rwxr-x---    1 YOUR_NAME YOUR_NAME     5392 Apr 29 03:16 SftpTest.java
2024-04-28T18:16:47+0000
2024-04-28T18:38:40+0000
Apr 29, 2024 3:38:40 AM SftpTest main
INFO: SftpTest.java

参考

配列とハッシュの基礎サンプル

最近、Perlのプログラムに触れる機会ができたのだが、配列とかハッシュとか、記号が多すぎて理解が追い付かない。 「$」とか「@」とか「%」とか「\」とか。

なので、以下のページを参考にしながら、軽くサンプルを作ってみた。

#! /usr/bin/perl

# 別の変数に「コピー」を渡す
my @ary1 = (1,2,3);
my @ary2 = @ary1;

# 件数を取るときは、一旦スカラー変数に代入
my $ct = @ary1;
print $ct."\n";

# 「コピー」なので、元を変えても影響を受けない
push @ary1, 4;
for my $elem (@ary1) {
    printf "ary1 = $elem\n";
}
for my $elem (@ary2) {
    printf "ary2 = $elem\n";
}

# 別の変数に「参照(リファレンス)」を渡す
my $ary3 = [1,2,3];
my $ary4 = $ary3;

# 「参照(リファレンス)」なので、元を変えると先も変わる
push @$ary3, 4;
for my $elem (@{$ary3}) {
    printf "ary3 = $elem\n";
}
for my $elem (@{$ary4}) {
    printf "ary4 = $elem\n";
}

# 別の変数に「コピー」を渡す
my %hash1 = (e1 => 1, e2 => 2, e3 => 3);
my %hash2 = %hash1;

# 「コピー」なので、元を変えても影響を受けない
%hash1->{e4} = 4;
for my $key (sort keys %hash1) {
    printf "hash1 = %s = %s\n", $key, %hash1->{$key};
}
for my $key (sort keys %hash2) {
    printf "hash2 = %s = %s\n", $key, %hash2->{$key};
}

# 別の変数に「参照(リファレンス)」を渡す
my $hash3 = {e1 => 1, e2 => 2, e3 => 3};
my $hash4 = $hash3;

# 「参照(リファレンス)」なので、元を変えると先も変わる
$hash3->{e4} = 4;
for my $key (sort keys %{$hash3}) {
    printf "hash3 = %s = %s\n", $key, %{$hash3}->{$key};
}
for my $key (sort keys %{$hash4}) {
    printf "hash4 = %s = %s\n", $key, %{$hash4}->{$key};
}

出力は以下のような感じ。

Using a hash as a reference is deprecated at Main.pl line 38.
Using a hash as a reference is deprecated at Main.pl line 40.
Using a hash as a reference is deprecated at Main.pl line 43.
Using a hash as a reference is deprecated at Main.pl line 53.
Using a hash as a reference is deprecated at Main.pl line 56.
3
ary1 = 1
ary1 = 2
ary1 = 3
ary1 = 4
ary2 = 1
ary2 = 2
ary2 = 3
ary3 = 1
ary3 = 2
ary3 = 3
ary3 = 4
ary4 = 1
ary4 = 2
ary4 = 3
ary4 = 4
hash1 = e1 = 1
hash1 = e2 = 2
hash1 = e3 = 3
hash1 = e4 = 4
hash2 = e1 = 1
hash2 = e2 = 2
hash2 = e3 = 3
hash3 = e1 = 1
hash3 = e2 = 2
hash3 = e3 = 3
hash3 = e4 = 4
hash4 = e1 = 1
hash4 = e2 = 2
hash4 = e3 = 3
hash4 = e4 = 4

「Using a hash as a reference is deprecated」というのが気になるが、また後で調べよう。

指定するキーワードを全て含むファイルを探すスクリプト

指定するキーワードのいずれかを含むファイルを探す場合には、単純に

egrep -lr "(key1|key2|key3|key4|key5|key6)" data

としてやれば良くて、結果は以下のようになる。

data/1_2_3_4_5.txt
data/1_2_3_4_5_6.txt
data/1_2_3_5_6.txt

ただ、その逆、「指定するキーワードの全て」を含むファイルを探すとなるとなかなか簡単にはいかない。

一案として、

grep -lr key6 $(grep -lr key5 $(grep -lr key4 $(grep -lr key3 $(grep -lr key2 $(grep -lr key1 data)))))

なんていうのも一つの手だが、キーワードが増えるごとにネストの段数が増えるのが煩わしいし、なんか気持ち悪い。

なので、ちょっとしたシェルスクリプトを書いてみた。 なお、【パスに空白が含まれたりする】ケースは知らないなので、その辺りはご容赦を。

#! /bin/bash

#debug=1

dir=$1
if [ ! -d "${dir}" ]; then
    printf "%s: No such directory.\n" "${dir}"
    exit 1
fi
shift
keys=($@)
if [ ${#keys[@]} -eq 0 ]; then
    printf "Usage: %s dir keyword1 [keyword2 ...]\n" "${0}"
    exit 2
fi

if [ ${debug} ]; then
    echo "Count of keys is ${#keys[@]}"
fi

for f in $(find "${dir}" -type f) ; do
    ct=0
    for k in ${keys[@]} ; do
        if [ ${debug} ]; then
            echo "Checking ${f} for keyword '${k}'..."
            grep -m 1 -c "${k}" "${f}" /dev/null | grep -v ':0$'
        fi
        tmpCt=$(grep -m 1 -c "${k}" "${f}" /dev/null | grep -v ':0$' | wc -l)
        if [ ${tmpCt} -eq 0 ]; then
            break
        fi
        ct=$((ct+tmpCt))
    done
    if [ ${ct} -eq ${#keys[@]} ]; then
        if [ ${debug} ]; then
            echo "OK: ${f}"
        else
            echo "${f}"
        fi
    else
        if [ ${debug} ]; then
            echo "NG: ${f}"
        fi
    fi
done

これを

$ debug=1 ./grep-all.sh data key1 key2 key3 key4
Count of keys is 4
Checking data/1_2_3_4_5.txt for keyword 'key1'...
data/1_2_3_4_5.txt:1
Checking data/1_2_3_4_5.txt for keyword 'key2'...
data/1_2_3_4_5.txt:1
Checking data/1_2_3_4_5.txt for keyword 'key3'...
data/1_2_3_4_5.txt:1
Checking data/1_2_3_4_5.txt for keyword 'key4'...
data/1_2_3_4_5.txt:1
OK: data/1_2_3_4_5.txt
Checking data/1_2_3_4_5_6.txt for keyword 'key1'...
data/1_2_3_4_5_6.txt:1
Checking data/1_2_3_4_5_6.txt for keyword 'key2'...
data/1_2_3_4_5_6.txt:1
Checking data/1_2_3_4_5_6.txt for keyword 'key3'...
data/1_2_3_4_5_6.txt:1
Checking data/1_2_3_4_5_6.txt for keyword 'key4'...
data/1_2_3_4_5_6.txt:1
OK: data/1_2_3_4_5_6.txt
Checking data/1_2_3_5_6.txt for keyword 'key1'...
data/1_2_3_5_6.txt:1
Checking data/1_2_3_5_6.txt for keyword 'key2'...
data/1_2_3_5_6.txt:1
Checking data/1_2_3_5_6.txt for keyword 'key3'...
data/1_2_3_5_6.txt:1
Checking data/1_2_3_5_6.txt for keyword 'key4'...
NG: data/1_2_3_5_6.txt
Checking data/2_3_4_5_6.txt for keyword 'key1'...
NG: data/2_3_4_5_6.txt

としたり、

$ ./grep-all.sh data key1 key2 key3 key4 key5 key6
data/1_2_3_4_5_6.txt

としたり、という感じで。

【2023/06/11改版】 オリジナルのスクリプトは、key1にヒットしなくてもkey2~key6をチェックしてしまい、非常に効率が悪いと気付いたので改善。

オリジナルも残しておく。

#! /bin/bash

#debug=1

dir=$1
if [ ! -d "${dir}" ]; then
    printf "%s: No such directory.\n" "${dir}"
    exit 1
fi
shift
keys=($@)
if [ ${#keys[@]} -eq 0 ]; then
    printf "Usage: %s dir keyword1 [keyword2 ...]\n" "${0}"
    exit 2
fi

if [ ${debug} ]; then
    echo "Count of keys is ${#keys[@]}"
fi

for f in $(find "${dir}" -type f) ; do
    ct=0
    for k in ${keys[@]} ; do
        ct=$((ct+$(grep -m 1 -c "${k}" "${f}" /dev/null | grep -v ':0$' | wc -l)))
    done
    if [ ${ct} -eq ${#keys[@]} ]; then
        if [ ${debug} ]; then
            echo "OK: ${f}"
        else
            echo "${f}"
        fi
    else
        if [ ${debug} ]; then
            echo "NG: ${f}"
        fi
    fi
done

短縮URLの展開処理(PHP)の改版

古き良き時代は終わりを告げ、Webサイトへの接続はHTTP over SSL/TLSが当たり前になった。

hhelibex.hatenablog.jp

hhelibex.hatenablog.jp

そんなわけで、久々に引っ張り出した短縮URL展開プログラムのPHP版が、このままじゃダメだ!という状況になっていたので改版。

<?php
class ShortURL {
    private static $services = null;
    private static function initServices() {
        if (!is_null(self::$services)) {
            return;
        }
        self::$services = array();
        self::$services[] = parse_url("http://t.co/");
        self::$services[] = parse_url("https://t.co/");
        self::$services[] = parse_url("http://bit.ly/");
        self::$services[] = parse_url("https://bit.ly/");
        self::$services[] = parse_url("http://goo.gl/");
        self::$services[] = parse_url("https://goo.gl/");
        self::$services[] = parse_url("http://tinyurl.com/");
        self::$services[] = parse_url("https://tinyurl.com/");
        self::$services[] = parse_url("http://tiny.one/");
        self::$services[] = parse_url("https://tiny.one/");
        self::$services[] = parse_url("http://htn.to/");
        self::$services[] = parse_url("https://htn.to/");
    }

    public static function expand($shortUrl) {
        self::initServices();

        $parsed = parse_url($shortUrl);
        if (!$parsed) {
            return $shortUrl;
        }
        $isTarget = false;
        foreach (self::$services as $service) {
            if ($service['scheme'] === $parsed['scheme']
                    && $service['host'] === $parsed['host']) {
                $isTarget = true;
                break;
            }
        }
        if (!$isTarget) {
            return $shortUrl;
        }

        $defaultPort = $service['scheme'] === 'https' ? 443 : 80;
        $host = ($service['scheme'] === 'https'
                    ? 'ssl://' : '') . $parsed['host'];
        $port = isset($parsed['port']) && $parsed['port'] > 0
                    ? $parsed['port'] : $defaultPort;
        $sock = fsockopen($host, $port);
        if (!$sock) {
            throw new Exception($shortUrl . ': Connection failed.');
        }

        fwrite($sock, "GET {$parsed['path']} HTTP/1.1\r\n");
        fwrite($sock, "Host: {$parsed['host']}\r\n");
        fwrite($sock, "Accept: */*\r\n");
        fwrite($sock, "\r\n");
        $returnUrl = null;
        while (($header = fgets($sock))) {
            $header = trim($header);
            if (!$header) {
                break;
            }
            if (preg_match("/^Location: /i", $header)) {
                $returnUrl = preg_replace("/^Location: /i", "", $header);
            }
        }
        fclose($sock);
        if ($returnUrl) {
            return $returnUrl;
        } else {
            return $shortUrl;
        }
    }
}

ポイントは、HTTP over SSL/TLSの際には「デフォルトポート番号が443」ということと、「fsockopenを使うときは "ssl://" を前に付ける必要がある」ということかしら。 後は、Locationヘッダを全て小文字で返してくる輩もいたので、その対応も追加(preg_match/preg_replaceの "i" オプションの指定)。

呼び出しのサンプル。 これに適当にHTMLコードを付け加えてあげると、最大100段まで展開・表示してくれます。

<pre><?php
if (isset($_POST['url'])) {
    include('./ShortURL.php');
    $url = $_POST['url'];
    try {
        printf("%s\r\n", htmlspecialchars($url));
        $indent = "┗";
        for ($i = 0; $i < 100 && ($newUrl = ShortURL::expand($url)) !== $url; ++$i) {
            printf("%s\r\n", $indent . htmlspecialchars($newUrl));
            $indent = " " . $indent;
            $url = $newUrl;
        }
    } catch (Exception $e) {
        echo htmlspecialchars($e->getMessage());
    }
}
?></pre>

実際に動くものをサンプルコードと合わせて提示するサイトを作りたいと思いながら幾星霜・・・

Net::SMTPSを使い始めたところからの悪夢⇒DLLがロードされない問題の調査方法

苦しんだ3日間の奮闘メモ。

事の始まり

PerlのNet::SMTPSモジュールを使って、GmailSMTPサーバー経由でメールを送ろうという試みを始めたところから悪夢は始まった。

プログラムのコアな部分は大まかに以下のような感じ。

sub send {
        my $self = shift;
        my ($args) = @_;
        my $from      = $args->{from};
        my $to        = $args->{to};
        my $header    = $args->{header};
        my $body      = $args->{body};

        my $config = MyConfig->new;
        $config->load();
        my $smtp = $config->getSmtpAddress();
        my $port = $config->getSmtpPort();
        my $user = $config->getSmtpUser();
        my $password = $config->getSmtpPassword();

        my $mail = Net::SMTPS->new($smtp,
            Hello => $smtp,
            Port => $port,
            doSSL => 'starttls',
            Debug => 0
        );
#       $mail->starttls();
        $mail->auth($user, $password);
        $mail->mail($from);
        $mail->to($to);
        $mail->data();
        $mail->datasend(join("\r\n", $header, $body));
        $mail->dataend();
        $mail->quit();
}

support.google.com

とか。

support.google.com

こうすればいいのか、とか。

まぁいろいろとあったが、何とかCentOS 7のVM上でNet::SMTPSを使ってGmailSMTPサーバー経由でメールを送れるようになった。

問題はここから。

悪夢の始まり

Windows環境に持ってきて、XAMPPに同梱されているPerl.exeで同じプログラムを実行すると、なぜか

Failed to open SMTPS connection: Bad file descriptor at・・

というエラーメッセージが出てメールが送れない。 printデバッグで、

require IO::Socket::SSL;

しているところで失敗しているっぽいことを突き止めたり、 SET PERL_DL_DEBUG=1 とすると、以下のようにデバッグログが出ることを知ったりする。

DynaLoader.pm loaded (. C:/xampp/perl/site/lib C:/xampp/perl/vendor/lib C:/xampp/perl/lib, C:\xampp\c\lib \xampp\c\x86_64-w64-mingw32\lib \xampp\c\lib\gcc\x86_64-w64-mingw32\8.3.0)
DynaLoader::bootstrap for Net::SSLeay (auto/Net/SSLeay/SSLeay.xs.dll)
DynaLoader::bootstrap for Digest::MD5 (auto/Digest/MD5/MD5.xs.dll)
Failed to open SMTPS connection: Bad file descriptor at ・・・

そこで、DLLのロードをしているDynaLoader.pmにprintデバッグして、 MD5.xs.dll ではなく、やはり SSLeay.xs.dll のロードに失敗していることを確認。

SSLeayと言えば libeay32.dllssleay32.dll という頭もあったのだろう。 また、以下のサイトを見てしまったのも悪夢を助長させる要因となった。

stackoverflow.com

(・・・2日後・・・)

ふと、「そもそも依存しているのはlibeay32.dllssleay32.dllじゃなかったりする?」と疑問を持ったところから急展開を見せる。

「DLL 依存性」などのキーワードで探すと、あるDLLが依存しているDLLの一覧を出力する方法を知る。

tech.mlexp.net

kazupon.org

最初、コマンドプロンプト

C:\Users\hhelibex>dumpbin /dependents C:\xampp\perl\vendor\lib\auto\Net\SSLeay\SSLeay.xs.dll
'dumpbin' は、内部コマンドまたは外部コマンド、
操作可能なプログラムまたはバッチ ファイルとして認識されていません。

と言われて、あれ?と思って後者のサイトを見つけ、Visual Studio 2019をインストール&Windows OSの再起動をした。

サイトの記述に従い、Visual Studio 2019を起動し、[ツール]-[コマンドライン]-[開発者用 PowerShell]でPowerShellを起動し、コマンドを叩く。

すると、

**********************************************************************
** Visual Studio 2019 Developer PowerShell v16.11.26
** Copyright (c) 2021 Microsoft Corporation
**********************************************************************
PS C:\Users\hhelibex\source\repos> dumpbin /dependents C:\xampp\perl\vendor\lib\auto\Net\SSLeay\SSLeay.xs.dll
Microsoft (R) COFF/PE Dumper Version 14.29.30148.0
Copyright (C) Microsoft Corporation.  All rights reserved.


Dump of file C:\xampp\perl\vendor\lib\auto\Net\SSLeay\SSLeay.xs.dll

File Type: DLL

  Image has the following dependencies:

    msvcrt.dll
    KERNEL32.dll
    libcrypto-1_1-x64__.dll
    libssl-1_1-x64__.dll
    perl532.dll

  Summary

        1000 .CRT
        2000 .bss
        1000 .data
        1000 .edata
        8000 .idata
        3000 .pdata
        D000 .rdata
        1000 .reloc
       5E000 .text
        1000 .tls
        4000 .xdata
PS C:\Users\hhelibex\source\repos>

libcrypto-1_1-x64__.dlllibssl-1_1-x64__.dll !?初めて見たぞ。

とりあえずググる。そして、いろいろとダウンロードして名前を変更して(アンダースコア2つ「__」が入っていないので)、順番に試す。 ダメ。

途方に暮れながら、何気なくPC内を探す。 ・・ なんかある!?

C:\Users\hhelibex> dir C:\xampp\apache\bin
 ドライブ C のボリューム ラベルがありません。
 ボリューム シリアル番号は 4006-07DE です

 C:\Users\hhelibex\xampp\apache\bin のディレクトリ

2023/05/23  20:33    <DIR>          .
2023/05/23  20:33    <DIR>          ..
2023/03/07  22:22            98,816 ab.exe
2023/03/07  22:22           110,592 abs.exe
2023/03/07  22:22            43,008 ApacheMonitor.exe
2023/03/07  22:22            19,456 apr_crypto_openssl-1.dll
2023/03/07  22:22            31,232 apr_dbd_odbc-1.dll
2023/03/07  22:22            14,848 apr_ldap-1.dll
2022/05/30  19:58           215,352 curl-ca-bundle.crt
2019/02/06  15:58         4,110,456 curl.exe
2023/03/07  22:25             9,192 dbmmanage.pl
2023/03/07  22:22           101,888 htcacheclean.exe
2023/03/07  22:22           124,416 htdbm.exe
2023/03/07  22:22            86,016 htdigest.exe
2023/03/07  22:22           118,784 htpasswd.exe
2023/03/07  22:24            30,720 httpd.exe
2023/03/07  22:22            65,536 httxt2dbm.exe
2023/05/23  20:33    <DIR>          iconv
2023/03/15  03:30        30,422,016 icudt71.dll
2023/03/15  03:30         3,031,552 icuin71.dll
2023/03/15  03:30            60,928 icuio71.dll
2023/03/15  03:30         2,253,312 icuuc71.dll
2021/09/12  18:59            55,808 jansson.dll
2023/03/07  22:20           215,552 libapr-1.dll
2023/03/07  22:20            36,864 libapriconv-1.dll
2023/03/07  22:20           294,912 libaprutil-1.dll
2023/02/09  21:49         3,445,248 libcrypto-1_1-x64.dll     ⇐☆☆☆
2019/02/06  15:58         1,020,536 libcurl.dll
2023/03/07  22:21           459,776 libhttpd.dll
2023/03/15  03:30           209,920 libsasl.dll
2023/03/15  03:30           380,928 libssh2.dll
2023/02/09  21:50           689,664 libssl-1_1-x64.dll       ⇐☆☆☆
2022/11/02  01:03         1,373,696 libxml2.dll
2023/03/07  22:22            58,368 logresolve.exe
2019/04/05  23:28           184,320 lua52.dll
2023/03/07  22:16           156,160 nghttp2.dll
2023/02/09  21:51           550,912 openssl.exe
2021/08/23  23:32           400,896 pcre.dll
2023/01/12  18:44           564,224 pcre2-8.dll
2012/04/17  02:30            61,440 pv.exe
2023/03/07  22:22            78,848 rotatelogs.exe
2023/03/07  22:22            18,432 wintty.exe
2022/11/02  01:06            89,600 zlib1.dll
              40 個のファイル          51,294,224 バイト
               3 個のディレクトリ  415,936,548,864 バイトの空き領域
C:\Users\hhelibex> 

ファイル名はちょっと違うけど、それっぽい! 早速PATHを通して実行してみる。。。やっぱりダメ。じゃあ、コピーして名前を変更して実行。 ・・・ 動いた!!!

じゃあ、PATHを通さずに、libcrypto-1_1-x64__.dlllibssl-1_1-x64__.dllだけコピーして・・ダメ。 まぁ、とりあえず動く方法が見つかって、ようやく一安心したところで、詳細を調べてみた。

先ほどのDLLの依存関係の続き。

PS C:\Users\hhelibex\source\repos> dumpbin /dependents C:\Users\hhelibex\SMTPServer\libcrypto-1_1-x64__.dll
Microsoft (R) COFF/PE Dumper Version 14.29.30148.0
Copyright (C) Microsoft Corporation.  All rights reserved.


Dump of file C:\Users\hhelibex\SMTPServer\libcrypto-1_1-x64__.dll

File Type: DLL

  Image has the following dependencies:

    WS2_32.dll
    ADVAPI32.dll
    USER32.dll
    bcrypt.dll
    KERNEL32.dll
    VCRUNTIME140.dll
    api-ms-win-crt-stdio-l1-1-0.dll
    api-ms-win-crt-convert-l1-1-0.dll
    api-ms-win-crt-string-l1-1-0.dll
    api-ms-win-crt-time-l1-1-0.dll
    api-ms-win-crt-utility-l1-1-0.dll
    api-ms-win-crt-runtime-l1-1-0.dll
    api-ms-win-crt-filesystem-l1-1-0.dll
    api-ms-win-crt-heap-l1-1-0.dll
    api-ms-win-crt-environment-l1-1-0.dll

  Summary

        1000 .00cfg
        8000 .data
        3000 .idata
       1C000 .pdata
       CF000 .rdata
        8000 .reloc
        1000 .rsrc
      252000 .text
PS C:\Users\hhelibex\source\repos> dumpbin /dependents C:\Users\hhelibex\SMTPServer\libssl-1_1-x64__.dll
Microsoft (R) COFF/PE Dumper Version 14.29.30148.0
Copyright (C) Microsoft Corporation.  All rights reserved.


Dump of file C:\Users\hhelibex\SMTPServer\libssl-1_1-x64__.dll

File Type: DLL

  Image has the following dependencies:

    libcrypto-1_1-x64.dll     ⇐☆☆☆
    KERNEL32.dll
    VCRUNTIME140.dll
    api-ms-win-crt-time-l1-1-0.dll
    api-ms-win-crt-string-l1-1-0.dll
    api-ms-win-crt-utility-l1-1-0.dll
    api-ms-win-crt-runtime-l1-1-0.dll
    api-ms-win-crt-convert-l1-1-0.dll
    api-ms-win-crt-stdio-l1-1-0.dll

  Summary

        1000 .00cfg
        5000 .data
        6000 .idata
        6000 .pdata
       23000 .rdata
        2000 .reloc
        1000 .rsrc
       74000 .text
PS C:\Users\hhelibex\source\repos>

・・おまえか! libssl-1_1-x64__.dlllibcrypto-1_1-x64.dll に依存している。 だからlibcrypto-1_1-x64__.dlllibssl-1_1-x64__.dllだけあってもダメで、同じ内容でファイル名だけ違う libcrypto-1_1-x64.dll がいないとダメなのか。

案の定、ファイルを3つ用意したら、PATHを通す必要もなく動いた。 あとは、これをWindowsサービス登録する形になるから、その場合にどうなるかだが、それはこれから調査。

反省

「SSLeay」と言えば「libeay32.dllとssleay32.dll」という思い込みから悪夢が始まり、3日間くらい浪費したので、思い込みは良くない。 でも、もう少しエラーメッセージを改善してほしい。依存しているけど見つからないライブラリをエラーメッセージに載せてくれればここまで悩まずに済んだんだけどな。

fopenに存在しないファイル名を指定したときの挙動

PHP 5.4.16からPHP 8.1.7にバージョンアップをしようとしたときのこと。

<input type="file" name="file" ~に値が指定されていないときに

fopen($_FILES['file']['tmp_name'], 'r');

したとき、例外が発生するようになってしまったので調査。同じPHP 5.x系ということで、php56を使い、php56とphp81での比較とする。

そもそものコードは以下のような感じ。

Main1.php

<?php

$filename = $argv[1];

$fp = fopen($filename, "r");
if (!$fp) {
    print "{$filename}: Cannot open file.\n";
    exit(1);
}

print "{$filename}: OK\n";

// ・・・

fclose($fp);

php56での実行結果。

$ php56 Main1.php Main1.php
Main1.php: OK
$ php56 Main1.php a.txt
PHP Warning:  fopen(a.txt): failed to open stream: No such file or directory in /home/hhelibex/blog/2022-0705-01/Main1.php on line 5
a.txt: Cannot open file.
$ php56 Main1.php ""
PHP Warning:  fopen(): Filename cannot be empty in /home/hhelibex/blog/2022-0705-01/Main1.php on line 5
: Cannot open file.
$ 

php81での実行結果。

$ php81 Main1.php Main1.php
Main1.php: OK
$ php81 Main1.php a.txt
PHP Warning:  fopen(a.txt): Failed to open stream: No such file or directory in /home/hhelibex/blog/2022-0705-01/Main1.php on line 5
a.txt: Cannot open file.
$ php81 Main1.php ""
PHP Fatal error:  Uncaught ValueError: Path cannot be empty in /home/hhelibex/blog/2022-0705-01/Main1.php:5
Stack trace:
#0 /home/hhelibex/blog/2022-0705-01/Main1.php(5): fopen()
#1 {main}
  thrown in /home/hhelibex/blog/2022-0705-01/Main1.php on line 5
$ 

ファイル名が空でなく存在しないファイルの場合の挙動はWarningのまま変わらないが、ファイル名が空文字列の場合に、PHP 8.1ではValueErrorが発生するようになってしまった。

対症療法をするなら、以下のような感じだろうか。

Main2.php

<?php

$filename = $argv[1];

if (empty($filename)) {
    print "Filename cannot be empty.\n";
    exit(1);
}
$fp = fopen($filename, "r");
if (!$fp) {
    print "{$filename}: Cannot open file.\n";
    exit(1);
}

print "{$filename}: OK\n";

// ・・・

fclose($fp);

しかし、ファイルが存在しない場合のWarningが気持ち悪い。

これは、C言語でプログラムを書いていた頃の癖でfopen()の実行結果しかチェックしないのが問題なのだろう。

ということで、PHPでの最適解は以下になるのではないか。

Main3.php

<?php

$filename = $argv[1];

if (!file_exists($filename)
|| !($fp = fopen($filename, "r"))) {
    print "{$filename}: Cannot open file.\n";
    exit(1);
}

print "{$filename}: OK\n";

// ・・・

fclose($fp);

php81での実行結果。

$ php81 Main3.php Main3.php
Main3.php: OK
$ php81 Main3.php a.txt
a.txt: Cannot open file.
$ php81 Main3.php ""
: Cannot open file.
$ 

厳密には、file_exists()とfopen()の間にファイルが存在しなくなった場合のエラーを考えなければならないが、それはイレギュラーケースとしてログに記録されてもいいのではないだろうか。