Обмен между ASP.NET Core и Typescript на фронтенде
Обмен между ASP.NET Core и Typescript на фронтенде

Всем привет, Меня зовут Олег и я .NET разработчик в компании Bimeister. Я хотел поделиться с вами своим интересным опытом по связи проекта на ASP.NET Core Web App с использованием IAsyncEnumerable коллекций и современного фронтенд SPA приложения на Typescript и Vue.js 3 версии.

В данной статье будет рассмотрено пошаговое создание простого проекта как для .NET, так и для его клиентской части, а также особенность их взаимодействия. Я хотел показать на несложном примере (даже для новичков в разработке по этим технологиям) как можно организовать обмен данными между ними. Тем не менее, даже искушенным разработчикам статья может оказаться полезной, если вы хотите попробовать использовать потоковое получение данных в Typescript или уже столкнулись со странными сложностями.

Все эти технологии не новы и, на самом деле, фреймворк и фронтенд часть могут быть любые на ваш вкус: Angular, или React, или что-либо еще. Весь интерес представляет собой именно унификация процесса обмена с современной асинхронной коллекцией с бэкенда на C#. Я не буду подробно заострять внимание на структуре обеих половин приложения, скажу лишь, что использовал один из сложившихся в моей практики шаблонов для запуска ASP.NET Core Web приложения с SPA. Все детали можно будет посмотреть в приложенном репозитории.

Давайте начнем с понимания — зачем вообще требуется этот IAsyncEnumerable и что с ним можно сделать? Это интерфейс, который позволяет обращаться к данным как к потоку, позволяя получать большое количество значений и возвращать их для дальнейшей обработки по мере готовности в асинхронном режиме. Он часто используются при конструкции async foreach и подробнее обо всем этом можно найти множество отличных статей, например, на Хабре. Я лишь заострю внимание на том, что главный интерес представляет собой возможность не ждать загрузки целой коллекции сразу, а что-то делать с каждым элементом или группой элементов по мере их появления. 

Давайте создадим примитивное веб-приложение с имитацией считывания данных из БД в асинхронном режиме. Я оставляю за пределами данной статьи размышления, почему стоит использовать базовую асинхронность или DI в своих приложениях, здесь они будут скорее всё же излишними, но зато соответствующими неким нынешним корпоративным стандартам написания кода на .NET. Давайте создадим базовое Core Web App приложение через Visual Studio 2022. Я буду использовать .NET 7, но подойдет любой из них с поддержкой этих конструкций. В качестве шаблона выбран ASP.NET Core Web API для простоты настроек связки с будущим фронтендом.

Шаблон проекта
Шаблон проекта
Настройки проекта
Настройки проекта

Для удобства работы не будем создавать отдельные проекты под слои приложения (что конечно же очень рекомендуется в ваших серьезных проектах). Создадим папки ClientApp (для фронтенда, вернемся к ней ниже в статье), Models и Repository, избавимся от WeatherForecast классов. В папку Models добавим класс некого продукта с разнообразными полями (чтобы потом было интереснее проверить их передачу на клиента). Набор полей не имеет значения, я лишь постарался сделать их отличающимися.

public class Product
{
    public Guid Id { get; set; }

    public string Code { get; set; }

    public string Name { get; set; }

    public int SortOrder { get; set; }

    public bool Enabled { get; set; }

    public DateTime ActivationDate { get; set; }  
}

Теперь в папке для репозитория объявим интерфейс IProductRepository, который будет содержать 2 метода. Первый — для классической отдачи всей коллекции, второй — с использованием IAsyncEnumerable. Естественно, оба они реализованы с учетом async/await подхода с токенами отмены и Task, чтобы было нагляднее и современнее. Каждый метод также принимает параметр onlyEnabled, который должен задавать фильтрацию — либо все элементы, либо с Enabled == true.

public interface IProductRepository
{
    Task<IReadOnlyCollection<Product>> GetClassicListAsync(bool onlyEnabled, CancellationToken ct = default);
    IAsyncEnumerable<Product> GetEnumerableListAsync(bool onlyEnabled, CancellationToken ct = default);
}

Теперь давайте добавим реализацию этого интерфейса ProductRepository, который будет просто с задержкой (спасибо команде await Task.Delay для симуляции реальной работы с БД) возвращать большую коллекцию продуктов. Для генерации разных значений написан примитивный метод GenerateProducts, который просто заполняет все поля какими-то значениями, причем только половина будет с Enabled == true. Он не асинхронный, так что и для GetEnumerableListAsync мы используем обычный foreach с yield return каждого элемента после задержки. GetClassicListAsync же вернет всю коллекцию после потенциальной фильтрации в LINQ.

public class ProductRepository : IProductRepository
{
    //emulate some datasource
    private IReadOnlyCollection<Product> GenerateProducts()
    {
        var retval = new List<Product>();
        for (int i = 0; i < 100; i++)
        {
            var product = new Product()
            {
                Id = Guid.NewGuid(),
                Name = $"Some name for HABR {i}",
                Code = $"{Math.Abs(i.ToString().GetHashCode())}",
                SortOrder = i,
                ActivationDate = DateTime.Today.AddDays(-1 * i),
                Enabled = i % 2 == 0
            };
            retval.Add(product);
        }
        return retval.AsReadOnly();
    }

    public async Task<IReadOnlyCollection<Product>> GetClassicListAsync(bool onlyEnabled, CancellationToken ct = default)
    {
        ct.ThrowIfCancellationRequested();
        //emulate slow connection
        await Task.Delay(2000, ct);
        return GenerateProducts()
            .Where(p => !onlyEnabled || p.Enabled)
            .ToList()
            .AsReadOnly();
    }

    public async IAsyncEnumerable<Product> GetEnumerableListAsync(bool onlyEnabled, CancellationToken ct = default)
    {
        foreach (var product in GenerateProducts())
        {
            ct.ThrowIfCancellationRequested();
            if (!onlyEnabled || product.Enabled)
            {
                //emulate slow connection
                await Task.Delay(1000, ct);
                yield return product;
            }
        }
    }
}

Теперь давайте добавим API контроллер ProductController для получения данных этих обоих методов. Создадим его и настроим из шаблона API Controller — Empty. Добавим атрибут для роутинга с явным указанием вызываемого метода api/[controller]/[action] и через DI подтянем по интерфейсу наш репозиторий. Наше API будет содержать 2 метода, для классического получения и для IAsyncEnumerable соответственно. Опустим логирование, обработку ошибок и прочие, безусловно важные, аспекты работы приложения. Как можно заметить, вместо классического Task<ActionResult> здесь явно отправляется коллекция в методе GetAsyncStream.

[ApiController]
[Route("api/[controller]/[action]")]
public class ProductController : ControllerBase
{
    public ProductController(IProductRepository rep)
    {
        _rep = rep;
    }

    private readonly IProductRepository _rep;

    [HttpGet]
    public IAsyncEnumerable<Product> GetAsyncStream(bool onlyEnabled, CancellationToken ct)
    {
        return _rep.GetEnumerableListAsync(onlyEnabled, ct);
    }

    [HttpGet]
    public async Task<ActionResult<IReadOnlyCollection<Product>>> GetList(bool onlyEnabled, CancellationToken ct)
    {
        var retval = await _rep.GetClassicListAsync(onlyEnabled, ct);
        return Ok(retval);
    }
}

Дальше в ASP приложении осталось долго и нудно настроить несколько вещей, большая часть из которых в Program.cs и в csproj файле самого проекта. Не будем останавливаться на каждой (наверняка есть более изящные способы), лишь подчеркнем самое важное. Приложение будет знать что оно работает при отладке в связке с SpaDevelopmentServer, так что вынесем в файл appsettings.json (всё остальное из него я удалил) настройку VitePortNumber для сервера фронтенд разработки Vite. К нему мы доберемся уже скоро! Я поставил порт 4444, вы вольны выбрать любой другой, главное чтобы он отличался от дефолтного порта запуска самого нашего API из настроек приложения.

{
  "VitePortNumber": 4444
}

Обновим файл Program.cs чтобы он подключал все базовые вещи вроде авторизации, контроллеров и прочего. Сначала добавим необходимые пакеты через Nuget

  • Microsoft.AspNetCore.Authorization

  • Microsoft.AspNetCore.SpaServices.Extensions

  • Microsoft.Extensions.Configuration

  • Microsoft.Extensions.DependencyInjection

Затем добавим все необходимые команды для middleware и настроим поведение в зависимости от того, у нас Development (тогда рядом запущен Vite) или другая среда.

public static void Main(string[] args)
{
    var builder = WebApplication.CreateBuilder(args);
    string environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
    var configuration = new ConfigurationBuilder()
        .AddJsonFile($"appsettings.json")
        .AddEnvironmentVariables()
        .Build();

        var port = configuration.GetValue<int>("VitePortNumber", 4444);
        builder.Services.AddAuthentication(IISDefaults.AuthenticationScheme);
        builder.Services.AddAuthorization();
        builder.Services.AddCors(options =>
        {
            options.AddDefaultPolicy(builder =>
                builder.SetIsOriginAllowed(_ => true)
                    .WithOrigins($"http://localhost:{port}")
                    .AllowAnyMethod()
                    .AllowAnyHeader()
                    .AllowCredentials());
        });
        // Add services to the container.
        builder.Services.AddControllers()
            .AddJsonOptions(options => options.JsonSerializerOptions.PropertyNameCaseInsensitive = true);
        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddHttpContextAccessor();
        //DI
        builder.Services.AddScoped<IProductRepository, ProductRepository>();
        if (builder.Environment.IsStaging() || builder.Environment.IsProduction())
        {
            builder.Services.AddSpaStaticFiles(configuration =>
            {
                configuration.RootPath = "ClientApp/dist";
            });
        }
        //
        var app = builder.Build();
        app.UseStaticFiles();
        if (builder.Environment.IsStaging() || builder.Environment.IsProduction())
        {
            app.UseSpaStaticFiles();
        }
        else
        {
            app.UseCors();
        }
        app.UseRouting();
        app.UseAuthorization();
        app.UseAuthentication();
        app.UseEndpoints(endpoints => endpoints.MapControllers());
        app.UseSpa(spa =>
        {
            if (app.Environment.IsDevelopment())
            {
                spa.UseProxyToSpaDevelopmentServer($"http://localhost:{port}");
            }
        });
        app.Run();
}

Также обновите файл проекта, чтобы он знал про SPA режим для наглядности. Жёстких правил нет, используйте тот шаблон, что удобен и работает у вас. Полный текст:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
	  <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
	  <TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
	  <IsPackable>false</IsPackable>
	  <SpaRoot>ClientApp\</SpaRoot>
	  <DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes>
	  <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

	<ItemGroup>
		<!-- Don't publish the SPA source files, but do show them in the project files list -->
		<Compile Remove="logs\**" />
		<Content Remove="$(SpaRoot)**" />
		<Content Remove="logs\**" />
		<EmbeddedResource Remove="logs\**" />
		<None Remove="$(SpaRoot)**" />
		<None Remove="logs\**" />
		<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
	</ItemGroup>

	<ItemGroup>
		<Folder Include="ClientApp\" />
	</ItemGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="7.0.10" />
		<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.10" />
		<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
	</ItemGroup>
	
</Project>

И в заключении обновим launchSettings.json, чтобы в IIS Express верно поднималось наше приложение, помните что порты у вас будут свои.

{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "iisSettings": {
    "windowsAuthentication": true,
    "anonymousAuthentication": false,
    "iisExpress": {
      "applicationUrl": "http://localhost:24443",
      "sslPort": 0
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Проверяем, что всё собирается, и начинаем колдовать над фронтендовой частью! Убедитесь, что у вас установлен Node.js. Для данного проекта я использовал чуть устаревшую версию v16.20.2 (64-bit). Отдельно готов порекомендовать использовать утилиту NVM для быстрого переключения версий ноды для разных проектов! После создания всех необходимых (или просто привычных мне) файлов конфигураций в папке ClientApp появляется вот такая структура:

Дерево файлов фронтенда
Дерево файлов фронтенда

Разбор типичного SPA на Vue.Js останется за рамками этой статьи, я лишь приведу package.json с общей конфигурацией проекта и всеми необходимыми нам зависимостями.

{
   "name": "clientapp",
   "private": true,
   "version": "0.0.0",
   "type": "module",
   "scripts": {
      "dev": "vite --mode dev",
      "build": "vue-tsc && vite build --mode dev",
      "build:qa": "vue-tsc && vite build --mode qa",
      "build:prd": "vue-tsc && vite build --mode prd",
      "preview": "vite preview",
      "lint": "eslint --ext .ts,vue --ignore-path .gitignore .",
      "format": "prettier .  --write"
   },
   "dependencies": {
      "@rollup/plugin-inject": "^5.0.3",
      "axios": "^1.3.4",
      "eslint-config-prettier": "^8.7.0",
      "eslint-plugin-import": "^2.27.5",
      "lodash": "^4.17.21",
      "path": "^0.12.7",
      "pinia": "^2.0.33",
      "prettier": "^2.8.4",
      "ts-simple-nameof": "^1.3.1",
      "vue": "^3.2.45",
      "vue-router": "^4.1.6"
   },
   "devDependencies": {
      "@types/lodash": "^4.14.191",
      "@types/node": "^18.14.6",
      "@typescript-eslint/eslint-plugin": "^5.54.1",
      "@typescript-eslint/parser": "^5.54.1",
      "@vitejs/plugin-vue": "^4.0.0",
      "@vue/eslint-config-typescript": "^11.0.2",
      "eslint": "^8.35.0",
      "eslint-plugin-vue": "^9.9.0",
      "sass": "^1.58.3",
      "typescript": "^5.0.4",
      "vite": "^4.1.0",
      "vite-plugin-eslint": "^1.8.1",
      "vue-tsc": "^1.0.24"
   }
}

Все вопросы линтера, препроцессора CSS или prettier оставим за скобками. В env файлы кладем 2 переменные: VITE_NODE_ENV с описанием текущей среды и VITE_VUE_APP_BASE_URL с путем относительно корневого домена (иногда хочется, чтобы http://mysite.com/subdomain/ был корневым для роутинга и прочих запросов, а приложение будет считать таковым только домен верхнего уровня). По умолчанию тут просто /, так как мы будем всё запускать в отладке под localhost:4444. На проде может быть не так. Также настроим файл vite.config.ts, где укажем тот же самый порт 4444, который был задан в ASP.NET приложении. Остальные манипуляции сделаны ради корректной работы всех плагинов и опций и тоже скопированы с живых работающих приложений.

import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import eslintPlugin from 'vite-plugin-eslint';
import * as path from 'path';

export default ({ mode }) => {
   process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
   const basePath = process.env.VITE_VUE_APP_BASE_URL;
   return defineConfig({
      base: basePath, //for paths in dist files
      plugins: [vue(), eslintPlugin()],
      server: {
         port: 4444, //check port in ASP Core appsettings for debug
         strictPort: true,
      },
      resolve: {
         alias: [{ find: '@', replacement: path.resolve(__dirname, 'src') }],
      },
      optimizeDeps: {
         force: true,
      },
   });
};

Что ж, мы готовы запустить npm install в папке ClientApp и наконец приблизиться к цели статьи. После этого подготовим папку src. В подпапке Models опишем очень знакомый нам Product.ts по аналогии с моделью из .NET

export class Product {
   id: string;
   code: string;
   name: string;
   sortOrder: number;
   enabled: boolean;
   activationDate: Date;
}

Также создадим пока пустой файл App.vue и точку входа - main.ts

import { createApp } from 'vue';
import App from '@/App.vue';

const app = createApp(App);
app.mount('#app');

И не забудем упомянуть его в index.html

<!DOCTYPE html>
<html lang="en">
   <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>Test IAsyncEnumerable Application</title>
   </head>
   <body>
      <div id="app"></div>
      <script type="module" src="/src/main.ts"></script>
   </body>
</html>

Для удобства в папку utils я положил два файла. Для работы с путями в своих приложениях мне помогает baseUrl.ts, который как раз читает переменные из env:

const envBaseUrl = `${import.meta.env.VITE_VUE_APP_BASE_URL}`;
const basePath = envBaseUrl.endsWith('/') ? envBaseUrl : `${envBaseUrl}/`;
const currentMode = `${import.meta.env.VITE_NODE_ENV}`;

export { basePath, currentMode };

А для возвращения к "старым добрым" Deferred вместо нативного класса Promise я использую вот такое решение:

/* eslint-disable @typescript-eslint/no-empty-function */
export function getDeferred<T>() {
   let resolve: (value: T) => void = () => {};
   let reject: (error: Error) => void = () => {};
   const promise = new Promise<T>((res, rej) => {
      resolve = res;
      reject = rej;
   });
   return { promise, reject, resolve };
}

Для отправки запросов в классическом виде прекрасно подходит библиотека Axios, но, к сожалению, у них всё еще остаются проблемы с поддержкой полноценного получения данных как потока (stream). У меня не вышло "подружить" между собой такие данные, чтобы они считывались не все целиком и при этом типизировались Typescript-ом, тем не менее, для классического запроса мы будем использовать именно Axios. Создадим папку API и начнем её заполнять.

Мне бы хотелось сделать достаточно универсальный инструмент, который мог бы считывать любой поток типизированных данных и обрабатывать при этом ошибки, так что мы будем использовать generics по аналогии с C#. Поэтому давайте определим для работы с нашим IAsyncEnumerable новый класс в файле fetchFactory.ts. Он принимает в конструкторе параметр базового пути для запросов, предсоздает правильные заголовки и имеет единственный метод fetchGet, который по указанному относительному пути возвращает Promise<Responce> запроса. Для обработки данных этого запроса отдельно экспортируется функция EnableStreamReader, которая принимает Generic параметр T как тип возвращаемого элемента читаемой коллекции, на вход хочет тот самый Responce из предыдущего метода класса и функцию, в которую для обработки будут попадать считанные элементы. Мы не можем гарантировать считывание строго по 1, так что для удобства всегда ожидаем обработку коллекции.

Ознакомьтесь с полным кодом и дальше я разберу самые интересные моменты:

import { getDeferred } from '@/utils/deferred';

export class FetchFactory {
   private baseUrl: string;
   private requestHeaders: HeadersInit;

   constructor(baseUrl: string) {
      this.baseUrl = baseUrl;
      this.requestHeaders = new Headers();
      this.requestHeaders.set('Content-Type', 'application/json');
      this.requestHeaders.set('Access-Control-Allow-Credentials', 'true');
   }

   public fetchGet(url: string, params?: URLSearchParams, signal?: AbortSignal): Promise<Response> {
      let checkedUrl = url.startsWith('/') ? `${this.baseUrl}${url}` : `${this.baseUrl}/${url}`;
      checkedUrl = params === undefined ? checkedUrl : `${checkedUrl}?${params}`;
      return fetch(checkedUrl, {
         method: 'GET',
         headers: this.requestHeaders,
         credentials: 'include',
         signal: signal,
      });
   }
}

export async function EnableStreamReader<T>(
   apiResponse: Response,
   resultIterator: (newElems: T[]) => void,
): Promise<void> {
   const { promise, resolve, reject } = getDeferred<void>();
   const decoder = new TextDecoder();
   const reader = apiResponse.body?.getReader();
   if (reader != null) {
      let streamNotEnded = true;
      while (streamNotEnded) {
         const { done, value } = await reader.read(); //reading new batch
         streamNotEnded = !done;
         if (streamNotEnded) {
            const result = decoder.decode(value).replace(/^\s*\[|]\s*$/g, '').replace(/^\s*,/, ''); //convert bytes to text
            if (result != null) {
               const parsed = JSON.parse(`[${result}]`); //parse to array
               if (parsed != null) {
                  const casted = parsed as T[]; //cast to array of T
                  if (casted != null) {
                     resultIterator(parsed); //iterate on result
                  } else reject(new Error('Readed value is not a JSON of expected Type'));
               } else reject(new Error('Readed value is not a JSON object'));
            } else reject(new Error('Readed value is not a string'));
         }
      }
      reader.releaseLock();
      resolve();
   } else reject(new Error('Reader not ready'));

   return promise;
}

В целом, по шагам задача не самая сложная — пока нам есть что читать (await reader.read() не вернул в done завершение соединения), мы пытаемся декодировать данные. Так как мы посылаем их с ASP.NET в формате JSON, то и сюда они тоже попадают так, но... просто как будто разделенные ровно между элементами массива. При этом открывающая и закрывающая скобка массивов тоже передается в первом и последнем элементе соответственно. Вот пример чтения наших Product

Считанные данные по порциям
Считанные данные по порциям

Поэтому мы убираем символы квадратных скобок и запятой из считанного блока. Дальше пытаемся подставить эту строку в полноценный массив [...] и превратить через JSON.parse. Массив нам пригодится на случай, если там и правда считалось больше 1 элемента за раз (так будет, если снять Delay в серверной части), иначе момент парсинга может вызвать ошибку. Далее мы кастуем считанный массив к необходимому типу данных и, если всё хорошо, передаем в метод для дальнейшей обработки.

Теперь давайте создадим файл productApi.ts, куда подключим как нашу "читалку" стримов, так и AxiosInstance класс (его мы опишем в точке входа в API чуть ниже). Здесь у нас будет три метода - для загрузок классического и "потокового" варианта данных, а также метод отмены для наглядности. Реализацию отмены вы можете отдельно изучить, мне очень понравилось вот так вызывать abortController. Нам важно убедиться, чтобы относительные пути привели нас в контроллер ASP.NET так, как мы его настроили в серверной части.

import { Product } from '@/models/product';
import { AxiosInstance } from 'axios';
import { FetchFactory, EnableStreamReader } from '@/API/fetchFactory';

export class ProductAPI {
   constructor(httpRequest: AxiosInstance, asyncEnumerableRequest: FetchFactory) {
      this.httpRequest = httpRequest;
      this.asyncEnumerableRequest = asyncEnumerableRequest;
   }

   private httpRequest: AxiosInstance;
   private asyncEnumerableRequest: FetchFactory;
   private abortController: AbortController;

   async getProducts(onlyEnabled: boolean): Promise<Product[]> {
      this.cancel();
      const result = await this.httpRequest.get<Product[]>('product/GetList', {
         params: {
            onlyEnabled: onlyEnabled,
         },
         signal: this.abortController.signal,
      });
      return result.data;
   }

   async getProductStream(onlyEnabled: boolean, onReceive: (elem: Product[]) => void) {
      this.cancel();
      const params = new URLSearchParams();
      params.append('onlyEnabled', onlyEnabled.toString());
      const response = await this.asyncEnumerableRequest.fetchGet(
         'product/GetAsyncStream',
         params,
         this.abortController.signal,
      );
      await EnableStreamReader<Product>(response, onReceive);
   }

   cancel(): void {
      if (this.abortController) {
         this.abortController.abort();
      }
      this.abortController = new AbortController();
   }
}

Я предпочитаю, чтобы для работы с API была единая точка входа, так что давайте создадим apiService.ts, где зададим базовый путь, создадим экземпляр AxiosInstance и выставим наружу другим компонентам приложения удобный способ вызывать классы вроде productApi.

<script setup lang="ts">
import { ref } from 'vue';
import { API } from '@/API/apiService';
import { Product } from '@/models/product';

const list = ref<Product[]>([]);
const onlyEnabled = ref<boolean>(true);

async function getList() {
   list.value = [];
   const newValues = await API.product.getProducts(onlyEnabled.value);
   list.value = newValues;
}
async function getListByStream() {
   list.value = [];
   await API.product.getProductStream(onlyEnabled.value, (prodArr) => {
      if (prodArr != undefined && prodArr.length > 0) {
         list.value.push(...prodArr);
      }
   });
}
function cancelLoading() {
   API.product.cancel();
}
</script>
<template>
   <h3>Classic load - wait all items from backend before render it</h3>
   <h3>Stream load - render items when they are received from backend</h3>
   <h3>Cancel to stop</h3>
   <div class="panel">
      <input id="onlyEnabledChb" v-model="onlyEnabled" type="checkbox" />
      <label for="onlyEnabledChb"> only enabled? </label>
   </div>
   <div class="panel">
      <button @click="getList">Classic load</button>
      <button @click="getListByStream">Stream load</button>
      <button @click="cancelLoading">Cancel loading</button>
   </div>
   <div v-for="item in list" :key="item.id" class="product">
      <b>Name:</b> '{{ item.name }}' <b>Code:</b> '{{ item.code }}' <b>SortOrder:</b> '{{ item.sortOrder }}'
      <b>Enabled:</b> '{{ item.enabled }}'
   </div>
</template>
<style lang="scss" scoped>
.product {
   margin: 10px;
   text-align: center;
}

.panel {
   display: flex;
   flex-direction: row;
   flex-wrap: nowrap;
   justify-content: center;
   align-items: baseline;
}
.panel button {
   margin-left: 10px;
}
</style>

Теперь всё готово для написания кода страницы App.vue. В настоящем приложении тут был бы роутинг, разбиение на компоненты и прочее, но мы оставим всё в одном месте для удобства, простоты и наглядности. Я использовал свежий подход Vue 3 с Composition API, который действительно кажется удобнее чем предыдущие версии, но всё прекрасно заработает и на них. Опишем три кнопки для вызова трех наших методов API, чекбокс для параметра "только с Enabled == true" и коллекцию считанных продуктов. Список очищается при каждом запуске для наглядности. Как можно заметить, при чтении "стримом" мы передаем стрелочную функцию для обработки приходящих данных. При классическом чтении можно обработать всё только целиком.

<script setup lang="ts">
import { ref } from 'vue';
import { API } from '@/API/apiService';
import { Product } from '@/models/product';

const list = ref<Product[]>([]);
const onlyEnabled = ref<boolean>(true);

async function getList() {
   list.value = [];
   const newValues = await API.product.getProducts(onlyEnabled.value);
   list.value = newValues;
}
async function getListByStream() {
   list.value = [];
   await API.product.getProductStream(onlyEnabled.value, (prodArr) => {
      if (prodArr != undefined && prodArr.length > 0) {
         list.value.push(...prodArr);
      }
   });
}
function cancelLoading() {
   API.product.cancel();
}
</script>
<template>
   <h3>Classic load - wait all items from backend before render it</h3>
   <h3>Stream load - render items when they are received from backend</h3>
   <h3>Cancel to stop</h3>
   <div class="panel">
      <input id="onlyEnabledChb" v-model="onlyEnabled" type="checkbox" />
      <label for="onlyEnabledChb"> only enabled? </label>
   </div>
   <div class="panel">
      <button @click="getList">Classic load</button>
      <button @click="getListByStream">Stream load</button>
      <button @click="cancelLoading">Cancel loading</button>
   </div>
   <div v-for="item in list" :key="item.id" class="product">
      <b>Name:</b> '{{ item.name }}' <b>Code:</b> '{{ item.code }}' <b>SortOrder:</b> '{{ item.sortOrder }}'
      <b>Enabled:</b> '{{ item.enabled }}'
   </div>
</template>
<style lang="scss" scoped>
.product {
   margin: 10px;
   text-align: center;
}

.panel {
   display: flex;
   flex-direction: row;
   flex-wrap: nowrap;
   justify-content: center;
   align-items: baseline;
}
.panel button {
   margin-left: 10px;
}
</style>

Всё готово к тестовому запуску. Сначала в папке ClientApp вызовем npm run dev для запуска отладочного сервера на порту 4444, затем запустим в Visual Studio наше приложение в IIS Express. Через некоторое время они "подружатся" и у нас отобразится работающий интерфейс, в котором можно поэкспериментировать с загрузками.

Результат работы приложения
Результат работы приложения

Что ж, всё выглядит рабочим, но при запуске вы можете столкнуться с проблемой, что .NET всё равно отправляет "потоковый" код только целиком, нивелируя преимущества и скорость обмена. Оказалось, что дело в настройке буферизации ответа, которая может быть полезна при классической работе, но абсолютна вредоносна в нашем случае. Чтобы всё заработало как надо — просто добавим в вызов контроллера ASP такую строку

HttpContext?.Features?.Get<IHttpResponseBodyFeature>()?.DisableBuffering();

Таким образом я хотел показать вам как можно считывать IAsyncEnumerable коллекции порциями без сторонних библиотек в Typescript. Надеюсь эта статья окажется кому-то действительно полезной, спасибо за чтение и удачного кодинга как на бэкенде, так и на фронтенде всем! Таков путь :)

Ссылка на репозиторий с исходным кодом.

Комментарии (3)


  1. verych
    29.09.2023 09:13

    Главное - много картинок кода!
    Спасибо! Интересно было почитать о потоковой передаче данных между бэком и фронтом. ????


  1. sstv
    29.09.2023 09:13

    Раз и два. Во втором крутой кейс со стримингом через SignalR ;-)


    1. DanteLFC Автор
      29.09.2023 09:13

      Привет, спасибо за коммент. Да, тут очень коротко и понятно реализована отправка (гораздо короче, чем в статье), но главная идея, ради которой я решился написать, была именно привязка к Typescript-у, чтобы иметь какой-то универсальный инструмент для получения данных. А с SignalR-ом действительно неплохо так подружить попробовать =)