Русский
Русский
English
Статистика
Реклама

Lazy load

Ленивая подгрузка переводов с Angular

22.07.2020 12:20:51 | Автор: admin

image


Если вы когда-нибудь участвовали в разработке крупного angular-проекта с поддержкой локализации, то эта статья для вас. Если же нет, то возможно, вам будет интересно, как мы решили проблему скачивания больших файлов с переводами при старте приложения: в нашем случае ~2300 строк и ~200 Кб для каждого языка.


Немного контекста


Всем привет! Я Frontend-разработчик компании ISPsystem в команде VMmanager.


Итак, мы имеем крупный frontend-проект. Под капотом angular 9-й версии на момент написания статьи. Поддержка локализации осуществляется библиотекой ngx-translate. Сами переводы в проекте лежат в json-файлах. Для взаимодействия с переводчиками используется сервис POEditor.


Что не так с большими переводами?


Во-первых, необходимость скачивать большой json-файл при первом входе в приложение.
Да, этот файл можно закэшировать, но примерно каждые 2 недели выходит обновление продукта и после каждого обновления переводы должны скачиваться заново.
К тому же в проекте используется система разделения доступа, а это значит, что пользователь с минимальным уровнем доступа увидит только малую часть всех переводов (сложно сказать конкретно, но разница в несколько, а может и в десятки раз), а все остальное будет для него обузой.


Во-вторых, навигация в огромном json-файле просто неудобна.
Конечно, мы не пишем код в блокноте. Но все равно поиск определенного ключа в определенном namespace становится непростой задачей. Например, надо найти TITLE, который лежит внутри HOME(HOME.....TITLE), при условии что в файле есть еще сотня TITLE, а объект внутри HOME тоже содержит сотню ключей.


Что делать с этими проблемами?


Как можно догадаться из названия статьи: распилить переводы на кусочки, разложить по разным файлам и скачивать по мере их необходимости.
Эта концепция хорошо ложится на модульную архитектуру angular. Мы будем указывать в angular-модуле, какой кусок переводов нам для него нужен.


Еще нам может понадобиться один и тот же кусок переводов в разных частях (модулях) приложения, для этого можно его положить в отдельный файл. Конечно, можно положить этот кусок в главный файл, откуда он будет доступен во всем приложении, но вы не забыли, чем мы тут занимаемся? этот кусок опять же может быть частью исключительно администраторского интерфейса и менее привилегированным пользователям никогда не понадобится.


А еще часть переводов, которую можно положить отдельно, может быть частью других отдельных переводов (для более мелкого дробления на части).


На основании перечисленных хотелок получается примерно такая структура файлов:


<projectRoot>/i18n/  ru.json  en.json  HOME/    ru.json    en.json  HOME.COMMON/    ru.json    en.json  ADMIN/    ru.json    en.json

Тут файлы json в корне это основные файлы, они будут скачиваться всегда (нужный, в зависимости от выбранного языка). Файлы в HOME переводы необходимые только обычному пользователю. ADMIN файлы необходимые только администратору. HOME.COMMON файлы необходимые и пользователю, и администратору.


Каждый json-файл внутри должен иметь структуру, соответствующую его namespace:


  • корневые файлы просто содержат {...};
  • файлы внутри ADMIN содержат { "ADMIN": {...} };
  • файлы внутри HOME.COMMON содержат { "HOME": { "COMMON": {...} } } ;
  • и т.д.

Пока что это можно воспринимать как мою причуду, далее это будет обоснованно.


Будем отталкиваться именно от этой структуры. У вас она может быть немного другой, от этого поменяются некоторые части из представленного далее кода.


ngx-translate из коробки этого всего не умеет, но предоставляет достаточный функционал, чтобы это можно было реализовать своими силами:


  • возможность обработать отсутствующий перевод используем это, чтобы докачать необходимые переводы налету;
  • возможность реализовать свой загрузчик файлов переводов а это чтобы скачивать сразу несколько файлов переводов, необходимых текущему модулю.

Реализация


Скачиватель переводов: TranslateLoader


Чтобы сделать свой скачиватель переводов, необходимо создать класс реализующий один метод abstract getTranslation(lang: string): Observable<any>. Для семантики можно унаследовать его от абстрактного класса TranslateLoader (импортируется из ngx-translate), который мы далее будем использовать для провайдинга.


Так как наш класс будет не просто скачивать переводы, но и как-то должен их объединять в один объект, кода будет чуть больше, чем один метод:


export class MyTranslationLoader extends TranslateLoader implements OnDestroy {  /** Глобальный кэш с флагами скачанных файлов переводов (чтобы не качать их повторно, для разных модулей) */  private static TRANSLATES_LOADED: { [lang: string]: { [scope: string]: boolean } } = {};  /** Сортируем ключи по возрастанию длины (маленькие куски будут вмердживаться в большие) */  private sortedScopes = typeof this.scopes === 'string' ? [this.scopes] : this.scopes.slice().sort((a, b) => a.length - b.length);  private getURL(lang: string scope: string): string {    // эта строка будет зависеть от того, куда и как вы кладете файлы переводов    // в нашем случае они лежат в корне проекта в директории i18n    return `i18n/${scope ? scope + '/' : ''}${lang}.json`;  }  /** Скачиваем переводы и запоминаем, что мы их скачали */  private loadScope(lang: string, scope: string): Observable<object> {    return this.httpClient.get(this.getURL(lang, scope)).pipe(      tap(() => {        if (!MyTranslationLoader.TRANSLATES_LOADED[lang]) {          MyTranslationLoader.TRANSLATES_LOADED[lang] = {};        }        MyTranslationLoader.TRANSLATES_LOADED[lang][scope] = true;      })    );  }  /**    * Все скачанные переводы необходимо объединить в один объект    * т.к. мы знаем, что файлы переводов не имеют пересечений по ключам,    * можно вместо сложной логики глубокого мерджа просто наложить объекты друг на друга,   * но надо делать это в правильном порядке, именно для этого мы выше отсортировали наши scope по длине,   * чтобы наложить HOME.COMMON на HOME, а не наоборот   */  private merge(scope: string, source: object, target: object): object {    // обрабатываем пустую строку для root модуля    if (!scope) {      return { ...target };    }    const parts = scope.split('.');    const scopeKey = parts.pop();    const result = { ...source };    // рекурсивно получаем ссылку на объект, в который необходимо добавить часть переводов    const sourceObj = parts.reduce(      (acc, key) => (acc[key] = typeof acc[key] === 'object' ? { ...acc[key] } : {}),      result    );        // также рекурсивно достаем нужную часть переводов и присваиваем    sourceObj[scopeKey] = parts.reduce((res, key) => res[key] || {}, target)?.[scopeKey] || {};    return result;  }  constructor(private httpClient: HttpClient, private scopes: string | string[]) {    super();  }  ngOnDestroy(): void {    // сбрасываем кэш, чтобы при hot reaload переводы перекачались    MyTranslationLoader.TRANSLATES_LOADED = {};  }  getTranslation(lang: string): Observable<object> {    // берем только еще не скачанные scope    const loadScopes = this.sortedScopes.filter(s => !MyTranslationLoader.TRANSLATES_LOADED?.[lang]?.[s]);    if (!loadScopes.length) {      return of({});    }    // скачиваем все и сливаем в один объект    return zip(...loadScopes.map(s => this.loadScope(lang, s))).pipe(      map(translates => translates.reduce((acc, t, i) => this.merge(loadScopes[i], acc, t), {}))    );  }}

Как можно заметить, scope здесь используется и как часть url для скачивания файла, и как ключ для доступа к необходимой части json, именно поэтому директория и структура в файле должны совпадать.


Как это использовать, описано чуть дальше.


Докачиватель переводов: MissingTranslationHandler


Чтобы реализовать эту логику, необходимо сделать класс, имеющий метод handle. Проще всего унаследовать класс от MissingTranslationHandler, который импортируется из ngx-translate.
Описание метода в репозитории ngx-translate выглядит так:


export declare abstract class MissingTranslationHandler {  /**   * A function that handles missing translations.   *   * @param params context for resolving a missing translation   * @returns a value or an observable   * If it returns a value, then this value is used.   * If it return an observable, the value returned by this observable will be used (except if the method was "instant").   * If it doesn't return then the key will be used as a value   */  abstract handle(params: MissingTranslationHandlerParams): any;}

Нас интересует как раз второй вариант развития событий: вернуть Observable на скачивание нужного куска переводов.


export class MyMissingTranslationHandler extends MissingTranslationHandler {  // кэшируем Observable с переводом, т.к. при входе на страницу, для которой еще нет переводов,  // каждая translate pipe вызовет метод handle  private translatesLoading: { [lang: string]: Observable<object> } = {};  handle(params: MissingTranslationHandlerParams) {    const service = params.translateService;    const lang = service.currentLang || service.defaultLang;    if (!this.translatesLoading[lang]) {      // вызываем загрузку переводов через loader (тот самый, который реализован выше)      this.translatesLoading[lang] = service.currentLoader.getTranslation(lang).pipe(        // добавляем переводы в общее хранилище ngx-translate        // флаг true говорит о том, что объекты необходимо смерджить        tap(t => service.setTranslation(lang, t, true)),        map(() => service.translations[lang]),        shareReplay(1),        take(1)      );    }    return this.translatesLoading[lang].pipe(      // вытаскиваем необходимый перевод по ключу и вставляем в него параметры      map(t => service.parser.interpolate(service.parser.getValue(t, params.key), params.interpolateParams)),      // при ошибке эмулируем стандартное поведение, когда нет перевода  возвращаем ключ      catchError(() => of(params.key))    );  }}

Мы в проекте всегда используем только строковые ключи (HOME.TITLE), но ngx-translate также поддерживает ключи в виде массива строк (['HOME', 'TITLE']). Если вы этим пользуетесь, то в обработке catchError необходимо добавить проверку вроде такой of(typeof params.key === 'string' ? params.key : params.key.join('.')).


Используем все вышеописанное


Чтобы использовать наши классы, необходимо указать их при импорте TranslateModule:


export function loaderFactory(scopes: string | string[]): (http: HttpClient) => TranslateLoader {  return (http: HttpClient) => new MyTranslationLoader(http, scopes);}// ...// app.module.tsTranslateModule.forRoot({  useDefaultLang: false,  loader: {    provide: TranslateLoader,    useFactory: loaderFactory(''),    deps: [HttpClient],  },})// home.module.tsTranslateModule.forChild({  useDefaultLang: false,  extend: true,  loader: {    provide: TranslateLoader,    useFactory: loaderFactory(['HOME', 'HOME.COMMON']),    deps: [HttpClient],  },  missingTranslationHandler: {    provide: MissingTranslationHandler,    useClass: MyMissingTranslationHandler,  },})// admin.module.tsTranslateModule.forChild({  useDefaultLang: false,  extend: true,  loader: {    provide: TranslateLoader,    useFactory: loaderFactory(['ADMIN', 'HOME.COMMON']),    deps: [HttpClient],  },  missingTranslationHandler: {/*...*/},})

Флаг useDefaultLang: false необходим для корректной работы missingTranslationHandler.
Флаг extend: true (добавлен в версии ngx-translate@12.0.0) необходим, чтобы дочерние модули работали с переводами главного модуля.


Также, чтобы избавиться от копирования подобных импортов для каждого модуля с отдельными переводами, можно сделать пару утилит:


export function translateConfig(scopes: string | string[]): TranslateModuleConfig {  return {    useDefaultLang: false,    loader: {      provide: TranslateLoader,      useFactory: httpLoaderFactory(scopes),      deps: [HttpClient],    },  };}@NgModule()export class MyTranslateModule {  static forRoot(scopes: string | string[] = [], config?: TranslateModuleConfig): ModuleWithProviders<TranslateModule> {    return TranslateModule.forRoot({      ...translateConfig([''].concat(scopes)),      ...config,    });  }  static forChild(scopes: string | string[], config?: TranslateModuleConfig): ModuleWithProviders<TranslateModule> {    return TranslateModule.forChild({      ...translateConfig(scopes),      extend: true,      missingTranslationHandler: {        provide: MissingTranslationHandler,        useClass: MyMissingTranslationHandler,      },      ...config,    });  }}

Такие импорты должны быть только в корневых модулях отдельных частей приложения, далее (чтобы использовать translate пайпу или директиву) надо просто импортировать TranslateModule.


В данный момент (на версии ngx-translate@12.1.2) можно заметить, что при переключении языка, пока происходит скачивание переводов, пайпа translate выводит [object Object]. Это ошибка внутри самой пайпы.


POEditor


Как я упоминал ранее, мы выгружаем наши переводы в сервис POEditor, чтобы там с ними мог работать переводчик. Для этого в сервисе есть соответствующее API:



Оба этих хэндлера работают с полными файлами переводов, но у нас все переводы лежат в разных файлах. Значит, перед отправкой надо все переводы склеить в один файл, а при скачивании разложить по файлам.


Эту логику мы реализовали в python3 скрипте.
В общих чертах он использует тот же принцип объединения переводов, что и в MyTranslateLoader. Разделение происходит по той же схеме, только из большого файла, мы вычитаем куски.


В скрипте реализовано несколько команд:


  • split принимает на вход файл и директорию, в которой у вас подготовлена структура для переводов, и раскладывает переводы согласно этой структуре (в нашем примере это директория i18n);
  • join делает обратное действие: принимает на вход путь до директории с переводами и кладет склеенный json либо в stdout, либо в указанный файл;
  • download скачивает переводы из POEditor, затем либо раскладывает их по файлам в переданной директории, либо кладет в один файл, переданный в аргументы;
  • upload соответственно загружает в POEditor переводы либо из переданной директории, либо из переданного файла;
  • hash считает md5 сумму всех переводов из переданной директории. Пригодится в том случае, если вы подмешиваете хеш в параметры для скачивания переводов, чтобы они не кэшировались в браузере при изменении.

Также там используется пакет argparse, который позволяет удобно работать с аргументами и генерирует --help команду.


Исходник скрипта в рамках статьи рассматривать не будем, потому что он достаточно большой, но вы можете найти его в репозитории по ссылке ниже.
Также в репозитории представлен демо проект, в котором реализовано все, что описано в статье. А также этот проект залит на платформу stackblitz, где можно все потыкать.


GitHub Репозиторий
Демо на Stackblitz


К чему мы пришли


Сейчас такой подход уже используется в VMmanager 6. Конечно, все наши переводы за один раз мы не стали разделять, потому что их достаточно много. Постепенно отделяем их от основного файла, а новый функционал стараемся реализовывать уже с разделением переводов.


На данный момент ленивую подгрузку переводов можно наблюдать в управлении пользователями панели, в настройках панели, а также в недавно реализованных резервных копиях.


А как вы решаете проблему больших файлов локализации? Или почему не стали этого делать?

Подробнее..

Перевод Решение проблемы N1 запроса без увеличения потребления памяти в Laravel

28.06.2020 00:17:28 | Автор: admin

Одна из основных проблем разработчиков, когда они создают приложение с ORM это N+1 запрос в их приложениях. Проблема N+1 запроса это не эффективный способ обращения к базе данных, когда приложение генерирует запрос на каждый вызов объекта. Эта проблема обычно возникает, когда мы получаем список данных из базы данных без использования ленивой или жадной загрузки (lazy load, eager load). К счастью, Laravel с его ORM Eloquent предоставляет инструменты, для удобной работы, но они имеют некоторые недостатки.
В этой статье рассмотрим проблему N+1, способы ее решения и оптимизации потребления памяти.


Давайте рассмотрим простой пример, как использовать eager loading в Laravel. Допустим, у нас есть простое веб-приложение, которое показывает список заголовков первых статей пользователей приложения. Тогда связь между нашими моделями может быть вроде такой:


Class User extends Authenticatable{    public function articles()    {        return $this->hasMany(Aricle::class, 'writter_id');    }}

и тогда простое действие получения данных из базы данных и передачи в шаблон может выглядеть таким образом:


Route::get('/test', function() {    $users = User::get();    return view('test', compact('users'));});

Простой шаблон test.blade.php для отображения списка пользователей с соответствующими заголовками их первой статьи:


@extends('layouts.app')@section('content')<ul>    @foreach($users as $user)        <li>Name: {{ $user->name}}</li>        <li>First Article: {{ $user->articles()->oldest()->first()->title }}</li>    @endforeach</ul>@endsection

И когда мы откроем нашу тестовую страницу в браузере, мы увидим нечто подобное:


image


Я использую debugbar (https://github.com/barryvdh/laravel-debugbar), чтобы показать, как выполняется наша тестовая страница. Для отображения этой страницы вызывается 11 запросов в БД. Один запрос для получения всей информации о пользователях и 10 запросов, чтобы показать заголовок их первой статьи. Видно, что 10 пользователей создают 10 запросов в базу данных к таблице статей. Это называется проблемой N+1 запроса.


Решение проблемы N+1 запроса с жадной загрузкой


Вам может показаться, что это не проблема производительности вашего приложения в целом. Но что, если мы хотим показать больше чем 10 элементов? И часто, нам также приходится иметь дело с более сложной логикой, состоящего из более чем одного N+1 запроса на странице. Это условие может привести к более чем 11 запросам или даже к экспоненциально растущему количеству запросов.


Итак, как мы это решаем? Есть один общий ответ на это:


Eager load


Eager load (жадная загрузка) это процесс, при котором запрос для одного типа объекта также загружает связанные объекты в рамках одного запроса к базе данных. В Laravel мы можем загружать данные связанных моделей используя метод with(). В нашем примере мы должны изменить код следующим образом:


Route::get('/test', function() {    $users = User::with('articles')->get();    return view('test', compact('users'));});

@extends('layouts.app')@section('content')<ul>    @foreach($users as $user)        <li>Name: {{ $user->name}}</li>        <li>First Article: {{ $user->articles()->sortBy('created_at')->first()->title }}</li>    @endforeach</ul>@endsection

И, наконец, уменьшить количество наших запросов до двух:


image


Также мы можем создать связь hasOne, с соответствующим запросом для получения первой статьи пользователя:


    public function first_article()    {        return $this->hasOne(Aricle::class, 'writter_id')->orderBy('created_at', 'asc');    }

Теперь мы можем загрузить ее вместе с пользователями:


Route::get('/test', function() {    $users = User::with('first_article')->get();    return view('test', compact('users'));});

Результат теперь выглядит следующим образом:


image


Итого, мы можем уменьшить количество наших запросов и решить проблему N+1 запроса. Но хорошо ли мы улучшили нашу производительность? Ответом может быть "нет"! Это правда, что мы уменьшили количество запросов и решили проблемы N+1 запроса, но на самое деле мы добавили новую неприятную проблему. Как вы видите, мы уменьшили количество запросов с 11 до 2, но мы также увеличили количество загружаемых моделей с 20 до 10010. Это означает, чтобы показать 10 пользователей и 10 заголовков статей мы загружаем 10010 объектов Eloquent в память. Если у вас не ограничена память, то это не проблема. Иначе вы можете положить ваше приложение.


Жадная загрузка динамических отношений


Должно быть 2 цели при разработке приложения:


  1. Мы должны сохранять минимальное количество запросов в БД
  2. Мы должны сохранять минимальное потребление памяти

В нашем примере, мы не смогли свести к минимуму потребление памяти, в то время, как мы уменьшили наши запросы до минимального количества. Во многих случаях разработчики также хорошо достигают первой цели, но проваливают вторую. В этом случае мы можем использовать подход жадной загрузки динамических отношений через подзапрос, чтобы достичь обеих целей.


Для реализации динамических отношений, мы будем напрямую использовать primary key вместо его foreign key. Мы также должны использовать подзапрос в связанной таблице, чтобы получить соответствующий идентификатор. Подзапрос будет размещен в select на основе отфильтрованных данных связанной таблицы.


Пример получения пользователей и id их первых статей через подзапрос:


select     "users".*,    (        select "id"         from "article"        where "writter_id" = "users"."id"        limit 1    ) as "first_article_id"from "users"

Мы можем получить такой запрос, если добавим select в подзапрос в нашем query builder. С использованием Eloquent это можно написать следующим образом:


User::addSelect(['first_article_id' => Article::select('id')                ->whereColumn('writter_id', 'users.id')                ->orderBy('created_at', 'asc')                ->take(1)    ])->get()

Этот код генерирует такой же sql запрос, что и в примере выше. После этого мы сможем использовать связь "first_article_id" для получения первых статей пользователя. Чтобы сделать наш код чище, мы можем использовать query scope Eloquent, чтобы упаковать наш код и выполнить жадную загрузку для получения первой статьи. Таким образом, мы должны добавить следующий код в класс модели User:


Class User extends Authenticatable{    public function articles()    {        return $this->hasMany(Aricle::class, 'writter_id');    }    public function first_article()    {        return $this->hasOne(Aricle::class, 'writter_id')->orderBy('created_at', 'asc');    }    public function scopeWithFirstArticle($query)    {        $query->addSelect(['first_article_id' => Article::select('id')                ->whereColumn('writter_id', 'users.id')                ->orderBy('created_at', 'asc')                ->take(1)        ])->with('first_article')    }}

И наконец, давайте изменим наш контроллер и шаблон. Мы должны использовать scope в нашем контроллере для жадной загрузки первой статьи. И мы можем напрямую обращаться к переменной first_article в нашем шаблоне:


Route::get('/test', function() {    $users = User::withFirstArticle()->get();    return view('test', compact('users'));});

@extends('layouts.app')@section('content')<ul>    @foreach($users as $user)        <li>Name: {{ $user->name}}</li>        <li>First Article: {{ $user->first_article->title }}</li>    @endforeach</ul>@endsection

Ниже результат производительности страницы после внесения этих изменений:


image


Теперь наша страница содержит всего 2 запроса и загружает 20 моделей. Мы достигли обеих целей оптимизации количества запросов к БД и минимизации потребления памяти.


Ленивая загрузка динамических отношений


Наша динамическая связь не будет работать хорошо автоматически. Сначала мы должны загрузить подзапрос для создания этой связи. Что, если мы хотим использовать ленивую подгрузку первых статей пользователя в одном месте и иметь жадную загрузку статей в другом месте?


Для этого нам нужно добавить небольшой хинт в наш ход, добавив accessor для свойства первой статьи:


public function getFirstArticleAtribute(){    if(!array_key_exists('first_article', $this->relations)) {        $this->setRelation('first_article', $this->articles()->oldest()->first());    }    return $this->getRelation('first_article');}

В действительности, мы не реализовывали ленивую загрузку для динамической связи. Мы просто назначили результат выполнения запроса получения первой статьи пользователя. Это должно работать одинаково хорошо при обращении к свойству first_article как для жадной загрузки, так и для ленивой загрузки.


Динамические связи в Laravel 5.X


К сожалению, наше решение применимо только к Laravel 6 и выше. Laravel 6 и предыдущие версии используется разная реализация addSelect. Для использования в более старых версиях фреймворка мы должны изменить наш код. Мы должны использовать selectSub для выполнения подзапроса:


public function scopeWithFirstArticle($query){    if(is_null($query->toBase()->columns)) {        $query->select([$query->toBase()->from . '.*']);    }    $query->selectSub(                Article::select('id')                ->whereColumn('writter_id', 'users.id')                ->orderBy('created_at', 'asc')                ->take(1)                ->toSql(),             'first_article_id'            )->with('first_article');}
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru