Рефакторинг Vizend Dock

Рефакторинг Vizend Dock

1. Введение

Vizend Dock является общим модулем фронтенда, который управляет учетными данными пользователя и информацией о текущем рабочем пространстве. Он хранит не только токены доступа и обновления, выданные после входа в систему, но и рабочий контекст, такой как pavilion, cineroom, stage, actor, а также передает необходимые заголовки для аутентификации и контекст Dock через интерсептор при запросах к API. Таким образом, если состояние Dock нарушено, это не ограничивается только неправильным отображением на экране в одном месте. На это влияют такие аспекты, как состояние входа, определение прав, переход между экранами и контекст запросов к API.

Существующий Dock изначально был написан с предположением, что это веб-приложение. Такие API браузера, как window, localStorage, sessionStorage и BroadcastChannel, использовались напрямую несколькими атомами, хуками и интерсепторами. Это работало только в веб-приложениях, но когда мы попытались применить Dock к мобильному приложению, это предположение сразу стало проблемой. В среде React Native не существует window.localStorage и window.sessionStorage.

Существовала проблема, когда состояние Dock предыдущего пользователя сохранялось в вебе, или когда значение в хранилище изменялось, но хук не обновлялся немедленно, и изменения отображались только после обновления страницы. Это происходило потому, что атомы аутентификации, атомы Dock и фактические хранилища имели отдельные состояния, из-за чего могли обновляться только некоторые из них в зависимости от путей изменений.

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

2. Проблемы, возникшие в существующей структуре

2.1 Сильная связь с браузерным хранилищем

До рефакторинга состояние аутентификации непосредственно читалось из браузерного хранилища в момент объявления атома. Состояние Dock также считывалось из window.sessionStorage при загрузке модуля и использовалось в качестве начального значения атома.

export const accessTokenAtom = atom(
  sessionStorage.getItem(accessTokenKey) ?? '',
);

const load = () => {
  const session =
    window?.sessionStorage.getItem(dockSessionKey) ?? '{}';
  const context =
    window?.sessionStorage.getItem(dockContextKey) ?? '{}';

  return {
    session: JSON.parse(session),
    context: JSON.parse(context),
  };
};

Этот код не разделяет технологию хранения и код управления состоянием. В браузере реализация Storage уже предоставлена, но в React Native приложение должно выбрать хранилище, которое будет использоваться. Чтобы добавить мобильное хранилище, необходимо было найти и изменить все прямые ссылки, рассеянные по атомам, хукам и интерсепторам. Даже тестирование с использованием хранилища в памяти оказалось непростым.

Политики местоположения для хранения также были разбросаны по нескольким местам. Правило, согласно которому токен доступа размещается в session storage, а токены обновления и значения, которые запомнены, находятся в local storage, было реализовано отдельно в атомах и интерсепторах.

2.2 Дублирование, когда атом и хранилище хранят каждое свое состояние

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

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

  • После входа или обновления токена значение в хранилище изменилось, но хук сохранил предыдущее значение.

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

  • Порядок инициализации atom и порядок инициализации interceptor различны, из-за чего при первом входе могут возникнуть случаи, когда состояние не считывается корректно.

  • Разные источники значений, считываемых hook и interceptor, затрудняли воспроизведение проблемы и отладку.

2.3 Фрагментация ответственности за настройки

На мобильных устройствах необходимо было заменить не только storage, но и веб-специфические действия. Например, поскольку нельзя напрямую вызывать window.alert и window.location, функции уведомлений и переходов между экранами должны были внедряться в мобильное приложение. Однако в начальной структуре каждый atom инициализации для аутентификации, инициализации Dock, хранилища auth interceptor, хранилища context interceptor и хранилища logbook interceptor имел свои отдельные функции настроек.

Если настройки разделены на несколько точек входа, может возникнуть несоответствие, из-за которого «hook видит новое хранилище, а interceptor обращается к базовому хранилищу». Следовательно, необходимо было объединить не только реализацию хранилища, но и границы инициализации.

3. Первоначальный ответ: обеспечение мобильного пути исполнения через внедрение хранилища

Во-первых, была добавлена возможность разделять веб- и не веб-среду, чтобы код Dock мог выполняться в React Native и внедрялся внешний storage. Веб-среда использует существующее основное хранилище, а мобильное приложение должно передавать реализацию KeyValueStorage.

Общее соглашение по хранилищу определено как простые операции key-value: get, set, remove, clear.

export interface KeyValueStorage {
  get: (key: string) => string | null;
  set: (key: string, value: string) => void;
  remove: (key: string) => void;
  clear: () => void;
}

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

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

Также для замены веб-специфичных уведомлений и переходов было решено внедрить обработчики showAlert и navigateTo. Таким образом, побочные эффекты, зависящие от платформы, были вынесены наружу, помимо хранилища.

Хотя первоначальный ответ обеспечил мобильный путь исполнения, atom и storage все еще одновременно управляли состоянием, и функции инициализации аутентификации и Dock были раздельными. Простой возможности внедрять хранилище недостаточно для решения проблемы синхронизации состояния.

4. Конечное направление: переключение на единый опорный пункт для хранилища

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

Во-первых, состояние аутентификации и Dock можно читать и записывать только через центральный менеджер хранилища.

Во-вторых, хук React не обладает собственным значением и отображает значение, прочитанное из менеджера.

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

В-четвертых, инициализация хранилища и интерсептора выполняется в одной точке входа.

Мы убрали существующую цепочку атомной структуры и внедрили CentralStorageManager. Это не означает, что Jotai было полностью убрано из Dock. Только состояния, которым необходимо постоянное хранилище, такие как токен аутентификации и сессия/контекст Dock, были перенесены в менеджер хранилища, в то время как существующий подход сохраняется для состояний, необходимых только в памяти, таких как heartbeat и режим разработки.

Цель заключалась не в унификации всех состояний под одной технологией, а в устранении проблемы, когда оригинал постоянного состояния существует в двух местах: атоме и хранилище.

5. Реализация абстракции хранилища

5.1 Политика центрального менеджера и места хранения

CentralStorageManager получает одно внедрение реализации local/session storage и предоставляет общие операции get, set, remove. Значения, отличные от строк, сериализуются в JSON, и при запросе сначала пытается разбить JSON.

export class CentralStorageManager {
  private localStorage: KeyValueStorage | null = null;
  private sessionStorage: KeyValueStorage | null = null;
  private listeners = new Map<string, Set<(value: any) => void>>();

  initialize(
    localStorage: KeyValueStorage,
    sessionStorage: KeyValueStorage,
  ) {
    this.localStorage = localStorage;
    this.sessionStorage = sessionStorage;
  }

  set<T>(
    key: string,
    value: T,
    storageType: StorageType = 'session',
  ): void {
    const storage = this.getStorage(storageType);
    if (!storage) return;
    const serialized =
      typeof value === 'string' ? value : JSON.stringify(value);
    storage.set(key, serialized);
    this.notifyListeners(key, value);
  }

  remove(
    key: string,
    storageType: StorageType = 'session',
  ): void {
    const storage = this.getStorage(storageType);
    if (!storage) return;

    storage.remove(key);
    this.notifyListeners(key, undefined);
  }
}

Политика мест хранения была собрана в доменных API auth и dock. Вызывающий не должен каждый раз определять, в каком хранилище хранится токен доступа, а просто вызывает storageManager.auth.setAccessToken(). Политика, согласно которой refresh token находится в local, access token в session, а сессия и контекст Dock в session, раскрывается в одном файле.

auth = {
  getAccessToken: () =>
    this.get(STORAGE_KEYS.ACCESS_TOKEN, '', 'session'),
  setAccessToken: (token: string) =>
    this.set(STORAGE_KEYS.ACCESS_TOKEN, token, 'session'),

  getRefreshToken: () =>
    this.get(STORAGE_KEYS.REFRESH_TOKEN, '', 'local'),
  setRefreshToken: (token: string) =>
    this.set(STORAGE_KEYS.REFRESH_TOKEN, token, 'local'),

  clearTokens: () => {
    this.remove(STORAGE_KEYS.ACCESS_TOKEN, 'session');
    this.remove(STORAGE_KEYS.REFRESH_TOKEN, 'local');
    this.remove(STORAGE_KEYS.REMEMBERED, 'local');
  },
};

Так как все используют один и тот же API для хука, интерсептора, обработки выхода и обновления токена, достаточно изменить только центрального администратора, даже если место хранения меняется. В тестах можно внедрить хранилище KeyValueStorage на основе памяти для формирования потока состояния без браузера.

5.2 API для частичного обновления состояния Dock

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

updateContext: (updates: Partial<DockContext>): void => {
  const currentContext = this.dock.getContext();
  const updatedContext = { ...currentContext, ...updates };
  this.set(
    STORAGE_KEYS.DOCK_CONTEXT,
    updatedContext,
    'session',
  );
},

updateSessionField: <T extends keyof SessionDockRdo>(
  field: T,
  value: SessionDockRdo[T],
): void => {
  const currentSession = this.dock.getSession();
  const updatedSession = {
    ...currentSession,
    [field]: value,
  };
  this.set(
    STORAGE_KEYS.DOCK_SESSION,
    updatedSession,
    'session',
  );
},

После этого useDock стал использовать API, которое показывает намерение изменения, вместо замены всего большого объекта атома, и все записи прошли через одинаковый путь уведомления.

6. Синхронизация хуков и хранилища с помощью паттерна Observer

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

subscribe(
 key: string,
 listener: (value: any) => void,
): () => void {
 if (!this.listeners.has(key)) {
   this.listeners.set(key, new Set());
 }
 this.listeners.get(key)!.add(listener);
 return () => {
  const keyListeners = this.listeners.get(key);
  keyListeners?.delete(listener);
  if (keyListeners?.size === 0) {
    this.listeners.delete(key);
  }
 };
}

private notifyListeners(key: string, value: any): void {
 this.listeners.get(key)?.forEach((listener) => {
  listener(value);
 });
}

менеджер выполняет роль Subject, а hook выполняет роль Observer. Когда выполняется set или remove, соответствующий слушатель уведомляется, и слушатель заново выполняет getter, чтобы также вычислить производное значение, такое как authenticated. Эта логика подписки была выделена в общий hook useStorageValues.

export const useStorageValues = <
 T extends Record<string, any>
>(
 gettersMap: { [K in keyof T]: () => T[K] },
 subscriptionKeys: string[],
): T => {
 const gettersMapRef = useRef(gettersMap);
 gettersMapRef.current = gettersMap;

 const [values, setValues] = useState<T>(() => {
  const initialValues = {} as T;
  for (const [key, getter] of Object.entries(gettersMap)) {
   initialValues[key as keyof T] = getter();
  }
  return initialValues;
});

const updateValues = useCallback(() => {
 const newValues = {} as T;
 for (
   const [key, getter]
   of Object.entries(gettersMapRef.current)
 ) {
   newValues[key as keyof T] = getter();
 }
 setValues(newValues);
}, []);

useEffect(() => {
 if (!storageManager.isInitialized()) return;
 const unsubscribers = subscriptionKeys.map((key) =>
  storageManager.subscribe(key, updateValues),
 );
 updateValues();
 return () =>
  unsubscribers.forEach((unsubscribe) => unsubscribe());
}, [updateValues]);

 return values;
};

getterMap поддерживается в ref, чтобы функция слушателя не изменялась при каждом рендеринге. useAuthValues подписывается на ключи, связанные с аутентификацией, а useDockValues подписывается на сессию и контекст Dock. При выделении в общий hook также были удалены дублирующий код обновления счетчика, существовавший в каждом hook, и код подписки/отписки.

7. Объединение границ инициализации

Чтобы storage manager работал, каждая платформа должна предоставить корректную реализацию. Для этого инициализацию storage и конфигурацию интерцепторов мы объединили в initStorageSystem.

В вебе без специальной настройки используется существующий адаптер local/session storage. В не веб-средах обязательно принимаются storage и обработчики showAlert, navigateTo, а если sessionStorage отсутствует, используется реализация local storage. Затем сначала инициализируется storage manager, а потом настраиваются интерцепторы auth, context, log.

if (isWeb) {
  finalLocalStorage =
    localStorage || getLocalKeyValueStorage();
  finalSessionStorage =
    sessionStorage || getSessionKeyValueStorage();
} else {
  setNotWebHandlers({
    showAlert: notWebHandlers!.showAlert,
    navigateTo: notWebHandlers!.navigateTo,
  });

  finalLocalStorage = localStorage!;
  finalSessionStorage = sessionStorage || localStorage!;
}

storageManager.initialize(
  finalLocalStorage,
  finalSessionStorage,
);
configureInterceptors([
  authInterceptor,
  contextInterceptor,
  logbookInterceptor,
...interceptors,
]);

В функцию инициализации также добавлена проверка, чтобы избежать дублирующих вызовов. Если настроить это один раз в точке входа приложения, hook и интерцепторы будут использовать один и тот же storage manager. Существующий способ вызова в вебе также сохраняется, что снижает объем изменений в существующем приложении.

8. Результаты применения

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

Во-первых, реализация хранилища была отделена от Dock. Веб использует базовый адаптер, а мобильное приложение внедряет реализацию, удовлетворяющую контракту KeyValueStorage.

Во-вторых, состояние аутентификационного токена и состояние сессии/контекста Dock были интегрированы в storage manager. Проблема, связанная с тем, что hook и интерцепторы смотрели на разные источники состояния, уменьшилась, и состояние теперь немедленно обновляется через уведомление Observer после изменения значения.

В-третьих, политика хранилища и ответственность за инициализацию были централизованы. Место хранения access/refresh токенов, порядок инициализации для веба и не веба, объем очистки при выходе из системы можно проверить в одном определенном файле. При выходе из системы и истечении сессии будут удалены не только токен, но и сессия Dock, контекст и лог, чтобы избежать проблемы с оставшимися состояниями предыдущего пользователя.

В-четвертых, файл, управлявший цепными атомами, был удален, и роли были реконструированы с помощью storageManager и useStorageSubscription, что привело к уменьшению дублирующего кода.

В-пятых, useCrossTabTokenRefresh, useLogbook, интерцепторы auth/context/log начали использовать один и тот же API менеджера, что сделало выполнение последующих изменений более ясным.

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

9. Извлеченные уроки

Наиболее важный урок, который я извлек из этой работы, заключается в том, что владение состоянием более важно, чем сама библиотека управления состоянием. Это было сложно не из-за частого использования atom, а потому что все atom, storage и interceptor владели состоянием и могли изменять его по-разному.

Во-вторых, необходимо правильно определить объем абстракции платформы. Сначала это казалось проблемой замены localStorage на мобильное хранилище, но предпосылки платформы распространились и на уведомления, перемещение, порядок инициализации, обновление токена и журнал.

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

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

10. В заключение

Применение Vizend Dock на мобильных устройствах не ограничивалось простым обертыванием браузерного API в условные инструкции. Проблемы двойного состояния и фрагментации инициализации, которые ранее были скрыты, стали более ясными в процессе расширения платформы.

Мы смогли абстрагировать хранилище в KeyValueStorage, принять центральный менеджер как единую опорную точку для постоянного состояния и упростить поток состояния, соединяя хуки по ключевому Observer паттерну. При этом мы создали структуру, которая позволяла инъектировать мобильное хранилище, сохраняя при этом существующий способ использования веба, объединив точки входа для инициализации и область очистки при выходе.

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

IAN

Site footer