Skip to content

Commit e7727f2

Browse files
authored
File lock support (#40)
* PHP file lock support
1 parent e6e2ee6 commit e7727f2

File tree

8 files changed

+635
-28
lines changed

8 files changed

+635
-28
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ tests/RedisTest.php
1010
composer.lock
1111
tests/LocalTest.php
1212
/.phpunit.result.cache
13+
.php-cs-fixer.cache

.locks/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore

.php_cs

Lines changed: 0 additions & 27 deletions
This file was deleted.

README-zh_CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Snowflake 是 Twitter 内部的一个 ID 生算法,可以通过一些简单的
4848
* RedisSequenceResolver (基于 redis psetex 和 incrby 生成)
4949
* LaravelSequenceResolver(基于 redis psetex 和 incrby 生成)
5050
* SwooleSequenceResolver(基于 swoole_lock 锁)
51+
* FileLockResolver(基于 PHP 文件锁)
5152

5253
> **Warning**
5354
> RandomSequenceResolver 序列号提供者在高并发情况下可能会导致生成的 ID 重复,如果你的应用场景中可能会出现高并发的情况,建议使用 RedisSequenceResolver 或者 LaravelSequenceResolver。

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Based on this, we created this package and integrated multiple sequence-number p
4747
* RedisSequenceResolver (based on redis psetex and incrby)
4848
* LaravelSequenceResolver (based on redis psetex and incrby)
4949
* SwooleSequenceResolver (based on swoole_lock)
50+
* FileLockResolver(PHP file lock `fopen/flock`
5051

5152
Each provider only needs to ensure that the serial number generated in the same millisecond is different. You can get a unique ID.
5253

pint.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
{
2-
2+
"rules": {
3+
"header_comment": {
4+
"header": "This file is part of the godruoyi/php-snowflake.\n \n(c) Godruoyi <[email protected]> \n \nThis source file is subject to the MIT license that is bundled."
5+
}
6+
}
37
}

src/FileLockResolver.php

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the godruoyi/php-snowflake.
5+
*
6+
* (c) Godruoyi <[email protected]>
7+
*
8+
* This source file is subject to the MIT license that is bundled.
9+
*/
10+
11+
namespace Godruoyi\Snowflake;
12+
13+
use Exception;
14+
15+
class FileLockResolver implements SequenceResolver
16+
{
17+
/**
18+
* We should always use exclusive lock to avoid the problem of concurrent access.
19+
*/
20+
public const FlockLockOperation = LOCK_EX;
21+
22+
public const FileOpenMode = 'r+';
23+
24+
/**
25+
* For each lock file, we save 6,000 items, It can contain data generated within 10 minutes,
26+
* we believe is sufficient for the snowflake algorithm.
27+
*
28+
* 10m = 600s = 6000 ms
29+
*
30+
* @var int
31+
*/
32+
public static $maxItems = 6000;
33+
34+
/**
35+
* @var int
36+
*/
37+
public static $shardCount = 32;
38+
39+
/**
40+
* @var string
41+
*/
42+
protected $lockFileDir;
43+
44+
/**
45+
* @param string|null $lockFileDir
46+
*
47+
* @throws Exception
48+
*/
49+
public function __construct(string $lockFileDir = null)
50+
{
51+
$this->lockFileDir = $this->preparePath($lockFileDir);
52+
}
53+
54+
/**
55+
* {@inheritDoc}
56+
*
57+
* @throws Exception when can not open lock file
58+
*/
59+
public function sequence(int $currentTime)
60+
{
61+
$filePath = $this->createShardLockFile($this->getShardLockIndex($currentTime));
62+
63+
return $this->getSequence($filePath, $currentTime);
64+
}
65+
66+
/**
67+
* Get next sequence. move lock/unlock in the same method to avoid lock file not release, this
68+
* will be more friendly to test.
69+
*
70+
* @param string $filePath
71+
* @param int $currentTime
72+
* @return int
73+
*
74+
* @throws Exception
75+
*/
76+
protected function getSequence(string $filePath, int $currentTime): int
77+
{
78+
$f = null;
79+
80+
if (! file_exists($filePath)) {
81+
throw new Exception(sprintf('the lock file %s not exists', $filePath));
82+
}
83+
84+
try {
85+
$f = fopen($filePath, static::FileOpenMode);
86+
87+
// we always use exclusive lock to avoid the problem of concurrent access.
88+
// so we don't need to check the return value of flock.
89+
flock($f, static::FlockLockOperation);
90+
} catch (\Throwable $e) {
91+
$this->unlock($f);
92+
93+
throw new Exception(sprintf('can not open/lock this file %s', $filePath), $e->getCode(), $e);
94+
}
95+
96+
// We may get this error if the file contains invalid json, when you get this error,
97+
// may you can try to delete the invalid lock file directly.
98+
if (is_null($contents = $this->getContents($f))) {
99+
$this->unlock($f);
100+
101+
throw new Exception(sprintf('file %s is not a valid lock file.', $filePath));
102+
}
103+
104+
$this->updateContents($contents = $this->incrementSequenceWithSpecifyTime(
105+
$this->cleanOldSequences($contents), $currentTime
106+
), $f);
107+
108+
$this->unlock($f);
109+
110+
return $contents[$currentTime];
111+
}
112+
113+
/**
114+
* Unlock and close file.
115+
*
116+
* @param $f
117+
* @return void
118+
*/
119+
protected function unlock($f)
120+
{
121+
if (is_resource($f)) {
122+
flock($f, LOCK_UN);
123+
fclose($f);
124+
}
125+
}
126+
127+
/**
128+
* @param array $contents
129+
* @param $f
130+
* @return bool
131+
*/
132+
public function updateContents(array $contents, $f): bool
133+
{
134+
return ftruncate($f, 0) && rewind($f)
135+
&& (fwrite($f, serialize($contents)) !== false);
136+
}
137+
138+
/**
139+
* Increment sequence with specify time. if current time is not set in the lock file
140+
* set it to 1, otherwise increment it.
141+
*
142+
* @param array $contents
143+
* @param int $currentTime
144+
* @return array
145+
*/
146+
public function incrementSequenceWithSpecifyTime(array $contents, int $currentTime): array
147+
{
148+
$contents[$currentTime] = isset($contents[$currentTime]) ? $contents[$currentTime] + 1 : 1;
149+
150+
return $contents;
151+
}
152+
153+
/**
154+
* Clean the old content, we only save the data generated within 10 minutes.
155+
*
156+
* @param array $contents
157+
* @return array
158+
*/
159+
public function cleanOldSequences(array $contents): array
160+
{
161+
ksort($contents); // sort by timestamp
162+
163+
if (count($contents) > static::$maxItems) {
164+
$contents = array_slice($contents, -static::$maxItems, null, true);
165+
}
166+
167+
return $contents;
168+
}
169+
170+
/**
171+
* Remove all lock files, we only delete the file that name is match the pattern.
172+
*
173+
* @return void
174+
*/
175+
public function cleanAllLocksFile()
176+
{
177+
$files = glob($this->lockFileDir.'/*');
178+
179+
foreach ($files as $file) {
180+
if (is_file($file) && preg_match('/snowflake-(\d+)\.lock$/', $file)) {
181+
unlink($file);
182+
}
183+
}
184+
}
185+
186+
/**
187+
* Get resource contents, If the contents are invalid json, return null.
188+
*
189+
* @param $f resource
190+
* @return array|null
191+
*/
192+
public function getContents($f): ?array
193+
{
194+
$content = '';
195+
196+
while (! feof($f)) {
197+
$content .= fread($f, 1024);
198+
}
199+
200+
$content = trim($content);
201+
202+
if (empty($content)) {
203+
return [];
204+
}
205+
206+
try {
207+
if (is_array($data = unserialize($content))) {
208+
return $data;
209+
}
210+
} catch (\Throwable $e) {
211+
}
212+
213+
return null;
214+
}
215+
216+
/**
217+
* @see https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
218+
*
219+
* @param string $str
220+
* @return float
221+
*/
222+
public function fnv(string $str): float
223+
{
224+
$hash = 2166136261;
225+
226+
for ($i = 0; $i < strlen($str); $i++) {
227+
$hash ^= ord($str[$i]);
228+
$hash *= 16777619;
229+
}
230+
231+
return $hash;
232+
}
233+
234+
/**
235+
* Shard lock file index.
236+
*
237+
* @param int $currentTime
238+
* @return int
239+
*/
240+
public function getShardLockIndex(int $currentTime): int
241+
{
242+
return $this->fnv($currentTime) % self::$shardCount;
243+
}
244+
245+
/**
246+
* Check path is exists and writable.
247+
*
248+
* @throws Exception
249+
*/
250+
protected function preparePath(?string $lockFileDir): string
251+
{
252+
if (empty($lockFileDir)) {
253+
$lockFileDir = dirname(__DIR__).'/.locks/';
254+
}
255+
256+
if (! is_dir($lockFileDir)) {
257+
throw new Exception("{$lockFileDir} is not a directory.");
258+
}
259+
260+
if (! is_writable($lockFileDir)) {
261+
throw new Exception("{$lockFileDir} is not writable.");
262+
}
263+
264+
return $lockFileDir;
265+
}
266+
267+
/**
268+
* Generate shard lock file.
269+
*
270+
* @param int $index
271+
* @return string
272+
*/
273+
protected function createShardLockFile(int $index): string
274+
{
275+
$path = $this->filePath($index);
276+
277+
if (file_exists($path)) {
278+
return $path;
279+
}
280+
281+
touch($path);
282+
283+
return $path;
284+
}
285+
286+
/**
287+
* Format lock file path with shard index.
288+
*
289+
* @param int $index
290+
* @return string
291+
*/
292+
protected function filePath(int $index): string
293+
{
294+
return sprintf('%s%ssnowflake-%s.lock', rtrim($this->lockFileDir, DIRECTORY_SEPARATOR), DIRECTORY_SEPARATOR, $index);
295+
}
296+
}

0 commit comments

Comments
 (0)