diff --git a/Dockerfile.perl b/Dockerfile.perl
new file mode 100644
index 0000000..9434a70
--- /dev/null
+++ b/Dockerfile.perl
@@ -0,0 +1,26 @@
+# Build stage
+FROM perl:5.40 AS build
+
+WORKDIR /src
+
+# Copy client library
+COPY kenaito-conf-perl-client/ ./kenaito-conf-perl-client/
+
+# Install client dependencies
+WORKDIR /src/kenaito-conf-perl-client
+RUN cpanm --notest HTTP::Tiny JSON::PP
+
+# Copy demo project
+WORKDIR /src
+COPY kenaito-conf-perl-client-demo/ ./kenaito-conf-perl-client-demo/
+
+# Runtime stage
+FROM perl:5.40-slim
+
+WORKDIR /app
+COPY --from=build /src/kenaito-conf-perl-client/lib /app/kenaito-conf-perl-client/lib
+COPY --from=build /src/kenaito-conf-perl-client-demo/app.pl .
+
+ENV PERL5LIB=/app/kenaito-conf-perl-client/lib
+
+ENTRYPOINT ["perl", "app.pl"]
diff --git a/README.md b/README.md
index 381140a..321f32f 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,8 @@
- **后端**: Go + Gin + xorm + MySQL
- **前端**: Vue 2 + Vite 5 + Element UI 2
-- **客户端**: Go / Java(Spring Boot 2.x & 3.x)/ Python / Node.js / PHP / .NET / C / C++ / Lua / Kotlin / Rust / Ruby
+- **客户端**: Go / Java(Spring Boot 2.x & 3.x)/ Python / Node.js / Perl / PHP / .NET / C / C++ / Lua / Kotlin / Rust /
+ Ruby
## 项目结构
@@ -22,6 +23,8 @@ kenaito-conf/
├── kenaito-conf-python-client-demo/ # Python 客户端示例(端口 8082)
├── kenaito-conf-nodejs-client/ # Node.js 客户端 SDK(Node.js 18+)
├── kenaito-conf-nodejs-client-demo/ # Node.js 客户端示例(端口 8083)
+├── kenaito-conf-perl-client/ # Perl 客户端 SDK(Perl 5.16+)
+├── kenaito-conf-perl-client-demo/ # Perl 客户端示例
├── kenaito-conf-php-client/ # (未支持)PHP 客户端 SDK(PHP 8.0+)
├── kenaito-conf-php-client-demo/ # (未支持)PHP 客户端示例
├── kenaito-conf-dotnet-client/ # .NET 客户端 SDK(.NET 6.0+)
@@ -264,6 +267,60 @@ const allConfigs = client.getAll();
client.stop();
```
+### Perl 客户端
+
+适用于 Perl 5.16+ 项目。
+
+**1. 安装**
+
+```bash
+cd kenaito-conf-perl-client
+cpanm .
+```
+
+或在项目中直接引入 lib 路径:
+
+```perl
+use lib '/path/to/kenaito-conf-perl-client/lib';
+```
+
+**2. 使用**
+
+```perl
+use Kenaito::Conf::Config;
+use Kenaito::Conf::Client;
+
+my $cfg = Kenaito::Conf::Config->new(
+ base_url => 'http://localhost:8080',
+ app_name => 'my-app',
+ env => 'daily',
+ sync_interval => 10,
+);
+
+my $client = Kenaito::Conf::Client->new(config => $cfg);
+
+# 监听配置变更
+$client->on_change(sub {
+ my ($changed) = @_;
+ for my $k (keys %$changed) {
+ print "config changed: $k = $changed->{$k}\n";
+ }
+});
+
+# 启动客户端(拉取配置)
+$client->start();
+
+# 获取配置
+my $value = $client->get('database.host');
+my $all = $client->get_all();
+
+# 手动同步一次
+$client->sync_once();
+
+# 停止
+$client->stop();
+```
+
## API 概览
### 管理端 API(需 JWT 认证)
diff --git a/README_DOCKERFILE.md b/README_DOCKERFILE.md
index d12c14e..b3a343f 100644
--- a/README_DOCKERFILE.md
+++ b/README_DOCKERFILE.md
@@ -74,6 +74,18 @@ docker run -it --rm \
kenaito-conf-dotnet-demo
```
+## Perl 客户端(测试通过)
+
+```bash
+docker build -f Dockerfile.perl -t kenaito-conf-perl-demo .
+
+docker run -it --rm \
+ -e KENAITO_BASE_URL=http://host.docker.internal:8080 \
+ -e KENAITO_APP_NAME=kenaito-springboot-demo \
+ -e KENAITO_ENV=daily \
+ kenaito-conf-perl-demo
+```
+
## Spring Boot 2 客户端(测试通过)
```bash
diff --git a/kenaito-conf-dotnet-client/KenaitoConfClient.csproj b/kenaito-conf-dotnet-client/KenaitoConfClient.csproj
index 584cbed..44f1e9c 100644
--- a/kenaito-conf-dotnet-client/KenaitoConfClient.csproj
+++ b/kenaito-conf-dotnet-client/KenaitoConfClient.csproj
@@ -5,7 +5,7 @@
enable
enable
Kenaito.Conf.Client
- 1.0.0
+ 1.0.3
odboy
www.odboy.cn
Kenaito Config Center .NET Client
diff --git a/kenaito-conf-nodejs-client/package.json b/kenaito-conf-nodejs-client/package.json
index 229bdb1..07ca468 100644
--- a/kenaito-conf-nodejs-client/package.json
+++ b/kenaito-conf-nodejs-client/package.json
@@ -1,6 +1,6 @@
{
"name": "kenaito-conf",
- "version": "1.0.0",
+ "version": "1.0.3",
"description": "Kenaito Config Center Node.js Client",
"type": "module",
"main": "lib/index.js",
diff --git a/kenaito-conf-perl-client-demo/app.pl b/kenaito-conf-perl-client-demo/app.pl
new file mode 100644
index 0000000..ec06c5d
--- /dev/null
+++ b/kenaito-conf-perl-client-demo/app.pl
@@ -0,0 +1,83 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+use FindBin;
+use lib "$FindBin::Bin/../../kenaito-conf-perl-client/lib";
+use JSON::PP;
+use Kenaito::Conf::Config;
+use Kenaito::Conf::Client;
+
+my %config_values = (
+ 'database.host' => $ENV{DATABASE_HOST} // '192.168.1.1',
+ 'database.port' => $ENV{DATABASE_PORT} // '8888',
+ 'database.name' => $ENV{DATABASE_NAME} // 'xxxx',
+ 'database.user' => $ENV{DATABASE_USER} // 'xxxx',
+ 'database.password' => $ENV{DATABASE_PASSWORD} // 'xxxxxxxx',
+);
+
+sub get_env {
+ my ($key, $default) = @_;
+ return defined $ENV{$key} && $ENV{$key} ne '' ? $ENV{$key} : $default;
+}
+
+my $cfg = Kenaito::Conf::Config->new(
+ base_url => get_env('KENAITO_BASE_URL', 'http://localhost:8080'),
+ app_name => get_env('KENAITO_APP_NAME', 'kenaito-springboot-demo'),
+ env => get_env('KENAITO_ENV', 'daily'),
+ version => get_env('KENAITO_VERSION', 'latest'),
+ token => get_env('KENAITO_TOKEN', ''),
+ sync_interval => int(get_env('KENAITO_SYNC_INTERVAL', '10')),
+ cache_dir => get_env('KENAITO_CACHE_DIR', '.'),
+);
+
+my $client = Kenaito::Conf::Client->new(config => $cfg);
+
+$client->on_change(sub {
+ my ($changed) = @_;
+ print "Config changed:\n";
+ for my $k (sort keys %$changed) {
+ my $v = $changed->{$k};
+ print " $k = $v\n";
+ if (exists $config_values{$k}) {
+ $config_values{$k} = $v;
+ print " [updated] $k = $v\n";
+ }
+ }
+});
+
+eval {
+ $client->start();
+ 1;
+} or do {
+ warn "Failed to init config client, using local defaults: $@\n";
+};
+
+my $remote = $client->get_all();
+for my $k (keys %config_values) {
+ if (exists $remote->{$k}) {
+ $config_values{$k} = $remote->{$k};
+ }
+}
+
+my $count = scalar keys %$remote;
+print "loaded $count configs from remote\n\n";
+
+print "Current configs:\n";
+for my $k (sort keys %config_values) {
+ print " $k = $config_values{$k}\n";
+}
+
+print "\nListening for config changes... (press Ctrl+C to stop)\n";
+
+$SIG{INT} = sub {
+ print "\nShutting down...\n";
+ $client->stop();
+ exit 0;
+};
+
+# sync loop
+while (1) {
+ sleep $cfg->{sync_interval};
+ last if $client->{stop};
+ $client->sync_once();
+}
diff --git a/kenaito-conf-perl-client/Makefile.PL b/kenaito-conf-perl-client/Makefile.PL
new file mode 100644
index 0000000..7a84e51
--- /dev/null
+++ b/kenaito-conf-perl-client/Makefile.PL
@@ -0,0 +1,16 @@
+use strict;
+use warnings;
+use ExtUtils::MakeMaker;
+
+WriteMakefile(
+ NAME => 'Kenaito::Conf',
+ VERSION_FROM => 'lib/Kenaito/Conf.pm',
+ ABSTRACT => 'Kenaito Config Center Perl Client',
+ AUTHOR => 'odboy',
+ LICENSE => 'apache_2_0',
+ PREREQ_PM => {
+ 'HTTP::Tiny' => '0.070',
+ 'JSON::PP' => '0',
+ },
+ test => { TESTS => 't/*.t' },
+);
diff --git a/kenaito-conf-perl-client/README.md b/kenaito-conf-perl-client/README.md
new file mode 100644
index 0000000..73c63cd
--- /dev/null
+++ b/kenaito-conf-perl-client/README.md
@@ -0,0 +1,89 @@
+# Kenaito Config Center Perl Client
+
+Perl 客户端 SDK,适用于 Perl 5.16+ 项目。
+
+## 安装
+
+### 从本地安装
+
+```bash
+cd kenaito-conf-perl-client
+cpanm .
+```
+
+### 开发模式(符号链接)
+
+在 demo 项目中添加 lib 路径:
+
+```perl
+use lib '/path/to/kenaito-conf-perl-client/lib';
+```
+
+## 使用
+
+```perl
+use Kenaito::Conf::Config;
+use Kenaito::Conf::Client;
+
+my $cfg = Kenaito::Conf::Config->new(
+ base_url => 'http://localhost:8080',
+ app_name => 'my-app',
+ env => 'daily',
+ version => 'latest',
+ token => '',
+ sync_interval => 10,
+ cache_dir => '.',
+);
+
+my $client = Kenaito::Conf::Client->new(config => $cfg);
+
+# 监听配置变更
+$client->on_change(sub {
+ my ($changed) = @_;
+ for my $k (keys %$changed) {
+ print "config changed: $k = $changed->{$k}\n";
+ }
+});
+
+# 启动客户端(拉取配置)
+$client->start();
+
+# 获取配置
+my $value = $client->get('database.host');
+my $all = $client->get_all();
+
+# 手动同步一次
+$client->sync_once();
+
+# 停止
+$client->stop();
+```
+
+## 配置项
+
+| 参数 | 说明 | 默认值 |
+|----------------|------------|-------------------------|
+| `base_url` | 配置中心地址 | `http://localhost:8080` |
+| `app_name` | 应用名称 | (必填) |
+| `env` | 环境标识 | `dev` |
+| `version` | 配置版本 | `latest` |
+| `token` | 应用 Token | 空 |
+| `sync_interval`| 同步间隔(秒) | `60` |
+| `ignore_global`| 忽略全局配置 | `0` |
+| `cache_dir` | 缓存目录 | `.` |
+
+## 依赖
+
+- Perl 5.16+
+- HTTP::Tiny
+- JSON::PP
+
+## Demo
+
+```bash
+cd kenaito-conf-perl-client-demo
+KENAITO_BASE_URL=http://localhost:8080 \
+KENAITO_APP_NAME=kenaito-springboot-demo \
+KENAITO_ENV=daily \
+perl app.pl
+```
diff --git a/kenaito-conf-perl-client/lib/Kenaito/Conf.pm b/kenaito-conf-perl-client/lib/Kenaito/Conf.pm
new file mode 100644
index 0000000..eb80c5c
--- /dev/null
+++ b/kenaito-conf-perl-client/lib/Kenaito/Conf.pm
@@ -0,0 +1,9 @@
+package Kenaito::Conf;
+use strict;
+use warnings;
+use Kenaito::Conf::Client;
+use Kenaito::Conf::Config;
+
+our $VERSION = '1.0.3';
+
+1;
diff --git a/kenaito-conf-perl-client/lib/Kenaito/Conf/Cache.pm b/kenaito-conf-perl-client/lib/Kenaito/Conf/Cache.pm
new file mode 100644
index 0000000..4ab7854
--- /dev/null
+++ b/kenaito-conf-perl-client/lib/Kenaito/Conf/Cache.pm
@@ -0,0 +1,33 @@
+package Kenaito::Conf::Cache;
+use strict;
+use warnings;
+use JSON::PP;
+use File::Spec;
+
+use constant CACHE_FILE => 'config-cache.json';
+
+sub new {
+ my ($class, $cache_dir) = @_;
+ $cache_dir //= '.';
+ return bless { dir => $cache_dir }, $class;
+}
+
+sub write {
+ my ($self, $data) = @_;
+ my $path = File::Spec->catfile($self->{dir}, CACHE_FILE);
+ open my $fh, '>:encoding(UTF-8)', $path or return;
+ print $fh JSON::PP->new->utf8->encode($data);
+ close $fh;
+}
+
+sub read {
+ my ($self) = @_;
+ my $path = File::Spec->catfile($self->{dir}, CACHE_FILE);
+ open my $fh, '<:encoding(UTF-8)', $path or return undef;
+ local $/;
+ my $json = <$fh>;
+ close $fh;
+ return eval { JSON::PP->new->utf8->decode($json) };
+}
+
+1;
diff --git a/kenaito-conf-perl-client/lib/Kenaito/Conf/Client.pm b/kenaito-conf-perl-client/lib/Kenaito/Conf/Client.pm
new file mode 100644
index 0000000..e974397
--- /dev/null
+++ b/kenaito-conf-perl-client/lib/Kenaito/Conf/Client.pm
@@ -0,0 +1,179 @@
+package Kenaito::Conf::Client;
+use strict;
+use warnings;
+use HTTP::Tiny;
+use JSON::PP;
+use Kenaito::Conf::Config;
+use Kenaito::Conf::Cache;
+use Kenaito::Conf::Listener;
+
+sub new {
+ my ($class, %args) = @_;
+ my $config = $args{config};
+ die "config is required" unless $config;
+
+ my $self = bless {
+ config => $config,
+ cache => Kenaito::Conf::Cache->new($config->{cache_dir}),
+ listener => Kenaito::Conf::Listener->new(),
+ configs => {},
+ last_time => 0,
+ started => 0,
+ stop => 0,
+ http => HTTP::Tiny->new(timeout => 10),
+ }, $class;
+
+ return $self;
+}
+
+sub on_change {
+ my ($self, $handler) = @_;
+ $self->{listener}->on_change($handler);
+}
+
+sub get {
+ my ($self, $key) = @_;
+ return $self->{configs}{$key};
+}
+
+sub get_all {
+ my ($self) = @_;
+ return { %{$self->{configs}} };
+}
+
+sub start {
+ my ($self) = @_;
+ return if $self->{started};
+ $self->{started} = 1;
+
+ eval {
+ $self->_fetch_all();
+ 1;
+ } or do {
+ my $err = $@;
+ warn "[ConfigClient] fetch all configs error: $err, trying cache\n";
+ my $cached = $self->{cache}->read();
+ if ($cached) {
+ $self->_apply_configs($cached);
+ my $count = scalar keys %{$self->{configs}};
+ print "[ConfigClient] loaded $count configs from cache\n";
+ } else {
+ die "fetch configs failed and no cache: $err";
+ }
+ };
+}
+
+sub stop {
+ my ($self) = @_;
+ $self->{stop} = 1;
+}
+
+sub sync_once {
+ my ($self) = @_;
+ eval {
+ $self->_fetch_changed();
+ 1;
+ } or do {
+ warn "[ConfigClient] sync error: $@\n";
+ };
+}
+
+sub _fetch_all {
+ my ($self) = @_;
+ my $url = "$self->{config}{base_url}/api/fetchAllConfigs";
+ my $payload = JSON::PP->new->utf8->encode({
+ appName => $self->{config}{app_name},
+ env => $self->{config}{env},
+ version => $self->{config}{version},
+ ignoreGlobal => $self->{config}{ignore_global} ? \1 : \0,
+ token => $self->{config}{token},
+ });
+
+ my $resp = $self->{http}->post($url, {
+ content => $payload,
+ headers => { 'Content-Type' => 'application/json' },
+ });
+
+ die "HTTP error: $resp->{status} $resp->{reason}" unless $resp->{success};
+
+ my $data = JSON::PP->new->utf8->decode($resp->{content});
+
+ if (exists $data->{code} && $data->{code} != 0) {
+ die "server error: " . ($data->{msg} // 'unknown');
+ }
+
+ if (my $ts = $data->{'latest.fetch.timestamp'}) {
+ $self->{last_time} = int($ts);
+ }
+
+ $self->{cache}->write($data);
+ $self->_apply_configs($data);
+
+ my $count = scalar keys %{$self->{configs}};
+ print "[ConfigClient] fetched $count configs\n";
+}
+
+sub _fetch_changed {
+ my ($self) = @_;
+ my $url = "$self->{config}{base_url}/api/fetchChangedConfigs";
+ my $payload = JSON::PP->new->utf8->encode({
+ appName => $self->{config}{app_name},
+ env => $self->{config}{env},
+ version => $self->{config}{version},
+ lastTime => $self->{last_time},
+ ignoreGlobal => $self->{config}{ignore_global} ? \1 : \0,
+ token => $self->{config}{token},
+ });
+
+ my $resp = $self->{http}->post($url, {
+ content => $payload,
+ headers => { 'Content-Type' => 'application/json' },
+ });
+
+ die "HTTP error: $resp->{status} $resp->{reason}" unless $resp->{success};
+
+ my $result = JSON::PP->new->utf8->decode($resp->{content});
+
+ if (($result->{code} // -1) != 0) {
+ die "server error code: $result->{code}";
+ }
+
+ my $changed_data = $result->{data} || {};
+
+ if (my $ts = $changed_data->{'latest.fetch.timestamp'}) {
+ $self->{last_time} = int($ts);
+ }
+ delete $changed_data->{'latest.fetch.timestamp'};
+
+ # handle deleted keys
+ if (my $deleted_keys_str = delete $changed_data->{deletedKeys}) {
+ my @deleted = split /,/, $deleted_keys_str;
+ for my $key (@deleted) {
+ delete $self->{configs}{$key};
+ }
+ my $count = scalar @deleted;
+ print "[ConfigClient] $count configs deleted: " . join(', ', @deleted) . "\n";
+ }
+
+ if (%$changed_data) {
+ my %changed;
+ for my $k (keys %$changed_data) {
+ $self->{configs}{$k} = $changed_data->{$k};
+ $changed{$k} = $changed_data->{$k};
+ }
+ $self->{listener}->notify(\%changed);
+ my $count = scalar keys %changed;
+ print "[ConfigClient] $count configs changed: " . join(', ', keys %changed) . "\n";
+ }
+}
+
+sub _apply_configs {
+ my ($self, $data) = @_;
+ for my $k (keys %$data) {
+ next if $k =~ /^kenaito\./ || $k eq 'latest.fetch.timestamp';
+ next unless defined $data->{$k} && !ref $data->{$k};
+ $self->{configs}{$k} = $data->{$k};
+ }
+}
+
+1;
diff --git a/kenaito-conf-perl-client/lib/Kenaito/Conf/Config.pm b/kenaito-conf-perl-client/lib/Kenaito/Conf/Config.pm
new file mode 100644
index 0000000..af5d0c2
--- /dev/null
+++ b/kenaito-conf-perl-client/lib/Kenaito/Conf/Config.pm
@@ -0,0 +1,19 @@
+package Kenaito::Conf::Config;
+use strict;
+use warnings;
+
+sub new {
+ my ($class, %args) = @_;
+ return bless {
+ base_url => $args{base_url} // 'http://localhost:8080',
+ app_name => $args{app_name} // '',
+ env => $args{env} // 'dev',
+ version => $args{version} // 'latest',
+ token => $args{token} // '',
+ sync_interval => $args{sync_interval} // 60,
+ ignore_global => $args{ignore_global} // 0,
+ cache_dir => $args{cache_dir} // '.',
+ }, $class;
+}
+
+1;
diff --git a/kenaito-conf-perl-client/lib/Kenaito/Conf/Listener.pm b/kenaito-conf-perl-client/lib/Kenaito/Conf/Listener.pm
new file mode 100644
index 0000000..d74655a
--- /dev/null
+++ b/kenaito-conf-perl-client/lib/Kenaito/Conf/Listener.pm
@@ -0,0 +1,22 @@
+package Kenaito::Conf::Listener;
+use strict;
+use warnings;
+
+sub new {
+ my ($class) = @_;
+ return bless { handlers => [] }, $class;
+}
+
+sub on_change {
+ my ($self, $handler) = @_;
+ push @{$self->{handlers}}, $handler;
+}
+
+sub notify {
+ my ($self, $changed) = @_;
+ for my $h (@{$self->{handlers}}) {
+ eval { $h->($changed); 1 } or warn "[ConfigClient] change handler error: $@\n";
+ }
+}
+
+1;
diff --git a/kenaito-conf-python-client/kenaito_conf/__init__.py b/kenaito-conf-python-client/kenaito_conf/__init__.py
index 7d960a8..65f6646 100644
--- a/kenaito-conf-python-client/kenaito_conf/__init__.py
+++ b/kenaito-conf-python-client/kenaito_conf/__init__.py
@@ -2,4 +2,4 @@ from kenaito_conf.client import ConfigClient
from kenaito_conf.config import Config
__all__ = ["ConfigClient", "Config"]
-__version__ = "1.0.0"
+__version__ = "1.0.3"
diff --git a/kenaito-conf-python-client/pyproject.toml b/kenaito-conf-python-client/pyproject.toml
index b8e72c5..2b6b7d1 100644
--- a/kenaito-conf-python-client/pyproject.toml
+++ b/kenaito-conf-python-client/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kenaito-conf"
-version = "1.0.0"
+version = "1.0.3"
description = "Kenaito Config Center Python Client"
requires-python = ">=3.10"
dependencies = [