Галоўная / Артыкулы / Інтерфейсы для традынгу, якія адаптуюцца пад разныя умовы, з падтрымкай SSE, віртуалізацыі і бюджетам для 20 рынкаў

Інтерфейсы для традынгу, якія адаптуюцца пад разныя умовы, з падтрымкай SSE, віртуалізацыі і бюджетам для 20 рынкаў

Скоординавайце віртуалізаваныя рядкі React, центральны планашчык падпісоў SSE і зовнішній сховіщ для квот, ўпэўніваючыся таму, што адкрытыя заявкі будуць паставляць актуальныя цены без перакрыцча лімітав прыўязей.

1870 слоў

У табліцы для аналізу транзакцый можа быць перыяктыя інструменты, пры тым на экране паказваецца толькі калькі рэядоў. Котырыя з’являюцца без перапынення, апытак ужо можа скролляць, і для адкрытага замовлення можа заставацца неабходнае актуальная цена пасля таго, як рынак выйдзе за межы вікна прэгляду.

Этыя проблемы маюць разныя падходы да рашэння:

Scrolling changes visibility.
Subscription policy changes network demand.
SSE changes quote data.
React changes what is displayed.

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

З’явіце правілы перш чым выбраце спосаб ўтрымання

Праўільна будзе выйсці з таго, што бэкенд дазволяе максымум 20 ідэнтыфікатораў рынкаў на адной з’язку SSE. Тады правілы стаюць такімі:

  • Адны актыўны EventSource на кожную вікно браузера
  • Ніколі не больш чым 20 разных ідэнтыфікатораў рынкаў на гэтай з’язку
  • Адкрытае замовленне мае вышэйшы прыорітет чым звычныя рэяды списку
  • Патронак можа змяніцца без неабходнасці адменавання рэгістрацыі корыстніка
  • Актуальныя котыранцы зберагуюцца парад у стану компонента React
  • Прыходзячыя дадзеныя пераканальваюцца прытаму, прытым якія змянююць хранэлку
  • Шляхі для адправкі перачытаюць найновейшы котыранец, а не толькі той, які быў недавэльна прадстаўлены
  • Котыранцы, якія выйшлі з часу або былі перыяктаваны, не можна выкарыстоўваць для адправкі
  • Архітектура

    Virtualized list ─── visible and nearby market IDs ──┐
                                                       ├─► Subscription manager
    Open order ticket ─── selected market ID ───────────┘             │
                                                                     ▼
                                                              One SSE connection
                                                                     │
                                                              Validated messages
                                                                     ▼
                                                                 Quote store
                                                                /           \
                                                               ▼             ▼
                                                      React snapshots   Order validation
    

    Компоненты React адзначаюць патрэбныя данні і чытаюць ўзоркі. Яны не ачынаюць і не керуюць сокетамі.

    1. Хранэлка незалежна ад React

    Пераканальваныя котыранцы выглядаюць так:

    type Quote = Readonly<{
      marketId: string;
      bid: string;       // Decimal strings preserve wire precision.
      ask: string;
      version: number;
      staleAt: number;   // Server expiry timestamp in milliseconds.
      tradable: boolean;
    }>;
    

    Узоркі адкрываюць інформацыю пра гатовнасць а таксама і пакет даных:

    type QuoteSnapshot =
      | { state: "waiting"; quote: null }
      | { state: "fresh" | "stale"; quote: Quote };
    

    Форма хранэлкі застаецца маленькай:

    interface QuoteStore {
      receive(quote: Quote): void;
    
      // Immediate state, used by commands.
      readLatest(marketId: string): QuoteSnapshot;  // Cached visual snapshots, used by React.
      select(marketId: string): {
        subscribe(notify: () => void): () => void;
        getSnapshot(): QuoteSnapshot;
      };  invalidate(marketIds: readonly string[]): void;
      retain(marketIds: readonly string[]): void;
    }
    

    Логіка приему парадыруе версіі прытаму, прытым якія прыймаецца для апдэйту:

    function receiveQuote(next: Quote) {
      const previous = latestQuotes.get(next.marketId);
    
      // This example assumes strictly increasing quote versions.
      if (previous && next.version <= previous.version) return;  latestQuotes.set(next.marketId, next);  // Independent of whether another price message arrives.
      scheduleQuoteExpiry(next.marketId, next.staleAt);  // Ordinary prices can wait for the next visual publication.
      scheduleVisualPublication();
    }
    

    Часы выканання, анулюванне та розповсюджэння інформацыі сярод падпісчыкаў знаходзяцца ў магазіне. Історыя не є безмежной — зберагаецца толькі найсвежэйшы котырант для кожнага рынку:

    Market A, version 10
            ↓ replaced
    Market A, version 11
            ↓ replaced
    Market A, version 12
    

    Якщо сервер пераўтарае выкарыстоўванне прастораў версій між разнымі типамі фрэймаў, редукер павінен з’яеднаць іх сагласна гэтаму кантракту.

    2. Централізавана распадзелка 20 падпіскаў

    Корыстувачы абявляюць пра свою спрагу з павышаннем прыорітэта:

    type Demand = {
      marketIds: readonly string[];
      priority: number;
    };
    
    const PRIORITY = {
      orderTicket: 0,
      visible: 1,
      nearby: 2,
    } as const;const MAX_MARKETS = 20;
    

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

    function planSubscriptions(
      demands: readonly Demand[],
      activeIds: readonly string[],
    ): string[] {
      const priorities = new Map<string, number>();
    
      for (const demand of demands) {
        for (const marketId of demand.marketIds) {
          priorities.set(
            marketId,
            Math.min(
              priorities.get(marketId) ?? Infinity,
              demand.priority,
            ),
          );
        }
      }  if (priorities.size === 0) return [];  const active = new Set(activeIds);  const requested = [...priorities.keys()].sort(
        (left, right) =>
          priorities.get(left)! - priorities.get(right)! ||
          Number(active.has(right)) - Number(active.has(left)),
      );  return [...new Set([...requested, ...activeIds])]
        .slice(0, MAX_MARKETS)
        .sort();
    }
    

    Калі спрага перасягае двадцать рынкаў, зайвыя запісы застаюцца виднымі і чакаюць. Інтерфейс не павінен адазначаць, што у яных є жывая падтрымка.

    3. Апдэйт спрагі без пачатку новага эфекту React

    Кожны корыстувач атрымлеў стабільнага власніка:

    function useMarketDemand(
      marketIds: readonly string[],
      priority: number,
    ) {
      const [owner] = useState(() =>
        subscriptionManager.createOwner(),
      );
    
      const key = JSON.stringify([...new Set(marketIds)]);
      const stableIds = useMemo<string[]>(
        () => JSON.parse(key),
        [key],
      );  useEffect(() => {
        owner.update({
          marketIds: stableIds,
          priority,
        });
      }, [owner, stableIds, priority]);  useEffect(() => {
        return () => owner.dispose();
      }, [owner]);
    }
    

    Змены ID апдэйтуюць гэтага власніка на месцы; ад’язванне чыстае його.

    Шырокія змянення патрабаванняў адбываюцца ў межах фіксаванага вікна планавання:

    let reconciliationTimer:
      ReturnType<typeof setTimeout> | undefined;
    
    function scheduleReconciliation() {
      if (reconciliationTimer !== undefined) return;  reconciliationTimer = setTimeout(() => {
        reconciliationTimer = undefined;
        reconcileSubscriptions();
      }, 150);
    }
    

    Паколькі вікно є фіксаваным, а не змінным, непаўзлівая прасування не можа вечна адкладаць процес супрацоўкі. Першыя падключэння і заканчальная дезінсталяцыя все ўсё можу адбыцца негайна.

    4. Безпечна заменіце з’ѐеднанне SSE

    Новыя параметры запита означаюць новы EventSource. Закрыйце пярэдній стрым прычым апускання наступнага:

    let source: EventSource | undefined;
    let generation = 0;
    let clearWatchdog: (() => void) | undefined;
    
    function replaceConnection(marketIds: string[]) {
      const currentGeneration = ++generation;  clearWatchdog?.();
      source?.close();
      source = undefined;  quoteStore.retain(marketIds);
      quoteStore.invalidate(marketIds);  if (marketIds.length === 0 || !navigator.onLine) return;  const query = new URLSearchParams();  for (const marketId of marketIds) {
        query.append("marketId", marketId);
      }  const nextSource = new EventSource(
        `/api/v1/stream?${query.toString()}`,
      );  source = nextSource;
      const permittedIds = new Set(marketIds);  const watchdog = createSilenceWatchdog(() => {
        if (currentGeneration !== generation) return;    nextSource.close();
        reconcileSubscriptions({ force: true });
      });  clearWatchdog = watchdog.stop;
      watchdog.reset();  nextSource.addEventListener("quote", event => {
        if (currentGeneration !== generation) return;    // Parses JSON and validates it against the feed contract.
        const quote = parseQuoteMessage(event);    if (!quote || !permittedIds.has(quote.marketId)) {
          quoteStore.invalidate(marketIds);
          return;
        }    watchdog.reset();
        quoteStore.receive(quote);
      });  nextSource.addEventListener("heartbeat", () => {
        if (currentGeneration === generation) {
          watchdog.reset();
        }
      });  nextSource.onerror = () => {
        if (currentGeneration !== generation) return;    quoteStore.invalidate(marketIds);    if (nextSource.readyState === EventSource.CLOSED) {
          watchdog.stop();
          reportConnectionError();
        }    // Recoverable failures are retried by native EventSource.
      };
    }
    

    Функцыі нагляду, парсера і статусу з’ѐеднання застаюцца ў окремых модулях. Калькулятор паколення адсілае запознелыя звантажэнні з застарэлых сокетаў. Натыўны EventSource падключаецца занова у разы выканання помилак, якія можна вярнуць; вызов .close() завершае гэты экземпляр. Адзін раз пагледзіце нарадчык MDN пра викорыстоўванне серверных запускаемых з’явленняў для разумення прыменшэння браузера.

    Абоненты для роботы онлайн/офлайн і працы з жыццёвым циклам сторанкі знаходзяцца на рэвэле менеджара: перыяд офлайн-роботы анульвае запиты і закрывае сокет; пасля вярнення на звязак адбываецца павторны запуск на базе текущага запиту. Новы звязак чакае на новыя запіты, прычыму прабуючы ўвайсіць, перш чым дазволіць адправку.

    5. Вяртуалізаваць спіс і адобразіць його вікнаперагляду

    Вяртуалізацыя обмежвае розмеры DOM-элементаў; планавальнік абонентаў окрема обмежвае рынкі, якія транслююцца. За дапамогою TanStack Virtual видныя рядкі можуць мець вышэйшы прыорітет чым суседнія рядкі, якія знаходзяцца за межама вікнаперагляду:

    function MarketList({
      markets,
      onOpenTicket,
    }: {
      markets: Array<{ marketId: string; name: string }>;
      onOpenTicket(marketId: string, side: "BUY" | "SELL"): void;
    }) {
      const [viewport, setViewport] =
        useState<HTMLDivElement | null>(null);
    
      const virtualizer = useVirtualizer({
        count: markets.length,
        getScrollElement: () => viewport,
        getItemKey: index => markets[index]!.marketId,
        estimateSize: () => 72,
        overscan: 2,
      });  const rows = virtualizer.getVirtualItems();
      const range = virtualizer.range;  const visibleIds = range
        ? markets
            .slice(range.startIndex, range.endIndex + 1)
            .map(market => market.marketId)
        : [];  const nearbyIds = rows.map(
        row => markets[row.index]!.marketId,
      );  useMarketDemand(visibleIds, PRIORITY.visible);
      useMarketDemand(nearbyIds, PRIORITY.nearby);  return (
        <div
          ref={setViewport}
          role="region"
          aria-label="Markets"
          tabIndex={0}
          style={{ height: 560, overflow: "auto" }}
        >
          <div
            style={{
              height: virtualizer.getTotalSize(),
              position: "relative",
            }}
          >
            {rows.map(row => (
              <div
                key={row.key}
                style={{
                  position: "absolute",
                  top: 0,
                  left: 0,
                  width: "100%",
                  height: 72,
                  transform: `translateY(${row.start}px)`,
                }}
              >
                <MarketRow
                  market={markets[row.index]!}
                  onOpenTicket={onOpenTicket}
                />
              </div>
            ))}
          </div>
        </div>
      );
    }
    

    У гэтых прыкладах предпакуецца фіксаваная вышыня рядкаў; для рядкаў з мянюючысяй вышынай патрэбны замеры. Вяртуалізатор монтуюць рядкі; правілы абонавання застаюцца у власнасці самай аплікацыі (дакладней у документацыі TanStack Virtual для React). Калі рядак пакідае звычны вікнаперагляд, фокус клавіатуры таксама павінен заставляць яго залишыцца доступным.

    6. Адобразіць кожны рынак через свой снэпшот

    function useQuote(marketId: string) {
      const selection = useMemo(
        () => quoteStore.select(marketId),
        [marketId],
      );
    
      return useSyncExternalStore(
        selection.subscribe,
        selection.getSnapshot,
      );
    }
    

    Дакле, паколькі ў тым рынку не адбудзецца якіх-небудзь значных змян, select продовжае вяртаць той самы ідэнтычны кеш-адрес — адносна таго, чакае React ад зовнішняй базы дадзеных, якая викорыстоўваецца з useSyncExternalStore.

    const MarketRow = memo(function MarketRow({
      market,
      onOpenTicket,
    }: {
      market: { marketId: string; name: string };
      onOpenTicket(marketId: string, side: "BUY" | "SELL"): void;
    }) {
      const snapshot = useQuote(market.marketId);
      const quote = snapshot.quote;
    
      const available =
        snapshot.state === "fresh" && quote?.tradable;  return (
        <article>
          <strong>{market.name}</strong>      <button
            disabled={!available}
            onClick={() => onOpenTicket(market.marketId, "SELL")}
          >
            Sell · {quote?.bid ?? "—"}
          </button>      <button
            disabled={!available}
            onClick={() => onOpenTicket(market.marketId, "BUY")}
          >
            Buy · {quote?.ask ?? "—"}
          </button>      {snapshot.state !== "fresh" && (
            <span>Prices updating</span>
          )}
        </article>
      );
    });
    

    Змяна курсу на Рынку А спавешчае толькі чытача Рынка А — яна не заменяе всю масэвую структуру рынкаў у галоўным списку. Кнопкі ачынаюць заявку; яны не ставяць замовленне без жадных паўтарэнняў.

    У заявцы прызначаецца ўласны запит:

    function OrderTicket({ marketId }: { marketId: string }) {
      useMarketDemand([marketId], PRIORITY.orderTicket);
    
      const snapshot = useQuote(marketId);  // Render quantity, side, current quote, review and submit controls.
      // ...
    }
    

    Прасуванне сеткі не анулюе тэкст запиту ў заявцы. Адмова ад заявкі выкарыстоўвае толькі таго власніка; видныя рядкі аб близкія записы можа ўсё ж патрабаваць той самы інструмент.

    Абавязкова пераканайцеся ў актуальным курсе бездзейна пры пакупцы аб продажу

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

    async function submitOrder(intent: {
      clientOrderId: string; // Stable for retries of this exact intent.
      marketId: string;
      side: "BUY" | "SELL";
      quantity: string;
      reviewedVersion: number;
    }) {
      const snapshot = quoteStore.readLatest(intent.marketId);
      const quote = snapshot.quote;
    
      if (
        !navigator.onLine ||
        snapshot.state !== "fresh" ||
        !quote ||
        !quote.tradable ||
        Date.now() >= quote.staleAt
      ) {
        throw new Error("Wait for a fresh, tradable quote.");
      }  if (quote.version !== intent.reviewedVersion) {
        throw new Error("The quote changed. Review it again.");
      }  const limitPrice =
        intent.side === "BUY" ? quote.ask : quote.bid;  const response = await fetch("/api/v1/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          ...intent,
          type: "LIMIT",
          limitPrice,
        }),
      });  return parseOrderResponse(response);
    }
    

    Адпаведзі зьвяршаюцца аналізам на мету падтверджэння, адхоўкі чы ў разе неяснасці. Перапрыбутак падзеі зьвяртаеся да перваснага ідэнтыфікатора заявкі кліента. Автарызацыя, перакананне ў правядомасьці котацыі, падтверджэння чы заполненне застаюцца рашэньмі сервера. Ліміты цен толькі обмежваюць дазволеныя способы выкарыстоўвання — яны ніколі не гарантуюць заполнення.

    Два рашэньні па часу

    Рашэнь Мета
    Вікна падпіску ~150 мс Акумулявацыя швайнах змян у відобразэнні прычынам перзамены з’ѐднання
    Інтэрвал візуальной публікацыі ~100 мс Обмежэнне частоты перзапісу звычайных цен

    Уваходзячыя паведамленні як і раней адразу апдэююць актуальны склад. Можлівасць выкарыстоўвання паведамленняў не спрачывае звычнага візуальнага затрымкі. Вакно у 150 мс не прызначана для спамтавлення прасування, клікаў чы выказвання цэнаў, якія вже надходзяць праз актывае з’ўязанне.

    Памяць і чыстка

    Кэш каталагу (напрыклад, React Query) патрэбуе сабой правіла зберагчыць даныя. Віртуалізаваныя спісы могу паказваць дзесять рядоў, пры тым залишаючысь у кэшы тыясячы завантажаных записаў. Хавайце задачі разнымі: віртуалізацыя адпаведае за паказаную інтэрфейсную частку, планавальнік — за магчымасць падпіску, склад — за стан квоты, а React атрыбуе значэння тым кадрам, якія маюць значэнне.