Building a Zero‑Intrusion Nacos Config Listener Plugin for Webman
This article explains how to create a Webman plugin that integrates Nacos for configuration management, covering design goals, implementation details, code examples, problem fixes, and service load‑balancing strategies for both client and provider sides.
Background and Goal
A data‑centric department maintains several sub‑projects (A, B, C, D) written in different languages and deployed as independent services (SOA). To centralise configuration management, a Nacos‑based configuration centre is introduced with the goals of timeliness, deep reach, and zero intrusion.
Plugin Repository
GitHub: https://github.com/workbunny/webman-nacos
Configuration Listening Requirements
Timeliness
Depth of reach
Zero intrusion
YAML files replace traditional .env files and are stored in Nacos namespaces. The config() helper loads a PHP file, which calls yaml() to read the corresponding YAML file.
config() -> /config/X.php -> yaml() -> /x.yamlInitial Implementation (Version 1)
The first version uses a Timer together with Guzzle asynchronous requests and Nacos long‑polling to monitor multiple YAML files. A single worker process creates a periodic timer whose interval matches the Nacos long‑poll timeout.
public function onWorkerStart(Worker $worker) {
$worker->count = 1;
if ($this->configListeners) {
// Pull config files
foreach ($this->configListeners as $listener) {
list($dataId, $group, $tenant, $configPath) = $listener;
if (!file_exists($configPath)) {
$this->_get($dataId, $group, $tenant, $configPath);
}
}
// Create periodic listener
Timer::add($this->longPullingInterval, function () {
$promises = [];
foreach ($this->configListeners as $listener) {
list($dataId, $group, $tenant, $configPath) = $listener;
if (file_exists($configPath)) {
$promises[] = $this->client->config->listenerAsync(
$dataId,
$group,
md5(file_get_contents($configPath)),
$tenant,
$this->longPullingInterval * 1000
)->then(function (ResponseInterface $response) use ($dataId, $group, $tenant, $configPath) {
if ($response->getStatusCode() === 200 && $response->getBody()->getContents() !== '') {
$this->_get($dataId, $group, $tenant, $configPath);
}
}, function (GuzzleException $exception) {
Log::channel('error')->error($exception->getMessage(), $exception->getTrace());
});
}
}
if ($promises) {
Utils::settle($promises)->wait();
}
});
}
}Problems Identified in Version 1
The config() function does not refresh already loaded values, so in‑memory resources such as database connections remain stale.
The Timer + Guzzle combination blocks the timer cycle; Guzzle only provides concurrency for multiple HTTP requests, not for the timer itself.
The first timer execution is delayed, causing the initial configuration to be outdated at startup.
Fixes Implemented
Problem 1 – Refresh Configuration
After fetching a new configuration file, the worker process is reloaded so that subsequent calls to config() see the updated values.
protected function _get(string $dataId, string $group, string $tenant, string $path) {
$res = $this->client->config->get($dataId, $group, $tenant);
if (file_put_contents($path, $res, LOCK_EX)) {
reload($path);
}
}
protected function reload(string $file) {
Worker::log($file . ' update and reload.');
if (extension_loaded('posix') && extension_loaded('pcntl')) {
posix_kill(posix_getppid(), SIGUSR1);
} else {
Worker::reloadAllWorkers();
}
}Problem 2 – Non‑Blocking HTTP Calls
Switched to Workerman/http-client, which runs on Workerman’s event loop, allowing truly asynchronous HTTP requests without blocking the timer.
public function onWorkerStart(Worker $worker) {
$worker->count = 1;
if ($this->configListeners) {
foreach ($this->configListeners as $listener) {
list($dataId, $group, $tenant, $configPath) = $listener;
if (!file_exists($configPath)) {
$this->_get($dataId, $group, $tenant, $configPath);
}
$this->timers[$dataId] = Timer::add($this->longPullingInterval, function () use ($dataId, $group, $tenant, $configPath) {
$this->client->config->listenerAsyncUseEventLoop([
'dataId' => $dataId,
'group' => $group,
'contentMD5' => md5(file_get_contents($configPath)),
'tenant' => $tenant
], function (Response $response) use ($dataId, $group, $tenant, $configPath) {
if ($response->getStatusCode() === 200 && (string)$response->getBody() !== '') {
$this->_get($dataId, $group, $tenant, $configPath);
}
}, function (\Exception $exception) {
Log::channel('error')->error($exception->getMessage(), $exception->getTrace());
});
});
}
}
}Problem 3 – Immediate Timer Execution
A lightweight wrapper around Workerman\Timer was created to support immediate execution, delayed single execution, delayed looping, and immediate looping.
<?php
declare(strict_types=1);
namespace Workbunny\WebmanNacos;
use Workerman\Timer as WorkermanTimer;
final class Timer {
protected static array $_timers = [];
public static function add(float $delay, float $repeat, callable $callback, ...$args) {
switch (true) {
case ($delay === 0.0 && $repeat !== 0.0):
$callback(...$args);
return WorkermanTimer::add($repeat, $callback, $args);
case ($delay !== 0.0 && $repeat === 0.0):
return WorkermanTimer::add($delay, $callback, $args, false);
case ($delay !== 0.0 && $repeat !== 0.0 && $repeat === $delay):
return WorkermanTimer::add($delay, $callback, $args);
case ($delay !== 0.0 && $repeat !== 0.0 && $repeat !== $delay):
return $id = WorkermanTimer::add($delay, function (...$args) use (&$id, $repeat, $callback) {
$callback(...$args);
self::$_timers[$id] = WorkermanTimer::add($repeat, $callback, $args);
}, $args, false);
default:
$callback(...$args);
return 0;
}
}
public static function del(int $id): void {
if ($id !== 0 && isset(self::$_timers[$id]) && is_int($timerId = self::$_timers[$id])) {
unset(self::$_timers[$id]);
WorkermanTimer::del($timerId);
}
}
public static function delAll(): void {
self::$_timers = [];
WorkermanTimer::delAll();
}
}Service Load‑Balancing Discussion
The Nacos client does not provide built‑in load‑balancing. Load‑balancing strategies depend on the service architecture and can be implemented either on the provider side or the consumer side. Adding many strategies to a simple client would bloat it.
Reference Solution
Caller Side (Aa)
1. Create a singleton Nacos client per worker process and keep a long‑living connection.
2. Attach a timer to each client to periodically check the health of the connected B instance.
3. If health degrades, switch to another healthy instance based on health status, weight, or custom metadata.
Long‑living connections reduce HTTP overhead, and health checks are performed without blocking the event loop.
Provider Side (B)
1. Each B instance reports its health and custom metadata (e.g., request count, connection count) to Nacos at regular intervals.
Consumers can use this metadata to make informed routing decisions, or the provider can expose health information directly.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Open Source Tech Hub
Sharing cutting-edge internet technologies and practical AI resources.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
