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 = [