實作基於 Webhook 的「翻譯管理系統」(三):客戶端 Laravel 套件

前言

此套件的目的是封裝 Lexicon 客戶端 PHP 套件,使客戶端可以接收來自服務端的通知,也可以透過 Laravel 的 Artisan 指令獲取資源,並且生成語系檔。

服務提供者

LexiconServiceProvider 服務提供者中,定義了可以被發布的資源,以及需要綁定的類別。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
namespace MemoChou1993\Lexicon\Providers;

use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use MemoChou1993\Lexicon\Client;
use MemoChou1993\Lexicon\Console\ClearCommand;
use MemoChou1993\Lexicon\Console\SyncCommand;
use MemoChou1993\Lexicon\Lexicon;

class LexiconServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
{
$this->mergeConfigFrom(
__DIR__.'/../../config/lexicon.php',
'lexicon'
);

$this->app->singleton(Client::class, function() {
return new Client([
'host' => config('lexicon.host'),
'api_key' => config('lexicon.api_key'),
]);
});

$this->app->singleton('lexicon', function() {
return new Lexicon(app(Client::class));
});

$this->app->register(EventServiceProvider::class);
}

/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
if (! defined('CONFIG_SEPARATOR')) {
define('CONFIG_SEPARATOR', '.');
}

$this->publishes([
__DIR__.'/../../config/lexicon.php' => config_path('lexicon.php'),
]);

if ($this->app->runningInConsole()) {
$this->commands([
SyncCommand::class,
ClearCommand::class,
]);
}

Route::group([
'prefix' => '/api/'.config('lexicon.path'),
'middleware' => config('lexicon.middleware', []),
], function () {
$this->loadRoutesFrom(__DIR__.'/../Http/routes.php');
});
}
}

EventServiceProvider 服務提供者中,定義了註冊的事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
namespace MemoChou1993\Lexicon\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use MemoChou1993\Lexicon\Listeners\Sync;

class EventServiceProvider extends ServiceProvider
{
/**
* The event listener mappings for the application.
*
* @var array
*/
protected $listen = [
'sync' => [
Sync::class,
],
];

/**
* Register any events for your application.
*
* @return void
*/
public function boot()
{
parent::boot();
}
}

核心

Lexicon 類別中,定義了最重要的兩個方法,分別是 export()clear() 方法。export() 方法將獲取的資料整理成特定格式,並輸出成 Laravel 能夠使用的 PHP 語系檔,而 clear() 方法則是將舊有的 Lexicon 語系檔刪除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
namespace MemoChou1993\Lexicon;

use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Symfony\Component\VarExporter\Exception\ExceptionInterface;
use Symfony\Component\VarExporter\VarExporter;

class Lexicon
{
/**
* @var Client
*/
private Client $client;

/**
* @var array
*/
protected array $project;

/**
* @var Collection|null
*/
protected ?Collection $expectedLanguages = null;

/**
* @param Client $client
*/
public function __construct(Client $client)
{
$this->client = $client;
}

/**
* @return string
*/
protected function filename(): string
{
return config('lexicon.filename');
}

/**
* @param array $project
* @return void
*/
protected function setProject($project): void
{
$this->project = $project;
}

/**
* @return array
*/
protected function getProject(): array
{
return $this->project ?? $this->fetchProject();
}

/**
* @return Collection
*/
protected function getKeys(): Collection
{
return collect($this->getProject()['keys']);
}

/**
* @return Collection
*/
public function getLanguages(): Collection
{
return collect($this->getProject()['languages'])->pluck('name');
}

/**
* @return Collection
*/
protected function getExpectedLanguages(): Collection
{
return collect($this->expectedLanguages)->whenEmpty(function () {
return $this->getLanguages();
});
}

/**
* @param mixed $language
* @return bool
*/
public function hasLanguage($language): bool
{
return $this->getLanguages()->contains($language);
}

/**
* @return array|void
*/
protected function fetchProject()
{
try {
$response = $this->client->fetchProject();

$data = json_decode($response->getBody()->getContents(), true)['data'];

$this->setProject($data);

return $data;
} catch (GuzzleException $e) {
abort($e->getCode(), $e->getMessage());
}
}

/**
* @param string $language
* @return array
*/
protected function formatKeys(string $language): array
{
return $this->getKeys()
->mapWithKeys(function ($key) use ($language) {
return [
$key['name'] => $this->formatValues($key['values'], $language),
];
})
->toArray();
}

/**
* @param array $values
* @param string $language
* @return string
*/
protected function formatValues(array $values, string $language): string
{
return collect($values)
->filter(function ($value) use ($language) {
return $value['language']['name'] === $language;
})
->map(function ($value) {
return vsprintf('[%s,%s]%s', [
$value['form']['range_min'],
$value['form']['range_max'],
$value['text'],
]);
})
->implode('|');
}

/**
* @param array|string $languages
* @return self
*/
public function only(...$languages): self
{
$this->expectedLanguages = collect($languages)
->flatten()
->intersect($this->getLanguages());

return $this;
}

/**
* @param array|string $languages
* @return self
*/
public function except(...$languages): self
{
$this->expectedLanguages = $this->getLanguages()
->diff(collect($languages)->flatten());

return $this;
}

/**
* @return void
*/
public function export(): void
{
$this->getExpectedLanguages()
->each(function ($language) {
$this->save($language);
});
}

/**
* @param string $language
* @return void
* @throws ExceptionInterface
*/
protected function save(string $language): void
{
$keys = $this->formatKeys($language);

$data = vsprintf('%s%s%s%s%s%s%s', [
'<?php',
PHP_EOL,
PHP_EOL,
'return ',
VarExporter::export($keys),
';',
PHP_EOL,
]);

$directory = lang_path($language);

File::ensureDirectoryExists($directory);

$path = sprintf('%s/%s.php', $directory, $this->filename());

File::put($path, $data);
}

/**
* @return self
*/
public function clear(): self
{
$directories = File::directories(lang_path());

collect($directories)
->filter(function ($directory) {
return $this->hasLanguage(basename($directory));
})
->each(function ($directory) {
$path = sprintf('%s/%s.php', $directory, $this->filename());

File::delete($path);

return $directory;
})
->reject(function ($directory) {
return count(File::allFiles($directory)) > 0;
})
->each(function ($directory) {
File::deleteDirectory($directory);
});

return $this;
}

/**
* @param string|null $key
* @param int $number
* @param array $replace
* @param null $locale
* @return string
*/
public function trans($key = null, $number = 0, array $replace = [], $locale = null): string
{
if (is_null($key)) {
return '';
}

$key = $this->filename().CONFIG_SEPARATOR.$key;

return trans_choice($key, $number, $replace, $locale);
}
}

控制器

DispatchController 控制器中,負責接收來自服務端的請求,並且派發所有事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
namespace MemoChou1993\Lexicon\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Event;
use Symfony\Component\HttpFoundation\Response;

class DispatchController extends Controller
{
/**
* The events for the application.
*
* @var array
*/
protected array $events = [
'sync',
];

/**
* Receive and dispatch events.
*
* @param Request $request
* @return JsonResponse
*/
public function __invoke(Request $request)
{
collect($request->input('events'))
->intersect($this->events)
->each(fn($event) => Event::dispatch($event));

return response()->json(null, Response::HTTP_ACCEPTED);
}
}

事件

Sync.php 檔中,定義了一個同步事件,此同步事件會將原先舊的語系檔刪除,再重新輸出一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
namespace MemoChou1993\Lexicon\Listeners;

use MemoChou1993\Lexicon\Facades\Lexicon;

class Sync
{
/**
* Create the event listener.
*
* @return void
*/
public function __construct()
{
//
}

/**
* Handle the event.
*
* @return void
*/
public function handle()
{
Lexicon::clear()->export();
}
}

指令

SyncCommand 類別中,使用了 Lexicon::export() 方法,用來生成語系檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
namespace MemoChou1993\Lexicon\Console;

use Illuminate\Console\Command;
use MemoChou1993\Lexicon\Facades\Lexicon;
use Symfony\Component\HttpKernel\Exception\HttpException;

class SyncCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'lexicon:sync';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Sync language files';

/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}

/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
try {
Lexicon::export();
} catch (HttpException $e) {
$this->error($e->getMessage());

return 0;
}

return 1;
}
}

ClearCommand 類別中,使用了 Lexicon::clear() 方法,用來清除語系檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
namespace MemoChou1993\Lexicon\Console;

use Illuminate\Console\Command;
use MemoChou1993\Lexicon\Facades\Lexicon;
use Symfony\Component\HttpKernel\Exception\HttpException;

class ClearCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'lexicon:clear';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear language files';

/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}

/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
try {
Lexicon::clear();
} catch (HttpException $e) {
$this->error($e->getMessage());

return 0;
}

return 1;
}
}

使用

安裝套件。

1
composer require memochou1993/lexicon-api-laravel-client

修改 .env 檔,設置 Lexicon 服務端的網址,以及向服務端存取資源的 API 金鑰。

1
2
LEXICON_HOST=
LEXICON_API_KEY=

如果要獲取服務端的語系資源,並生成本地的語系檔,執行以下指令。

1
php artisan lexicon:sync

如果要清除本地的語系檔,執行以下指令。

1
php artisan lexicon:clear

程式碼