From 55b04f0eabdfc563cbeae8874e0ef8d686135097 Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Fri, 11 Oct 2024 00:22:46 +0500 Subject: [PATCH] Add DTOs, exceptions, and jobs for translation service. Introduced new Data Transfer Objects (DTOs), exceptions, and jobs to enhance the translation service functionality. Updated namespaces for consistency and added rate limiting to the translation provider. Expanded the README with detailed usage instructions. --- .gitignore | 1 + README.md | 95 ++++++++++++++- composer.json | 8 +- config/translate.php | 7 +- src/Contracts/ProcessTranslateContract.php | 10 ++ src/Contracts/Translate.php | 4 +- .../TranslationCompletedListener.php | 8 ++ src/DTO/Languages.php | 2 +- src/DTO/Parameter.php | 41 +++++++ src/DTO/ProcessTranslateDto.php | 59 +++++++++ src/DTO/ProcessTranslateLimit.php | 27 ++++ src/DTO/RunTranslateDto.php | 65 ++++++++++ src/DTO/Translated.php | 20 +++ src/Enums/TextType.php | 17 +++ src/Exceptions/AfterTranslateException.php | 10 ++ src/Exceptions/RunTranslateDtoException.php | 10 ++ src/Exceptions/TranslateException.php | 4 +- src/Facades/Translate.php | 12 +- src/Jobs/AfterTranslate.php | 53 ++++++++ src/Jobs/ProcessTranslate.php | 86 +++++++++++++ src/Jobs/RunTranslate.php | 62 ++++++++++ src/Translate/SplitTextIntoPartsCommand.php | 115 ++++++++++++++++++ src/Translate/TranslateManager.php | 30 ++++- src/Translate/Yandex/Connection.php | 14 +-- src/Translate/YandexDriver.php | 10 +- src/TranslateLaravelProvider.php | 17 ++- 26 files changed, 751 insertions(+), 36 deletions(-) create mode 100644 .gitignore create mode 100644 src/Contracts/ProcessTranslateContract.php create mode 100644 src/Contracts/TranslationCompletedListener.php create mode 100644 src/DTO/Parameter.php create mode 100644 src/DTO/ProcessTranslateDto.php create mode 100644 src/DTO/ProcessTranslateLimit.php create mode 100644 src/DTO/RunTranslateDto.php create mode 100644 src/DTO/Translated.php create mode 100644 src/Enums/TextType.php create mode 100644 src/Exceptions/AfterTranslateException.php create mode 100644 src/Exceptions/RunTranslateDtoException.php create mode 100644 src/Jobs/AfterTranslate.php create mode 100644 src/Jobs/ProcessTranslate.php create mode 100644 src/Jobs/RunTranslate.php create mode 100644 src/Translate/SplitTextIntoPartsCommand.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61ead86 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/README.md b/README.md index d690baa..ce17fd1 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,99 @@ Библиотека для Laravel, что бы общаться с сервисом перевода от yandex. В будущем (скорей всего в далёком будущем) планирую расширить поддержку разных сервисов перевода. Что бы можно было общаться через один интерфейс с разными сервисами. -Параметры в **.env**: +# Параметры в **.env**: -# TRANSLATE_SERVICE +### TRANSLATE_SERVICE По умолчанию через какой сервис обращаться за переводом. На данный момент доступен только один сервис - **yandex**. -# TRANSLATE_YANDEX_FOLDER_ID +### TRANSLATE_YANDEX_FOLDER_ID ID folder. Код можно увидеть в адресе console.yandex.cloud/folders/**{тут будет код}**. -# TRANSLATE_YANDEX_AUTHORIZED_KEY_PATH +### TRANSLATE_YANDEX_AUTHORIZED_KEY_PATH Абсолютный путь в рамках приложения Laravel к файлу **authorized_key.json**. -Например: положили файл в корень Laravel. Тогда будет путь такой: **/authorized_key.json**. \ No newline at end of file +Например: положили файл в корень Laravel. Тогда будет путь такой: **/authorized_key.json**. + +### TRANSLATE_YANDEX_LIMIT_MAX_REQUEST +Максимальное количество запросов в период, который указан в TRANSLATE_YANDEX_LIMIT_RATE_SECONDS. По умолчанию 20 запросов в секунду. **На данный момент работает только перевод через систему очередей.** + +### TRANSLATE_YANDEX_LIMIT_RATE_SECONDS +Период в котором считается максимальное количество запросов. По умолчанию стоит секунда. **На данный момент работает только перевод через систему очередей.** + +### TRANSLATE_YANDEX_LIMIT_MAX_SYMBOLS +Максимальное количество символов за один запрос. Если превышает, то делится на две части и делает по очереди два запроса. По умолчанию стоит 9000 символов. **На данный момент работает только перевод через систему очередей.** + +# Методы +### Перевести обычный текст +> \KorElf\TranslateLaravel\Facades\Translate::translateText( +>
      string | array \$text, +>
      string            \$targetLanguageCode, +>
      ?string          \$sourceLanguageCode = null +>
): string | array + +**\$text** - текст который нужно перевести. +
**\$targetLanguageCode** - язык на который надо перевести. +
**\$sourceLanguageCode** - язык с которого надо перевести. Если **null**, то сервис сам пытается определить язык. + +
+ +### Перевести текст в формате HTML +> \KorElf\TranslateLaravel\Facades\Translate::translateHtml( +>
      string | array \$text, +>
      string            \$targetLanguageCode, +>
      ?string          \$sourceLanguageCode = null +>
): string | array + +**\$text** - текст который нужно перевести. +
**\$targetLanguageCode** - язык на который надо перевести. +
**\$sourceLanguageCode** - язык с которого надо перевести. Если **null**, то сервис сам пытается определить язык. + +
+ +### Получить список языков, который сервис поддерживает +> \KorElf\TranslateLaravel\Facades\Translate::listLanguages(): \KorElf\TranslateLaravel\DTO\Languages + +
+ +### Перевести с помощью очередей (Queues) +> \KorElf\TranslateLaravel\Facades\Translate::runJob( +>
      \KorElf\TranslateLaravel\DTO\RunTranslateDto                             \$params, +>
      \KorElf\TranslateLaravel\Contracts\TranslationCompletedListener \$completedListener +
): \Illuminate\Foundation\Bus\PendingDispatch + +**\$params** - параметры перевода. +
**\$completedListener** - после завершения перевода отправляет результат в этот объект. Объект должен соблюдать контракт **\KorElf\TranslateLaravel\Contracts\TranslationCompletedListener**. + +**Пример:** +> Создаём файл TranslationListener например в папке app/Services +> +> +> namespace App\Services; +> +> use Illuminate\Support\Facades\Log; +> use KorElf\TranslateLaravel\Contracts\TranslationCompletedListener; +> +> final class TranslationListener implements TranslationCompletedListener +> { +> public function onTranslationCompleted(array $translatedText): void +> { +> foreach ($translatedText as $translatedTextKey => $translatedTextValue) { +> Log::info($translatedTextKey . ': ' . $translatedTextValue); +> } +> } +> } +> +> Потом например в каком-то контроллере (а лучше конечно в сервисе) пишем такой метод +> +> public function sendingForTranslation(\App\Services\TranslationListener $translationListener): View +> { +> // Вначале создаём объект с параметрами +> $params = (new \KorElf\TranslateLaravel\DTO\RunTranslateDto) +> ->addParamText('title', 'Заголовок', 'en', 'ru') +> ->addParamHtml('content', '

Привет, Мир!

', 'en', 'ru'); +> +> // Отправляем на очередь +> \KorElf\TranslateLaravel\Facades\Translate::runJob($params, $translationListener); +> +> return view('success'); +> } diff --git a/composer.json b/composer.json index 292d5fe..23ac26f 100644 --- a/composer.json +++ b/composer.json @@ -21,18 +21,20 @@ "php": "^8.2", "illuminate/support": "^10.0|^11.0", "guzzlehttp/guzzle": "^7.0.1", - "web-token/jwt-framework": "^3.0" + "web-token/jwt-framework": "^3.0", + "ext-libxml": "^2.11.7", + "ext-dom": "^2.11.7" }, "extra": { "laravel": { "providers": [ - "korElf\\TranslateLaravel\\TranslateLaravelProvider" + "KorElf\\TranslateLaravel\\TranslateLaravelProvider" ] } }, "autoload": { "psr-4": { - "korElf\\TranslateLaravel\\": "src/" + "KorElf\\TranslateLaravel\\": "src/" } } } diff --git a/config/translate.php b/config/translate.php index 35f7c4f..641347b 100644 --- a/config/translate.php +++ b/config/translate.php @@ -24,10 +24,15 @@ return [ 'services' => [ 'yandex' => [ - 'driver' => '\korElf\TranslateLaravel\Translate\YandexDriver', + 'driver' => '\KorElf\TranslateLaravel\Translate\YandexDriver', 'config' => [ 'folder_id' => env('TRANSLATE_YANDEX_FOLDER_ID'), 'authorized_key_path' => base_path(env('TRANSLATE_YANDEX_AUTHORIZED_KEY_PATH')), + 'limit' => [ + 'max_request' => (int) env('TRANSLATE_YANDEX_LIMIT_MAX_REQUEST', 20), + 'rate_seconds' => (int) env('TRANSLATE_YANDEX_LIMIT_RATE_SECONDS', 1), + 'max_symbols' => (int) env('TRANSLATE_YANDEX_LIMIT_MAX_SYMBOLS', 9000), + ], ], ], ], diff --git a/src/Contracts/ProcessTranslateContract.php b/src/Contracts/ProcessTranslateContract.php new file mode 100644 index 0000000..c7bce08 --- /dev/null +++ b/src/Contracts/ProcessTranslateContract.php @@ -0,0 +1,10 @@ +text; + } + + public function getTextType(): TextType + { + return $this->textType; + } + + public function getTargetLanguageCode(): string + { + return $this->targetLanguageCode; + } + + public function getSourceLanguageCode(): ?string + { + return $this->sourceLanguageCode; + } + + public function getDriver(): ?string + { + return $this->driver; + } +} \ No newline at end of file diff --git a/src/DTO/ProcessTranslateDto.php b/src/DTO/ProcessTranslateDto.php new file mode 100644 index 0000000..811b2da --- /dev/null +++ b/src/DTO/ProcessTranslateDto.php @@ -0,0 +1,59 @@ +groupName; + } + + public function getKey(): string + { + return $this->key; + } + + public function getPart(): int + { + return $this->part; + } + + public function getText(): string + { + return $this->text; + } + + public function getTextType(): TextType + { + return $this->textType; + } + + public function getTargetLanguageCode(): string + { + return $this->targetLanguageCode; + } + + public function getSourceLanguageCode(): ?string + { + return $this->sourceLanguageCode; + } + + public function getDriver(): ?string + { + return $this->driver; + } +} \ No newline at end of file diff --git a/src/DTO/ProcessTranslateLimit.php b/src/DTO/ProcessTranslateLimit.php new file mode 100644 index 0000000..4e4ffbf --- /dev/null +++ b/src/DTO/ProcessTranslateLimit.php @@ -0,0 +1,27 @@ +maxRequest; + } + + public function getRateSeconds(): int + { + return $this->rateSeconds; + } + + public function getDriver(): string + { + return $this->driver; + } +} \ No newline at end of file diff --git a/src/DTO/RunTranslateDto.php b/src/DTO/RunTranslateDto.php new file mode 100644 index 0000000..eaa2030 --- /dev/null +++ b/src/DTO/RunTranslateDto.php @@ -0,0 +1,65 @@ + \KorElf\TranslateLaravel\DTO\Parameter] + */ + private array $params = []; + + public function addParam( + string $key, + string $text, + TextType $textType, + string $targetLanguageCode, + ?string $sourceLanguageCode = null, + ?string $driver = null + ): self + { + if (isset($this->params[$key])) { + throw new RunTranslateDtoException('Duplicate key: ' . $key); + } + + $this->params[$key] = new Parameter( + $text, + $textType, + $targetLanguageCode, + $sourceLanguageCode, + $driver, + ); + + return $this; + } + + public function addParamHtml( + string $key, + string $text, + string $targetLanguageCode, + ?string $sourceLanguageCode = null, + ?string $driver = null + ): self + { + return $this->addParam($key, $text, TextType::Html, $targetLanguageCode, $sourceLanguageCode, $driver); + } + + public function addParamText( + string $key, + string $text, + string $targetLanguageCode, + ?string $sourceLanguageCode = null, + ?string $driver = null + ): self + { + return $this->addParam($key, $text, TextType::Text, $targetLanguageCode, $sourceLanguageCode, $driver); + } + + public function getParams(): array + { + return $this->params; + } +} \ No newline at end of file diff --git a/src/DTO/Translated.php b/src/DTO/Translated.php new file mode 100644 index 0000000..3bcf5e3 --- /dev/null +++ b/src/DTO/Translated.php @@ -0,0 +1,20 @@ +data[$key] = $parts; + + return $this; + } + + public function toArray(): array + { + return $this->data; + } +} \ No newline at end of file diff --git a/src/Enums/TextType.php b/src/Enums/TextType.php new file mode 100644 index 0000000..b0750f1 --- /dev/null +++ b/src/Enums/TextType.php @@ -0,0 +1,17 @@ + 'translateText', + self::Html => 'translateHtml', + }; + } +} diff --git a/src/Exceptions/AfterTranslateException.php b/src/Exceptions/AfterTranslateException.php new file mode 100644 index 0000000..3c13907 --- /dev/null +++ b/src/Exceptions/AfterTranslateException.php @@ -0,0 +1,10 @@ +groupName, []); + + $errors = []; + foreach ($this->translated->toArray() as $key => $parts) { + if (!isset($data[$key]) || count($data[$key]) !== $parts) { + $errors[] = $key; + continue; + } + + $translated[$key] = implode(' ', $data[$key]); + } + + if (empty($data)) { + throw new AfterTranslateException('Part or all of the text has not been translated. Keys: ' . implode(', ', $errors)); + } + Cache::forget($this->groupName); + + $this->listener->onTranslationCompleted($translated); + } +} \ No newline at end of file diff --git a/src/Jobs/ProcessTranslate.php b/src/Jobs/ProcessTranslate.php new file mode 100644 index 0000000..d605868 --- /dev/null +++ b/src/Jobs/ProcessTranslate.php @@ -0,0 +1,86 @@ + + */ + public function middleware(): array + { + return [ + new RateLimited('kor-elf-translate'), + ]; + } + + public function getLimited(): ProcessTranslateLimit + { + $driver = $this->param->getDriver(); + if ($driver === null) { + $driver = Translate::getDefaultDriver(); + } + $limit = Translate::getLimit($driver); + + return new ProcessTranslateLimit( + maxRequest: $limit['max_request'] ?? 1000000000, + rateSeconds: $limit['rate_seconds'] ?? 1, + driver: $driver, + ); + } + + /** + * Execute the job. + */ + public function handle(): void + { + $param = $this->param; + $groupName = $param->getGroupName(); + + $translated = Cache::get($groupName, []); + if (!isset($translated[$param->getKey()])) { + $translated[$param->getKey()] = []; + } + + $translate = Translate::service($param->getDriver()); + $function = $param->getTextType()->functionName(); + $key = $param->getKey(); + $part = $param->getPart(); + + $translated[$key][$part] = $param->getText(); + if (\trim($param->getText()) !== '') { + + $translated[$key][$part] = $translate->{$function}( + $param->getText(), + $param->getTargetLanguageCode(), + $param->getSourceLanguageCode() + ); + + } + + Cache::put($groupName, $translated, 86400); + } +} \ No newline at end of file diff --git a/src/Jobs/RunTranslate.php b/src/Jobs/RunTranslate.php new file mode 100644 index 0000000..935a89c --- /dev/null +++ b/src/Jobs/RunTranslate.php @@ -0,0 +1,62 @@ +toString(); + $translated = new Translated(); + foreach ($this->runTranslateDto->getParams() as $key => $param) { + /* @var \KorElf\TranslateLaravel\DTO\Parameter $param */ + + $texts = $splitTextIntoPartsCommand->execute($param->getText(), $param->getTextType(), $param->getDriver()); + foreach ($texts as $part => $partText) { + $processTranslateDto = new ProcessTranslateDto( + $groupName, + $key, + $part, + $partText, + $param->getTextType(), + $param->getTargetLanguageCode(), + $param->getSourceLanguageCode(), + $param->getDriver(), + ); + $chains[] = new ProcessTranslate($processTranslateDto); + } + $translated->add($key, count($texts)); + } + $chains[] = new AfterTranslate($groupName, $this->translationCompletedListener, $translated); + + Bus::chain($chains)->dispatch(); + } +} \ No newline at end of file diff --git a/src/Translate/SplitTextIntoPartsCommand.php b/src/Translate/SplitTextIntoPartsCommand.php new file mode 100644 index 0000000..ce985c4 --- /dev/null +++ b/src/Translate/SplitTextIntoPartsCommand.php @@ -0,0 +1,115 @@ + mb_strlen($text)) { + return [$text]; + } + + if ($textType === TextType::Html) { + return $this->splitHtmlText($text, $maxLength); + } + + return $this->splitTextBySentences($text, $maxLength); + } + + private function splitTextBySentences(string $text, int $maxLength): array + { + // Common expression for searching for sentences. + $sentenceEndings = '/(?<=[.?!])\s+(?=[A-ZА-Я])/u'; + + //Dividing the text into sentences + $sentences = preg_split($sentenceEndings, $text, -1, PREG_SPLIT_NO_EMPTY); + + $parts = []; + $currentPart = ''; + + foreach ($sentences as $sentence) { + // If adding a sentence does not exceed the limit, add it to the current part. + if (mb_strlen($currentPart . ' ' . $sentence) <= $maxLength) { + $currentPart .= (empty($currentPart) ? '' : ' ') . $sentence; + continue; + } + + // Otherwise, save the current part and start a new one. + if (!empty($currentPart)) { + $parts[] = $currentPart; + $currentPart = ''; + } + $currentPart = $sentence; + } + + if (!empty($currentPart)) { + $parts[] = $currentPart; + } + + return $parts; + } + + private function splitHtmlText(string $html, int $maxLength): array + { + libxml_use_internal_errors(true); + + $dom = new DOMDocument(); + $dom->loadHTML(mb_convert_encoding('' . $html . '', 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + + $parts = []; + $currentPart = new DOMDocument(); + $currentLength = 0; + + // Iterate through all child nodes of the root element + foreach ($dom->documentElement->childNodes as $node) { + if ($node->nodeType === XML_TEXT_NODE || $node->nodeType === XML_CDATA_SECTION_NODE) { + // Dividing text nodes into sentences + $sentences = preg_split('/(?<=[.?!])\s+(?=[A-ZА-Я])/', $node->textContent, -1, PREG_SPLIT_NO_EMPTY); + + foreach ($sentences as $sentence) { + $sentenceHtml = htmlspecialchars($sentence); + if ($currentLength + mb_strlen($sentenceHtml) > $maxLength) { + $parts[] = $currentPart->saveHTML(); + + // Start new part + $currentPart = new DOMDocument(); + $currentLength = 0; + } + + $textNode = $currentPart->createTextNode($sentence . ' '); + $currentPart->appendChild($textNode); + $currentLength += mb_strlen($sentenceHtml); + } + continue; + } + + $nodeHtml = $dom->saveHTML($node); + + if ($currentLength + mb_strlen($nodeHtml) > $maxLength) { + $parts[] = $currentPart->saveHTML(); + + // Start new part + $currentPart = new DOMDocument(); + $currentLength = mb_strlen($nodeHtml); + } + + $currentPart->appendChild($currentPart->importNode($node, true)); + $currentLength += mb_strlen($nodeHtml); + } + + if ($currentPart->hasChildNodes()) { + $parts[] = $currentPart->saveHTML(); + } + + libxml_clear_errors(); + + return $parts; + } +} \ No newline at end of file diff --git a/src/Translate/TranslateManager.php b/src/Translate/TranslateManager.php index d7214cd..2a56ef3 100644 --- a/src/Translate/TranslateManager.php +++ b/src/Translate/TranslateManager.php @@ -1,10 +1,15 @@ getConfig($name); if (\is_null($config)) { - throw new InvalidArgumentException("Translate service [{$name}] is not defined."); + throw new InvalidArgumentException('Translate service [' . $name . '] is not defined.'); } return $config['driver']::init($this->app, $config['config']); @@ -60,6 +65,25 @@ final class TranslateManager unset($this->translates[$name]); } + /** + * Run through queues. + */ + public function runJob(RunTranslateDto $params, TranslationCompletedListener $completedListener): PendingDispatch + { + return RunTranslate::dispatch($params, $completedListener); + } + + public function getLimit(?string $driver = null): array + { + if ($driver === null) { + $driver = $this->getDefaultDriver(); + } + + $config = $this->getConfig($driver); + + return $config['config']['limit'] ?? []; + } + /** * Dynamically call the default driver instance. */ diff --git a/src/Translate/Yandex/Connection.php b/src/Translate/Yandex/Connection.php index 8c4f73c..7dd43f6 100644 --- a/src/Translate/Yandex/Connection.php +++ b/src/Translate/Yandex/Connection.php @@ -1,6 +1,6 @@ publishes([ __DIR__ . '/../config/translate.php' => config_path('translate.php'), ], 'config'); + + RateLimiter::for('kor-elf-translate', function (ProcessTranslateContract $job) { + $limited = $job->getLimited(); + + return Limit::perSecond( + $limited->getMaxRequest(), + $limited->getRateSeconds() + )->by($limited->getDriver()); + }); } /** @@ -26,7 +38,6 @@ final class TranslateLaravelProvider extends ServiceProvider __DIR__ . '/../config/translate.php', 'translate' ); - $this->app->singleton('translate', function (Application $app) { return new TranslateManager($app); }); -- 2.45.2