migears / data-structure
Redis data structure service: explicit Hash/List/Set/ZSet semantics with an interface for redis-compatible servers
Requires
- php: ^8.1
- psr/log: ^3.0
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.0
Suggests
- ext-redis: Required to use RedisDataStructure implementation
Provides
None
Conflicts
None
Replaces
None
README
A standalone Redis data structure service: explicit Hash / List / Set / Sorted Set (ZSet) semantics plus a distributed lock, targeting Redis-compatible servers (Redis, Valkey, KeyDB).
Sister package of migears/cache: migears/cache stays a pure PSR-16 key-value store, while this package exposes the data-structure layer. Both operate on the same Redis connection but keep separate interfaces and abstractions.
Background: miGears is the open-source successor of TinyGears, a self-developed PHP framework. It was renamed and open-sourced recently because the name TinyGears is already taken in the open-source community.
Features
- PHP 8.1+, PSR-4 autoloading, namespace
MiGears\DataStructure DataStructureInterface— a contract of 30 operations across Hash (8), List (5), Set (5), ZSet (9) and key-level TTL (3)- Scalar-only value semantics (
int|float|string) — no transparent serialization, each structure defines its own value types RedisDataStructure— phpredis implementation withwithPrefix()key namespacingRedisLock— distributed lock viaSET NX EXwith safe owner-token release (Lua compare-and-delete)- Required PSR-3 logger injection; all errors wrapped in
DataStructureException - Separate unit and integration (real Redis) test suites
Boundaries
In scope
- The
DataStructureInterfacecontract of 30 operations — Hash (8), List (5), Set (5), ZSet (9) and key-level TTL (3) — and itsRedisDataStructureimplementation, against a Redis-compatible server (Redis, Valkey, KeyDB). - Scalar-only value semantics (
int|float|string) with no transparent serialization; ZSet scores are normalized tofloaton read. - A distributed lock (
RedisLock) built onSET NX EXwith safe owner-token release (Lua compare-and-delete). - Key namespacing via
withPrefix(), required PSR-3 logger injection, and all errors wrapped inDataStructureException.
Not in scope (by design)
- Establishing or managing the Redis connection — these classes never connect on their own; the connection and its life cycle stay with the caller.
- Transparent serialization of arrays/objects — hand anything beyond scalars to the PSR-16 key-value store
migears/cache, its sister package on the same connection. - Acting as a general-purpose Redis client — no raw-command passthrough, nothing beyond the documented Hash / List / Set / ZSet / TTL operations.
- Blocking or waiting to acquire the lock —
lock()is a single non-blockingSET NX EXreturningbool; no wait/retry loop, no re-entrancy, no TTL auto-renewal.
Installation
composer require migears/data-structure
The Redis implementations require the
redisextension:pecl install redis
Handling the Redis Connection
The classes never connect to Redis themselves. They only wrap a connection you provide, so the connection — and its life-cycle — stays with the caller:
use MiGears\DataStructure\RedisDataStructure; $redis = new Redis(); $redis->connect('127.0.0.1', 6379); // The logger is required and must be supplied by the caller. $ds = new RedisDataStructure($redis, $logger);
In a miGears web environment, inject the connection in MiRest and obtain it through the service registry:
$rest->set(Redis::class, function () { $redis = new Redis(); $redis->connect('127.0.0.1', 6379); return $redis; }); // in a resource: $ds = new RedisDataStructure($this->resolve(Redis::class), $logger); $lock = new RedisLock($this->resolve(Redis::class), $logger);
Value Semantics
Values are scalars only (int|float|string). There is no transparent serialization of arrays or objects:
- Hash values —
int|float|string - List elements —
string - Set members —
string - ZSet — scores are
int|float, normalized tofloaton read
Store anything more complex through the base PSR-16 cache (migears/cache) instead.
Quick Start
use MiGears\DataStructure\RedisDataStructure; $ds = new RedisDataStructure($redis, $logger); /* Hash */ $ds->hashSet('user:1', 'name', 'Alice'); $ds->hashGet('user:1', 'name'); // 'Alice' $ds->hashIncrBy('user:1', 'points', 10); // 10 (atomic) /* List — FIFO queue (push to tail, pop from head) */ $ds->listPush('jobs', 'reindex', 'notify'); // 2 (length) $ds->listPop('jobs'); // 'reindex' $ds->listLen('jobs'); // 1 /* Set */ $ds->setAdd('tags:new', ['php', 'redis']); $ds->setIsMember('tags:new', 'php'); // true $ds->setMembers('tags:new'); // ['php', 'redis'] /* Sorted Set (ZSet) — leaderboard */ $ds->zAdd('board', 100, 'alice'); $ds->zAdd('board', 90, 'bob'); $ds->zRange('board'); // ['bob' => 90.0, 'alice' => 100.0] (asc) $ds->zSelect('board'); // ['alice' => 100.0, 'bob' => 90.0] (desc) $ds->zIncrBy('board', 'alice', 5); // 105.0 /* TTL */ $ds->expire('board', 3600); $ds->ttl('board'); // ~3600 $ds->persist('board'); // remove expiry
Distributed lock
use MiGears\DataStructure\RedisLock; $lock = new RedisLock($redis, $logger); if ($lock->lock('job:report', 30)) { // 30s TTL, acquire returns bool try { // critical section } finally { $lock->unlock('job:report'); // released only if we still own it } }
unlock() is safe: it releases only when the key still holds this instance's owner token (Lua compare-and-delete). A lock that expired and was re-acquired is never deleted by a stale holder.
Key prefix — namespacing
Share one Redis instance across apps/modules without key collisions:
$tenantCache = $ds->withPrefix('tenant:42:'); $tenantCache->hashSet('config', 'theme', 'dark'); // stores "tenant:42:config"
API Reference
Hash
| Method | Returns | Description |
|---|---|---|
hashSet($key, $field, $value) |
bool |
Set the value of a field |
hashGet($key, $field) |
int|float|string|null |
Get a field's value (null when missing) |
hashMultiGet($key, $fields) |
array |
Get several fields, existing ones only |
hashGetAll($key) |
array |
Get all fields of the hash |
hashDel($key, $field|fields) |
int |
Delete one or more fields |
hashExists($key, $field) |
bool |
Check whether a field exists |
hashLen($key) |
int |
Number of fields |
hashIncrBy($key, $field, $increment = 1) |
int |
Atomic increment of a numeric field |
List
| Method | Returns | Description |
|---|---|---|
listPush($key, ...$values) |
int |
Push value(s) to the tail (rPush), returns new length |
listPop($key) |
string|null |
Pop from the head (lPop), null when empty |
listRange($key, $start = 0, $end = -1) |
array |
Range of elements (supports negative indexes) |
listLen($key) |
int |
Length of the list |
listIndex($key, $index) |
string|null |
Element at a given index (negates count from tail), null if out of range |
Set
| Method | Returns | Description |
|---|---|---|
setAdd($key, $member|members) |
int |
Add one or more members, returns count added |
setRemove($key, $member|members) |
int |
Remove one or more members, returns count removed |
setMembers($key) |
array |
All members |
setSize($key) |
int |
Number of members |
setIsMember($key, $member) |
bool |
Check membership |
Sorted Set (ZSet)
| Method | Returns | Description |
|---|---|---|
zAdd($key, $score, $member) |
int |
Add a member with a score |
zRemove($key, $member|members) |
int |
Remove one or more members |
zSize($key) |
int |
Number of members |
zScore($key, $member) |
int|float|null |
Score of a member, null when missing |
zRange($key, $start = 0, $end = -1) |
array |
Members ordered by score ascending (by index range) |
zSelect($key, $min = 0, $max = 9999999999, $limit = 0, $order = 'DESC') |
array |
Members filtered by score bounds and sorted; $limit 0 = unlimited |
zBatchAdd($key, $set) |
bool |
Batch add from a flat [score, member, score, member, ...] array |
zInterStore($destKey, $keys, $aggregate = 'MIN') |
int |
Intersection of multiple ZSets into $destKey |
zIncrBy($key, $member, $increment = 1) |
int|float |
Atomic score increment |
$min/$maxare inclusive score bounds.zSelectreturns scores asfloat, keys ordered by score (defaultDESC), and$orderis case-insensitive —'ASC'and'DESC'only: anything else raisesDataStructureExceptioninstead of being read asDESC.The default bounds form a bounded window (
0to9999999999), so members scored below0or above that ceiling are not returned; pass explicit bounds to widen it.zBatchAddanswersfalseonly for an empty list, i.e. nothing to do: an odd number of entries, or a command that fails inside the pipeline, raisesDataStructureExceptioninstead of being flattened into the samefalse. It uses a pipeline rather than a transaction, so a failing pair leaves the pairs before it applied.
Key level (TTL)
| Method | Returns | Description |
|---|---|---|
ttl($key) |
int |
Remaining TTL in seconds; -1 = no expiry, -2 = key missing |
expire($key, $ttl) |
bool |
Set a TTL in seconds; a non-positive value deletes the key |
persist($key) |
bool |
Remove the expiry |
Error Handling & Logging
Every operation is wrapped in try/catch: underlying exceptions are logged through the injected PSR-3 logger and re-thrown as a MiGears\DataStructure\Exception\DataStructureException. The logger is required — the class substitutes no NullLogger of its own, so a call site that forgets it fails at assembly time:
use MiGears\DataStructure\RedisDataStructure; use Monolog\Logger; use Monolog\Handler\StreamHandler; $logger = new Logger('ds'); $logger->pushHandler(new StreamHandler('ds.log')); $ds = new RedisDataStructure($redis, $logger);
When the client reports a failure without throwing — which phpredis does by
answering false — methods that return a count or a score raise
DataStructureException rather than letting an impossible false reach the
return type, or quietly turning into 0 behind a cast. Methods that answer a
yes/no question (hashExists, setIsMember, expire, persist) pass the
client's false through, because there it means "no" rather than "failure".
Testing
composer test:unit # logic tests via mocked Redis — no server needed composer test:integration # real Redis required
The integration suite auto-resolves a connection (first match wins) and starts a throwaway container if none is present:
REDIS_DSN— e.g.redis://:pass@host:6379/15or barehost:6379REDIS_HOST/REDIS_PORT/REDIS_DB/REDIS_AUTHdockerorpodman— auto-spawn aredis:7-alpinecontainer
If none is available, integration tests are skipped.
The resolved database is flushed. Every integration test method calls
flushDB(), so point these variables at a dedicated test database only. A database that is named is used exactly as given, including db 0 (redis://host:6379/0orREDIS_DB=0); when no database is named the suite falls back to db 15.
License
MIT
migears/data-structure
一个独立的 Redis 数据结构服务:显式的 Hash / List / Set / 有序集合(ZSet) 语义,外加一个分布式锁,面向 Redis 兼容服务器(Redis、Valkey、KeyDB)。
它是 migears/cache 的姊妹包:migears/cache 保持纯粹的 PSR-16 键值缓存,本包则提供数据结构层。二者共用同一条 Redis 连接,但保持独立的接口与抽象。
背景:miGears 源自自研 PHP 框架 TinyGears,因 TinyGears 这一名字 已被开源社区占用,故近期更名并开源发布。
特性
- PHP 8.1+,PSR-4 自动加载,命名空间
MiGears\DataStructure DataStructureInterface—— 涵盖 Hash(8)、List(5)、Set(5)、ZSet(9)与 Key 级 TTL(3)共 30 个操作的契约- 仅标量取值语义(
int|float|string)——不做透明序列化,每种结构自行定义取值类型 RedisDataStructure—— phpredis 实现,支持withPrefix()键命名空间RedisLock—— 基于SET NX EX的分布式锁,支持安全的属主令牌释放(Lua 比较后删除)- 必需的 PSR-3 日志注入;所有错误统一包装为
DataStructureException - 独立的单元测试与集成(真实 Redis)测试套件
边界
范围内
DataStructureInterface契约的 30 个操作 —— Hash(8)、List(5)、Set(5)、ZSet(9)与 Key 级 TTL(3)—— 及其实现RedisDataStructure;面向 Redis 兼容服务器(Redis、Valkey、KeyDB)。- 仅标量取值语义(
int|float|string),不做透明序列化;ZSet 分数读取时归一化为float。 - 基于
SET NX EX的分布式锁RedisLock,支持安全的属主令牌释放(Lua 比较后删除)。 - 通过
withPrefix()做键命名空间,必需的 PSR-3 日志注入,所有错误统一包装为DataStructureException。
范围外(刻意不做)
- 建立或管理 Redis 连接 —— 这些类自身不连接 Redis;连接及其生命周期由调用方掌控。
- 对数组或对象做透明序列化 —— 标量以外的数据交给姊妹包 PSR-16 键值缓存
migears/cache(二者共用同一条连接)。 - 充当通用 Redis 客户端 —— 不做原始命令透传,不提供文档所列 Hash / List / Set / ZSet / TTL 操作之外的接口。
- 阻塞式或等待式获取锁 ——
lock()是一次非阻塞的SET NX EX,返回bool;没有等待/重试循环、没有可重入语义、也不自动续期 TTL。
安装
composer require migears/data-structure
Redis 实现需要
redis扩展:pecl install redis
连接的建立
这些类自身不会连接 Redis。它们只包装你传入的连接,连接及其生命周期由调用方掌控:
use MiGears\DataStructure\RedisDataStructure; $redis = new Redis(); $redis->connect('127.0.0.1', 6379); // 日志器为必填,必须由调用方传入。 $ds = new RedisDataStructure($redis, $logger);
在 miGears 的 web 环境中,把连接注入 MiRest,再经服务注册中心取得:
$rest->set(Redis::class, function () { $redis = new Redis(); $redis->connect('127.0.0.1', 6379); return $redis; }); // 在资源类中: $ds = new RedisDataStructure($this->resolve(Redis::class), $logger); $lock = new RedisLock($this->resolve(Redis::class), $logger);
取值语义
值为仅标量(int|float|string),不对数组或对象做透明序列化:
- Hash 值 ——
int|float|string - List 元素 ——
string - Set 成员 ——
string - ZSet —— 分数为
int|float,读取时统一归一化为float
如需存储更复杂的结构,请改用基础 PSR-16 缓存(migears/cache)。
快速开始
use MiGears\DataStructure\RedisDataStructure; $ds = new RedisDataStructure($redis, $logger); /* Hash */ $ds->hashSet('user:1', 'name', 'Alice'); $ds->hashGet('user:1', 'name'); // 'Alice' $ds->hashIncrBy('user:1', 'points', 10); // 10(原子) /* List —— FIFO 队列(尾部入队,头部出队) */ $ds->listPush('jobs', 'reindex', 'notify'); // 2(长度) $ds->listPop('jobs'); // 'reindex' $ds->listLen('jobs'); // 1 /* Set */ $ds->setAdd('tags:new', ['php', 'redis']); $ds->setIsMember('tags:new', 'php'); // true $ds->setMembers('tags:new'); // ['php', 'redis'] /* 有序集合(ZSet)——排行榜 */ $ds->zAdd('board', 100, 'alice'); $ds->zAdd('board', 90, 'bob'); $ds->zRange('board'); // ['bob' => 90.0, 'alice' => 100.0](升序) $ds->zSelect('board'); // ['alice' => 100.0, 'bob' => 90.0](降序) $ds->zIncrBy('board', 'alice', 5); // 105.0 /* TTL */ $ds->expire('board', 3600); $ds->ttl('board'); // 约 3600 $ds->persist('board'); // 移除过期时间
分布式锁
use MiGears\DataStructure\RedisLock; $lock = new RedisLock($redis, $logger); if ($lock->lock('job:report', 30)) { // 30s TTL,获取返回 bool try { // 临界区 } finally { $lock->unlock('job:report'); // 仅当仍持有该锁时才会被释放 } }
unlock() 是安全的:只有当 key 仍持有本实例的属主令牌时才会释放(Lua 比较后删除)。已过期并被重新获取的锁,绝不会被陈旧的持有者误删。
键前缀——命名空间
多应用/模块共用一条 Redis 时避免键冲突:
$tenantCache = $ds->withPrefix('tenant:42:'); $tenantCache->hashSet('config', 'theme', 'dark'); // 实际存储 "tenant:42:config"
API 参考
Hash
| 方法 | 返回 | 说明 |
|---|---|---|
hashSet($key, $field, $value) |
bool |
设置某个字段的值 |
hashGet($key, $field) |
int|float|string|null |
读取某个字段的值(不存在返回 null) |
hashMultiGet($key, $fields) |
array |
批量读取字段,仅返回已存在的 |
hashGetAll($key) |
array |
读取哈希的全部字段 |
hashDel($key, $field|fields) |
int |
删除一个或多个字段 |
hashExists($key, $field) |
bool |
字段是否存在 |
hashLen($key) |
int |
字段数量 |
hashIncrBy($key, $field, $increment = 1) |
int |
数值字段原子自增 |
List
| 方法 | 返回 | 说明 |
|---|---|---|
listPush($key, ...$values) |
int |
向尾部入队(rPush),返回新长度 |
listPop($key) |
string|null |
从头部出队(lPop),空时返回 null |
listRange($key, $start = 0, $end = -1) |
array |
元素区间(支持负索引) |
listLen($key) |
int |
列表长度 |
listIndex($key, $index) |
string|null |
指定索引处的元素(负值从尾部计数),越界返回 null |
Set
| 方法 | 返回 | 说明 |
|---|---|---|
setAdd($key, $member|members) |
int |
添加一个或多个成员,返回新增数量 |
setRemove($key, $member|members) |
int |
删除一个或多个成员,返回删除数量 |
setMembers($key) |
array |
全部成员 |
setSize($key) |
int |
成员数量 |
setIsMember($key, $member) |
bool |
是否包含某成员 |
有序集合(ZSet)
| 方法 | 返回 | 说明 |
|---|---|---|
zAdd($key, $score, $member) |
int |
添加一个带分数的成员 |
zRemove($key, $member|members) |
int |
删除一个或多个成员 |
zSize($key) |
int |
成员数量 |
zScore($key, $member) |
int|float|null |
某成员的分数,不存在返回 null |
zRange($key, $start = 0, $end = -1) |
array |
按分数升序返回索引区间内的成员 |
zSelect($key, $min = 0, $max = 9999999999, $limit = 0, $order = 'DESC') |
array |
按分数区间筛选并排序;$limit 为 0 表示不限条数 |
zBatchAdd($key, $set) |
bool |
批量添加,$set 为扁平数组 [score, member, score, member, ...] |
zInterStore($destKey, $keys, $aggregate = 'MIN') |
int |
多个 ZSet 求交集写入 $destKey |
zIncrBy($key, $member, $increment = 1) |
int|float |
分数原子自增 |
$min/$max为闭区间分数边界。zSelect返回的分数均为float,默认按分数降序,$order不区分大小写——只接受'ASC'与'DESC';其他取值会抛出DataStructureException,而不是被当作DESC。默认边界构成一个有界窗口(
0到9999999999):分数低于0或高于该上界的成员不会被返回,需要更宽的区间请显式传参。zBatchAdd仅在传入空数组(无事可做)时返回false:元素个数为奇数、或 pipeline 内某条命令失败,都会抛出DataStructureException,而不是被压成同一个false。它使用 pipeline 而非事务,因此某一对失败时前面已生效的成员会保留。
Key 级(TTL)
| 方法 | 返回 | 说明 |
|---|---|---|
ttl($key) |
int |
剩余 TTL(秒);-1 = 无过期,-2 = key 不存在 |
expire($key, $ttl) |
bool |
设置以秒为单位的过期时间;非正值会删除该键 |
persist($key) |
bool |
移除过期时间 |
错误处理与日志
每个操作都用 try/catch 包裹:底层异常会通过注入的 PSR-3 日志器记录,然后以 MiGears\DataStructure\Exception\DataStructureException 重新抛出。日志器为必填——类自身不会用 NullLogger 兜底,忘记传入会在装配期失败:
use MiGears\DataStructure\RedisDataStructure; use Monolog\Logger; use Monolog\Handler\StreamHandler; $logger = new Logger('ds'); $logger->pushHandler(new StreamHandler('ds.log')); $ds = new RedisDataStructure($redis, $logger);
当客户端不抛异常、而以 false 表示失败时(phpredis 的做法):返回计数或分数的方法会抛出 DataStructureException,而不是让一个不可能的 false 落到返回类型上,也不再经由强制转换悄悄变成 0。返回是非判断的方法(hashExists、setIsMember、expire、persist)会原样透传客户端的 false,因为在那里它表示「否」而不是「失败」。
测试
composer test:unit # 通过 mock Redis 跑逻辑测试——无需服务器 composer test:integration # 需要真实 Redis
集成套件会自动解析连接(按顺序取首个可用),若无可用连接则会自动拉起一个临时容器:
REDIS_DSN—— 例如redis://:pass@host:6379/15或裸host:6379REDIS_HOST/REDIS_PORT/REDIS_DB/REDIS_AUTHdocker或podman—— 自动拉起redis:7-alpine容器
若均不可用,集成测试会被跳过。
被解析出的库会被清空。 每个集成测试方法都会调用
flushDB(),因此请只把这些变量指向专用的测试库。显式指定的库会按原值使用,包括 db 0(redis://host:6379/0或REDIS_DB=0);未指定库时回退到 db 15。
License
MIT