現代 Perl 開發模式
用於建構穩固且易於維護應用程式的慣用 Perl 5.36+ 模式與最佳實踐。
何時啟用
- 編寫新的 Perl 程式碼或模組時
- 審查 Perl 程式碼是否符合慣用語法時
- 重構舊版 Perl 程式碼至現代標準時
- 設計 Perl 模組架構時
- 將 5.36 之前的程式碼遷移至現代 Perl 時
運作方式
將這些模式作為現代 Perl 5.36+ 預設值的偏好來應用:簽名、明確模組、精準的錯誤處理以及可測試的邊界。以下範例旨在作為起點複製,然後根據您眼前的實際應用程式、依賴堆疊和部署模型進行調整。
核心原則
1. 使用 use v5.36 編譯指示
單一 use v5.36 取代了舊的樣板程式碼,並啟用嚴格模式、警告以及子常式簽名。
# 良好的做法:現代前置宣告
use v5.36;
sub greet($name) {
say "Hello, $name!";
}
# 不好的做法:舊版樣板
use strict;
use warnings;
use feature 'say', 'signatures';
no warnings 'experimental::signatures';
sub greet {
my ($name) = @_;
say "Hello, $name!";
}
2. 子常式簽名
使用簽名以獲得清晰度和自動參數數量檢查。
use v5.36;
# 良好的做法:帶有預設值的簽名
sub connect_db($host, $port = 5432, $timeout = 30) {
# $host 是必需的,其他有預設值
return DBI->connect("dbi:Pg:host=$host;port=$port", undef, undef, {
RaiseError => 1,
PrintError => 0,
});
}
# 良好的做法:用於可變參數的 Slurpy 參數
sub log_message($level, @details) {
say "[$level] " . join(' ', @details);
}
# 不好的做法:手動參數解包
sub connect_db {
my ($host, $port, $timeout) = @_;
$port //= 5432;
$timeout //= 30;
# ...
}
3. 上下文敏感性
理解標量與列表上下文 — 這是 Perl 的核心概念。
use v5.36;
my @items = (1, 2, 3, 4, 5);
my @copy = @items; # 列表上下文:所有元素
my $count = @items; # 標量上下文:計數 (5)
say "Items: " . scalar @items; # 強制標量上下文
4. 後綴解參照
對巢狀結構使用後綴解參照語法以提高可讀性。
use v5.36;
my $data = {
users => [
{ name => 'Alice', roles => ['admin', 'user'] },
{ name => 'Bob', roles => ['user'] },
],
};
# 良好的做法:後綴解參照
my @users = $data->{users}->@*;
my @roles = $data->{users}[0]{roles}->@*;
my %first = $data->{users}[0]->%*;
# 不好的做法:環綴解參照(在鏈式呼叫中較難閱讀)
my @users = @{ $data->{users} };
my @roles = @{ $data->{users}[0]{roles} };
5. isa 運算子 (5.32+)
中綴型別檢查 — 取代 blessed($o) && $o->isa('X')。
use v5.36;
if ($obj isa 'My::Class') { $obj->do_something }
錯誤處理
eval/die 模式
use v5.36;
sub parse_config($path) {
my $content = eval { path($path)->slurp_utf8 };
die "配置錯誤: $@" if $@;
return decode_json($content);
}
Try::Tiny(可靠的例外處理)
use v5.36;
use Try::Tiny;
sub fetch_user($id) {
my $user = try {
$db->resultset('User')->find($id)
// die "找不到使用者 $id\n";
}
catch {
warn "擷取使用者 $id 失敗: $_";
undef;
};
return $user;
}
原生的 try/catch (5.40+)
use v5.40;
sub divide($x, $y) {
try {
die "除以零" if $y == 0;
return $x / $y;
}
catch ($e) {
warn "錯誤: $e";
return;
}
}
使用 Moo 的現代 OO
偏好使用 Moo 來實現輕量級、現代的物件導向程式設計。僅在其元協定(metaprotocol)被需要時才使用 Moose。
# 良好的做法:Moo 類別
package User;
use Moo;
use Types::Standard qw(Str Int ArrayRef);
use namespace::autoclean;
has name => (is => 'ro', isa => Str, required => 1);
has email => (is => 'ro', isa => Str, required => 1);
has age => (is => 'ro', isa => Int, default => sub { 0 });
has roles => (is => 'ro', isa => ArrayRef[Str], default => sub { [] });
sub is_admin($self) {
return grep { $_ eq 'admin' } $self->roles->@*;
}
sub greet($self) {
return "你好,我是 " . $self->name;
}
1;
# 使用方式
my $user = User->new(
name => 'Alice',
email => '[email protected]',
roles => ['admin', 'user'],
);
# 不好的做法:受祝福的 hashref(無驗證,無存取子)
package User;
sub new {
my ($class, %args) = @_;
return bless \%args, $class;
}
sub name { return $_[0]->{name} }
1;
Moo 角色
package Role::Serializable;
use Moo::Role;
use JSON::MaybeXS qw(encode_json);
requires 'TO_HASH';
sub to_json($self) { encode_json($self->TO_HASH) }
1;
package User;
use Moo;
with 'Role::Serializable';
has name => (is => 'ro', required => 1);
has email => (is => 'ro', required => 1);
sub TO_HASH($self) { { name => $self->name, email => $self->email } }
1;
原生的 class 關鍵字 (5.38+, Corinna)
use v5.38;
use feature 'class';
no warnings 'experimental::class';
class Point {
field $x :param;
field $y :param;
method magnitude() { sqrt($x**2 + $y**2) }
}
my $p = Point->new(x => 3, y => 4);
say $p->magnitude; # 5
}
正規表示式
命名捕獲與 /x 標記
use v5.36;
# 良好的做法:使用 /x 的命名捕獲以增強可讀性
my $log_re = qr{
^ (?<timestamp> \d{4}-\d{2}-\d{2} \s \d{2}:\d{2}:\d{2} )
\s+ \[ (?<level> \w+ ) \]
\s+ (?<message> .+ ) $
}x;
if ($line =~ $log_re) {
say "時間: $+{timestamp}, 等級: $+{level}";
say "訊息: $+{message}";
}
# 不好的做法:位置捕獲(難以維護)
if ($line =~ /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+\[(\w+)\]\s+(.+)$/) {
say "時間: $1, 等級: $2";
}
預編譯模式
use v5.36;
# 良好的做法:編譯一次,多次使用
my $email_re = qr/^[A-Za-z0-9._%+-]+\@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
sub validate_emails(@emails) {
return grep { $_ =~ $email_re } @emails;
}
資料結構
參照與安全深層存取
use v5.36;
# 雜湊與陣列參照
my $config = {
database => {
host => 'localhost',
port => 5432,
options => ['utf8', 'sslmode=require'],
},
};
# 安全的深層存取(任何層級缺失則回傳 undef)
my $port = $config->{database}{port}; # 5432
my $missing = $config->{cache}{host}; # undef, 不會有錯誤
# 雜湊切片
my %subset;
@subset{qw(host port)} = @{$config->{database}}{qw(host port)};
# 陣列切片
my @first_two = $config->{database}{options}->@[0, 1];
# 多重變數 for 迴圈(5.36 為實驗性,5.40 穩定)
use feature 'for_list';
no warnings 'experimental::for_list';
for my ($key, $val) (%$config) {
say "$key => $val";
}
檔案 I/O
三引數 open
use v5.36;
# 良好的做法:搭配 autodie 的三引數 open(核心模組,消除 'or die')
use autodie;
sub read_file($path) {
open my $fh, '<:encoding(UTF-8)', $path;
local $/;
my $content = <$fh>;
close $fh;
return $content;
}
# 不好的做法:雙引數 open(shell 注入風險,請參閱 perl-security)
open FH, $path; # 絕對不要這樣做
open FH, "< $path"; # 仍然不好 — 使用者資料存在於模式字串中
使用 Path::Tiny 進行檔案操作
use v5.36;
use Path::Tiny;
my $file = path('config', 'app.json');
my $content = $file->slurp_utf8;
$file->spew_utf8($new_content);
# 迭代目錄
for my $child (path('src')->children(qr/\.pl$/)) {
say $child->basename;
}
模組組織
標準專案佈局
MyApp/
├── lib/
│ └── MyApp/
│ ├── App.pm # 主模組
│ ├── Config.pm # 設定
│ ├── DB.pm # 資料庫層
│ └── Util.pm # 工具
├── bin/
│ └── myapp # 進入點腳本
├── t/
│ ├── 00-load.t # 編譯測試
│ ├── unit/ # 單元測試
│ └── integration/ # 整合測試
├── cpanfile # 相依性
├── Makefile.PL # 建置系統
└── .perlcriticrc # linting 設定
Exporter 模式
package MyApp::Util;
use v5.36;
use Exporter 'import';
our @EXPORT_OK = qw(trim);
our %EXPORT_TAGS = (all => \@EXPORT_OK);
sub trim($str) { $str =~ s/^\s+|\s+$//gr }
1;
工具
perltidy 設定 (.perltidyrc)
-i=4 # 4 空格縮排
-l=100 # 100 字元行長度
-ci=4 # 延續縮排
-ce # cuddled else
-bar # 左大括號在同一行
-nolq # 不要向外縮進長引號字串
perlcritic 設定 (.perlcriticrc)
severity = 3
theme = core + pbp + security
[InputOutput::RequireCheckedSyscalls]
functions = :builtins
exclude_functions = say print
[Subroutines::ProhibitExplicitReturnUndef]
severity = 4
[ValuesAndExpressions::ProhibitMagicNumbers]
allowed_values = 0 1 2 -1
相依性管理 (cpanfile + carton)
cpanm App::cpanminus Carton # 安裝工具
carton install # 從 cpanfile 安裝相依性
carton exec -- perl bin/myapp # 使用本機相依性執行
# cpanfile
requires 'Moo', '>= 2.005';
requires 'Path::Tiny';
requires 'JSON::MaybeXS';
requires 'Try::Tiny';
on test => sub {
requires 'Test2::V0';
requires 'Test::MockModule';
};
快速參考:現代 Perl 慣用寫法
| 舊有模式 | 現代替代方案 |
|---|---|
use strict; use warnings; | use v5.36; |
my ($x, $y) = @_; | sub foo($x, $y) { ... } |
@{ $ref } | $ref->@* |
%{ $ref } | $ref->%* |
open FH, "< $file" | open my $fh, '<:encoding(UTF-8)', $file |
blessed hashref | 帶有型別的 Moo 類別 |
$1, $2, $3 | $+{name} (命名捕獲) |
eval { }; if ($@) | Try::Tiny 或原生 try/catch (5.40+) |
BEGIN { require Exporter; } | use Exporter 'import'; |
| 手動檔案操作 | Path::Tiny |
blessed($o) && $o->isa('X') | $o isa 'X' (5.32+) |
builtin::true / false | use builtin 'true', 'false'; (5.36+, 實驗性) |
反模式
# 1. 雙引數 open(安全性風險)
open FH, $filename; # 絕對不要
# 2. 間接物件語法(語意模糊)
my $obj = new Foo(bar => 1); # 不好
my $obj = Foo->new(bar => 1); # 良好
# 3. 過度依賴 $_
map { process($_) } grep { validate($_) } @items; # 難以理解
my @valid = grep { validate($_) } @items; # 較好:分解它
my @results = map { process($_) } @valid;
# 4. 停用 strict refs
no strict 'refs'; # 幾乎總是錯的
${"My::Package::$var"} = $value; # 改用雜湊
# 5. 使用全域變數作為設定
our $TIMEOUT = 30; # 不好:可變的全域變數
use constant TIMEOUT => 30; # 較好:常數
# 最佳:帶有預設值的 Moo 屬性
# 6. 使用字串 eval 載入模組
eval "require $module"; # 不好:程式碼注入風險
eval "use $module"; # 不好
use Module::Runtime 'require_module'; # 良好:安全的模組載入
require_module($module);
請記住:現代 Perl 是乾淨、可讀且安全的。讓 use v5.36 處理樣板程式碼,使用 Moo 處理物件,並偏好使用 CPAN 上經實戰考驗的模組,而非自行撰寫解決方案。