[Perl]他の言語にあるアレをもって来ようぜって話

このエントリはPerl Advent Calendar 2024の22日目のエントリとなります。

他の言語にあるアレとは

皆さん、Perl触ってますか?Perlを触っている方もそうでない方も、ご自身がよく使う言語にあるメジャーなライブラリや大変有用な基礎機能というものがあると思います。私が思いつくところですと、例えば以下のようなものでしょうか。

  • C#におけるLINQ
  • F#におけるパイプライン演算子
  • Goにおけるgoroutine
  • RubyにおけるMix-in
  • ElixirにおけるLiveView
  • PHP/LaravelにおけるLivewire
  • などなど・・・

とにかく、いくつかの言語で実装されている便利なやつ、ということを言いたいのです。

で、最近私はTypescriptという言語を触る機会が多いのですが、この言語では大変有用なライブラリがたくさん作られ、提供されています。一部をご紹介します。

  • Prisma ORM : Typescript向けに作られたORM。スキーマ変更に対するケアが素晴らしい。
  • Zod : データバリデーター。非常に複雑なデータ構造に対するスキーマをシンプルに記述できる。
  • Biome : ソースコードのフォーマッティングと構文解析を行うツール。品質の高いコードには必須。

Perlにも便利なものを持ってきてもいいじゃないか

近ごろは、Perlにとって明るい話題というのをなかなか聞かなくなってきたなぁと思うようになりました。個人的にはPerlは好きな言語ですし、私がまともにITエンジニアとして食っていけるようになるきっかけとなった言語ですので、末永く便利に使いたいものだと思っています。

そんなことを考えていたある日、先ほどご紹介したZodというバリデーターの存在を知り、業務でも利用するようになりました。このZodの便利なところは、とにかくスキーマを細やかに指定できるというところであり、そのスキーマ自体が変数として取り扱うことが出来る点です。

例えばご当地バーガーショップのレシートデータを構造化しようと考えた場合、そのデータスキーマを設計する必要があります。注文された商品とその点数、テーブル番号、値段などがデータスキーマに含まれる必要があることでしょう。

Zodで表現するならば、だいたい以下のようになるかと思います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { z } from 'zod'

// 商品単品のスキーマ
const itemSchema = z.object({
// 商品名。便宜上30文字までとしてある
name: z.string().max(30),
// 商品価格。便宜上10万円までとしてある
price: z.number().min(0).max(100000),
})

// 注文のスキーマ
const orderSchema = z.object({
// 商品オブジェクト。どの商品を注文するのかを表す
item: itemSchema,
// 注文数。一度に同じ商品を20個まで注文できる
amounts: z.number().min(1).max(20),
})

// 担当者のスキーマ
const staffSchema = z.object({
// 担当者ID
id: z.number().min(1),
// 担当者名。便宜上30文字までとしてある
name: z.string().max(30),
})

// レシートのスキーマ
const receiptSchema = z.object({
// テーブル番号。1~45まである店舗ということにする
tableNumber: z.number().min(1).max(45),
// 注文の一覧
orders: z.array(orderSchema),
// 注文された日時の記録。デフォルトで現在時刻が記録される
orderedAt: z.date().default(new Date()),
// 注文受付担当者
staff: staffSchema,
})

こういう感じのスキーマ定義を、型がとても緩いPerlでも使えるようにしたら便利では?と思い、移植してみました。それが拙作のCPANライブラリであるPozです。

https://metacpan.org/pod/Poz

Pozのつかいかた

Pozをインストールする場合は cpanm などのPerlモジュール管理ツールをお使いいただくのが最も簡単です。

1
$ cpanm Poz

では、先ほどZodで表現してみたご当地バーガーショップのレシートデータ構造化スキーマを、Pozでもやってみます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
use strict;
use utf8;
use Poz qw/z/;
use Time::Piece ();

# new Date() の代わりを作っておく
my $new_date = sub {
Time::Piece::localtime->strftime('%Y-%m-%dT%H:%M:%SZ');
};

# 商品単品のスキーマ
my $item_schema = z->object({
# 商品名。便宜上30文字までとしてある
name => z->string->max(30),
# 商品価格。便宜上10万円までとしてある
price => z->number->min(0)->max(100000),
})->as('BurgerShop::Item');

# 注文のスキーマ
my $order_schema = z->object({
# 商品オブジェクト。どの商品を注文するのかを表す
item => $item_schema,
# 注文数。一度に同じ商品を20個まで注文できる
amounts => z->number->min(1)->max(20),
})->as('BurgerShop::Order');

# 担当者のスキーマ
my $staff_schema = z->object({
# 担当者ID
id => z->number->min(1),
# 担当者名。便宜上30文字までとしてある
name => z->string->max(30),
})->as('BurgerShop::Staff');

# レシートのスキーマ
my $receipt_schema = z->object({
# テーブル番号。1~45まである店舗ということにする
table_number => z->number->min(1)->max(45),
# 注文の一覧
orders => z->array($order_schema),
# 注文された日時の記録。デフォルトで現在時刻が記録される
ordered_at => z->datetime->default(sub { $new_date->() }),
# 注文受付担当者
staff => $staff_schema,
})->as('BurgerShop::Receipt');

なるべくインターフェースをZodに寄せてありますので、ほぼZodの使い方と一緒です。

違うとことがあるとすれば、 ->as('BurgerShop::Receipt') のあたりでしょうか。これは本家Zodにはない指定でして、Zodの場合は構造体の型を z.infer で生成することができるのですが、Perlの場合はHashRefに型を紐づけたい場合、blessするほかありません。

そのような言語の特性を吸収するべく、バリデーションが通ったHashRefについては as(...) で指定したクラスのオブジェクトとしてblessしてしまおう、という対応をとっています。

なお、実際にバリデーションを行うときには以下のようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
my $data = {
table_number => 10,
orders => [
{
item => {
name => 'チーズバーガー',
price => 250,
},
amounts => 2,
},
{
item => {
name => 'ポテト',
price => 150,
},
amounts => 1,
},
],
staff => {
id => 1,
name => '山田太郎',
},
};

# データを検証
my ($result, $error) = $receipt_schema->safe_parse($data);

上記の例では safe_parse を使っていますが、本家Zodと同じように parse も使えます。ここら辺のインターフェースもZodによせてありますので、Zodに慣れている方であれば違和感なくご利用いただけるのではないでしょうか。

なお、この時以下のテストが通ります(実はこのエントリを書いていてバグに気が付いたので、急いで直して、v0.03をリリースしました・・・!!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# エラーは無し
is($error, undef, 'expected no error');

# テーブル番号は10番
is($result->{table_number}, 10, 'table_number is 10');

# オーダー一覧はBurgerShop::Orderインスタンスの配列になっている
is_deeply($result->{orders}, [
bless({
item => {
name => 'チーズバーガー',
price => 250,
},
amounts => 2,
}, 'BurgerShop::Order'),
bless({
item => {
name => 'ポテト',
price => 150,
},
amounts => 1,
}, 'BurgerShop::Order'),
], 'orders is correct');

# スタッフもBurgerShop::Staffのインスタンスになっている
is_deeply($result->{staff}, bless({
id => 1,
name => '山田太郎',
}, 'BurgerShop::Staff'), 'staff is correct');

# 注文日時がデフォルト値(現在時刻)となっている
like($result->{ordered_at}, qr/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, 'ordered_at is correct');

まあまあ複雑なデータ構造もいい感じに検証されており、無事に検証を通過したデータは適切にblessされているのがわかります。ちょっと頑張りましたね!

kuraとの連携

Perlの型ライブラリに kura というものがございまして、これはMetaCPANにある様々な型ライブラリのうち、 Data::Checks, Type::Tiny, Moose::Meta::TypeConstraint, Mouse::Meta::TypeConstraint, Specio などを横断的に使えるようにしてしまおうという、大変意欲的なものとなっています。

なんと、ありがたいことに、kuraの作者である こばけんさん からPozにコントリビュートをいただいており、 既にkuraとの連携 ができるように調整いただいております。本当にありがたいことです。

1
2
use Poz qw(z);
use kura Name => z->string->min(1)->max(255);

というか、Pozをリリースしてから2日以内くらい?でこれが出来るようになっていたんです。すごくないですか・・・?

まとめ

ZodをPerlに移植したようなバリデーター「Poz」を作った話をさせていただきました。

別の言語で使える便利そうなやつをPerlに持ってくるというのは、かなり楽しいです。

今のご時世、AIによるサポートを得ながらライブラリの開発ができますが、今回作ったPozもGitHub Copilotを存分に活用して開発しました。なかなか簡単に作れたので、ちょっとPerlに元気を与えたいと思った方にはぜひともチャレンジしてもらいたいなと思います。