Обзор одностраничных приложений (SPAs) в ASP.NET Core

Примечание.

Это не последняя версия этой статьи. В текущем выпуске см . версию .NET 8 этой статьи.

Внимание

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

В текущем выпуске см . версию .NET 8 этой статьи.

Visual Studio предоставляет шаблоны проектов для создания одностраничных приложений (SPAs) на основе платформ JavaScript, таких как Angular, React и Vue с серверной частью ASP.NET Core. Эти шаблоны:

  • Создайте решение Visual Studio с интерфейсным проектом и серверным проектом.
  • Используйте тип проекта Visual Studio для JavaScript и TypeScript (esproj) для внешнего интерфейса.
  • Используйте проект ASP.NET Core для серверной части.

Проекты, созданные с помощью шаблонов Visual Studio, можно запускать из командной строки в Windows, Linux и macOS. Чтобы запустить приложение, используйте dotnet run --launch-profile https для запуска проекта сервера. Запуск проекта сервера автоматически запускает внешний сервер разработки JavaScript. Сейчас https требуется профиль запуска.

Учебники по Visual Studio

Чтобы приступить к работе, следуйте одному из учебников в документации по Visual Studio:

Дополнительные сведения см. в статье JavaScript и TypeScript в Visual Studio

ASP.NET основные шаблоны SPA

Visual Studio включает шаблоны для создания приложений ASP.NET Core с интерфейсом JavaScript или TypeScript. Эти шаблоны доступны в Visual Studio 2022 версии 17.8 или более поздней версии с установленной рабочей нагрузкой ASP.NET и веб-разработки .

Шаблоны Visual Studio для создания приложений ASP.NET Core с интерфейсом JavaScript или TypeScript предоставляют следующие преимущества:

  • Очистка разделения проектов для внешнего интерфейса и серверной части.
  • Будьте в курсе последних версий интерфейсной платформы.
  • Интеграция с новейшими средствами командной строки интерфейсной платформы, такими как Vite.
  • Шаблоны для JavaScript и TypeScript (только TypeScript для Angular).
  • Расширенные возможности редактирования кода JavaScript и TypeScript.
  • Интеграция средств сборки JavaScript с сборкой .NET.
  • Пользовательский интерфейс управления зависимостями npm.
  • Совместим с отладкой и запуском Visual Studio Code.
  • Запустите интерфейсные модульные тесты в тестовом Обозреватель с помощью платформ тестирования JavaScript.

Устаревшие шаблоны spa ASP.NET Core

В более ранних версиях пакета SDK для .NET добавлены устаревшие шаблоны для создания приложений SPA с ASP.NET Core. Документация по этим старым шаблонам см. в статье ASP.NET Core 7.0 обзора SPA и статьи Angular и React .

Архитектура шаблонов одностраничных приложений

Шаблоны одностраничных приложений (SPA) для Angular и React позволяют разрабатывать приложения Angular и React , размещенные на сервере серверной части .NET.

Во время публикации файлы приложения Angular и React копируются wwwroot в папку и обслуживаются через ПО промежуточного слоя статических файлов.

Вместо возврата HTTP 404 (Не найдено), резервный маршрут обрабатывает неизвестные запросы к серверной части и служит index.html для SPA.

Во время разработки приложение настроено на использование внешнего прокси-сервера. React и Angular используют тот же внешний прокси-сервер.

При запуске приложения страница index.html открывается в браузере. Специальное ПО промежуточного слоя, которое включено только в разработке:

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

Схема прокси-сервера браузера

Основное преимущество шаблонов SPA для ASP.NET Core предоставляют следующие преимущества:

  • Запускает прокси-сервер, если он еще не запущен.
  • Настройка HTTPS.
  • Настройка некоторых запросов для прокси-сервера серверной ASP.NET Core.

Когда браузер отправляет запрос на серверную конечную точку, например /weatherforecast в шаблонах. Прокси-сервер SPA получает запрос и отправляет его обратно на сервер прозрачно. Сервер отвечает, а прокси-сервер SPA отправляет запрос обратно в браузер:

Схема прокси-сервера

Опубликованные одностраничные приложения

Когда приложение опубликовано, SPA становится коллекцией файлов в папке wwwroot .

Для обслуживания приложения не требуется компонент среды выполнения:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();


app.MapControllerRoute(
    name: "default",
    pattern: "{controller}/{action=Index}/{id?}");

app.MapFallbackToFile("index.html");

app.Run();

В созданном выше шаблоне Program.cs файле:

  • app.UseStaticFiles позволяет обслуживать файлы.
  • app.MapFallbackToFile("index.html") включает обслуживание документа по умолчанию для любого неизвестного запроса, который получает сервер.

Когда приложение публикуется с помощью dotnet publish, следующие задачи в csproj файле гарантируют выполнение npm restore и выполнение соответствующего скрипта npm для создания рабочих артефактов:

  <Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
    <!-- Ensure Node.js is installed -->
    <Exec Command="node --version" ContinueOnError="true">
      <Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
    </Exec>
    <Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
    <Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
  </Target>

  <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
    <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />

    <!-- Include the newly-built files in the publish output -->
    <ItemGroup>
      <DistFiles Include="$(SpaRoot)build\**" />
      <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
        <RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
        <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
      </ResolvedFileToPublish>
    </ItemGroup>
  </Target>
</Project>

Разработка одностраничных приложений

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

<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>
    <SpaProxyServerUrl>https://localhost:44414</SpaProxyServerUrl>
    <SpaProxyLaunchCommand>npm start</SpaProxyLaunchCommand>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="7.0.1" />
  </ItemGroup>

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

  <Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
    <!-- Ensure Node.js is installed -->
    <Exec Command="node --version" ContinueOnError="true">
      <Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
    </Exec>
    <Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
    <Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
  </Target>

  <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
    <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />

    <!-- Include the newly-built files in the publish output -->
    <ItemGroup>
      <DistFiles Include="$(SpaRoot)build\**" />
      <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
        <RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
        <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
      </ResolvedFileToPublish>
    </ItemGroup>
  </Target>
</Project>
  • SpaProxyServerUrl: управляет URL-адресом, в котором сервер ожидает запуска прокси-сервера SPA. Это URL-адрес:
    • Сервер проверяет связь после запуска прокси-сервера, чтобы узнать, готов ли он.
    • Где он перенаправляет браузер после успешного ответа.
  • SpaProxyLaunchCommand: команда, в которой сервер используется для запуска прокси-сервера SPA, когда он обнаруживает, что прокси-сервер не запущен.

Пакет Microsoft.AspNetCore.SpaProxy отвечает за предыдущую логику для обнаружения прокси-сервера и перенаправления браузера.

Сборка запуска размещения, определенная в Properties/launchSettings.json , используется для автоматического добавления необходимых компонентов во время разработки, чтобы определить, запущен ли прокси-сервер и запустить его в противном случае:

{
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:51783",
      "sslPort": 44329
    }
  },
  "profiles": {
    "MyReact": {
      "commandName": "Project",
      "launchBrowser": true,
      "applicationUrl": "https://localhost:7145;http://localhost:5273",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
      }
    },
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
      }
    }
  }
}

Настройка клиентского приложения

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

Настройка Angular

Созданный ClientApp/package.json шаблон файла:

{
  "name": "myangular",
  "version": "0.0.0",
  "scripts": {
    "ng": "ng",
    "prestart": "node aspnetcore-https",
    "start": "run-script-os",
    "start:windows": "ng serve --port 44483 --ssl --ssl-cert \"%APPDATA%\\ASP.NET\\https\\%npm_package_name%.pem\" --ssl-key \"%APPDATA%\\ASP.NET\\https\\%npm_package_name%.key\"",
    "start:default": "ng serve --port 44483 --ssl --ssl-cert \"$HOME/.aspnet/https/${npm_package_name}.pem\" --ssl-key \"$HOME/.aspnet/https/${npm_package_name}.key\"",
    "build": "ng build",
    "build:ssr": "ng run MyAngular:server:dev",
    "watch": "ng build --watch --configuration development",
    "test": "ng test"
  },
  "private": true,
  "dependencies": {
    "@angular/animations": "^14.1.3",
    "@angular/common": "^14.1.3",
    "@angular/compiler": "^14.1.3",
    "@angular/core": "^14.1.3",
    "@angular/forms": "^14.1.3",
    "@angular/platform-browser": "^14.1.3",
    "@angular/platform-browser-dynamic": "^14.1.3",
    "@angular/platform-server": "^14.1.3",
    "@angular/router": "^14.1.3",
    "bootstrap": "^5.2.0",
    "jquery": "^3.6.0",
    "oidc-client": "^1.11.5",
    "popper.js": "^1.16.0",
    "run-script-os": "^1.1.6",
    "rxjs": "~7.5.6",
    "tslib": "^2.4.0",
    "zone.js": "~0.11.8"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "^14.1.3",
    "@angular/cli": "^14.1.3",
    "@angular/compiler-cli": "^14.1.3",
    "@types/jasmine": "~4.3.0",
    "@types/jasminewd2": "~2.0.10",
    "@types/node": "^18.7.11",
    "jasmine-core": "~4.3.0",
    "karma": "~6.4.0",
    "karma-chrome-launcher": "~3.1.1",
    "karma-coverage": "~2.2.0",
    "karma-jasmine": "~5.1.0",
    "karma-jasmine-html-reporter": "^2.0.0",
    "typescript": "~4.7.4"
  },
  "overrides": {
    "autoprefixer": "10.4.5"
  },
  "optionalDependencies": {}
}
  • Содержит скрипты, запускающие сервер разработки angular:

  • Скрипт prestart вызывается ClientApp/aspnetcore-https.js, который отвечает за обеспечение доступности HTTPS-сертификата сервера разработки для прокси-сервера SPA.

  • И start:windowsstart:default:

    • Запустите сервер разработки Angular с помощью ng serve.
    • Укажите порт, параметры для использования HTTPS и путь к сертификату и связанному ключу. Номер порта соответствует номеру порта, указанному .csproj в файле.

Созданный шаблон ClientApp/angular.json содержит:

  • Команда serve.

  • Элемент proxyconfig в development конфигурации, указывающий, что proxy.conf.js следует использовать для настройки внешнего прокси-сервера, как показано в следующем выделенном JSon:

    {
      "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
      "version": 1,
      "newProjectRoot": "projects",
      "projects": {
        "MyAngular": {
          "projectType": "application",
          "schematics": {
            "@schematics/angular:application": {
              "strict": true
            }
          },
          "root": "",
          "sourceRoot": "src",
          "prefix": "app",
          "architect": {
            "build": {
              "builder": "@angular-devkit/build-angular:browser",
              "options": {
                "progress": false,
                "outputPath": "dist",
                "index": "src/index.html",
                "main": "src/main.ts",
                "polyfills": "src/polyfills.ts",
                "tsConfig": "tsconfig.app.json",
                "allowedCommonJsDependencies": [
                  "oidc-client"
                ],
                "assets": [
                  "src/assets"
                ],
                "styles": [
                  "node_modules/bootstrap/dist/css/bootstrap.min.css",
                  "src/styles.css"
                ],
                "scripts": []
              },
              "configurations": {
                "production": {
                  "budgets": [
                    {
                      "type": "initial",
                      "maximumWarning": "500kb",
                      "maximumError": "1mb"
                    },
                    {
                      "type": "anyComponentStyle",
                      "maximumWarning": "2kb",
                      "maximumError": "4kb"
                    }
                  ],
                  "fileReplacements": [
                    {
                      "replace": "src/environments/environment.ts",
                      "with": "src/environments/environment.prod.ts"
                    }
                  ],
                  "outputHashing": "all"
                },
                "development": {
                  "buildOptimizer": false,
                  "optimization": false,
                  "vendorChunk": true,
                  "extractLicenses": false,
                  "sourceMap": true,
                  "namedChunks": true
                }
              },
              "defaultConfiguration": "production"
            },
            "serve": {
              "builder": "@angular-devkit/build-angular:dev-server",
              "configurations": {
                "production": {
                  "browserTarget": "MyAngular:build:production"
                },
                "development": {
                  "browserTarget": "MyAngular:build:development",
                  "proxyConfig": "proxy.conf.js"
                }
              },
              "defaultConfiguration": "development"
            },
            "extract-i18n": {
              "builder": "@angular-devkit/build-angular:extract-i18n",
              "options": {
                "browserTarget": "MyAngular:build"
              }
            },
            "test": {
              "builder": "@angular-devkit/build-angular:karma",
              "options": {
                "main": "src/test.ts",
                "polyfills": "src/polyfills.ts",
                "tsConfig": "tsconfig.spec.json",
                "karmaConfig": "karma.conf.js",
                "assets": [
                  "src/assets"
                ],
                "styles": [
                  "src/styles.css"
                ],
                "scripts": []
              }
            },
            "server": {
              "builder": "@angular-devkit/build-angular:server",
              "options": {
                "outputPath": "dist-server",
                "main": "src/main.ts",
                "tsConfig": "tsconfig.server.json"
              },
              "configurations": {
                "dev": {
                  "optimization": true,
                  "outputHashing": "all",
                  "sourceMap": false,
                  "namedChunks": false,
                  "extractLicenses": true,
                  "vendorChunk": true
                },
                "production": {
                  "optimization": true,
                  "outputHashing": "all",
                  "sourceMap": false,
                  "namedChunks": false,
                  "extractLicenses": true,
                  "vendorChunk": false
                }
              }
            }
          }
        }
      },
      "defaultProject": "MyAngular"
    }
    

ClientApp/proxy.conf.js определяет маршруты, которые должны быть проксированы обратно на серверную часть сервера. Общий набор параметров определяется в по промежуточном слоях http-proxy для react и angular, так как они оба используют один и тот же прокси-сервер.

Следующий выделенный код использует ClientApp/proxy.conf.js логику на основе переменных среды, заданных во время разработки, чтобы определить порт, на котором выполняется серверная часть:

const { env } = require('process');

const target = env.ASPNETCORE_HTTPS_PORTS ? `https://localhost:${env.ASPNETCORE_HTTPS_PORTS}` :
  env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'http://localhost:51951';

const PROXY_CONFIG = [
  {
    context: [
      "/weatherforecast",
   ],
    target: target,
    secure: false,
    headers: {
      Connection: 'Keep-Alive'
    }
  }
]

module.exports = PROXY_CONFIG;

Настройка React

  • Раздел package.json "Скрипты" содержит следующие скрипты, запускающие приложение React во время разработки, как показано в следующем выделенном коде:

    {
      "name": "myreact",
      "version": "0.1.0",
      "private": true,
      "dependencies": {
        "bootstrap": "^5.2.0",
        "http-proxy-middleware": "^2.0.6",
        "jquery": "^3.6.0",
        "merge": "^2.1.1",
        "oidc-client": "^1.11.5",
        "react": "^18.2.0",
        "react-dom": "^18.2.0",
        "react-router-bootstrap": "^0.26.2",
        "react-router-dom": "^6.3.0",
        "react-scripts": "^5.0.1",
        "reactstrap": "^9.1.3",
        "rimraf": "^3.0.2",
        "web-vitals": "^2.1.4",
        "workbox-background-sync": "^6.5.4",
        "workbox-broadcast-update": "^6.5.4",
        "workbox-cacheable-response": "^6.5.4",
        "workbox-core": "^6.5.4",
        "workbox-expiration": "^6.5.4",
        "workbox-google-analytics": "^6.5.4",
        "workbox-navigation-preload": "^6.5.4",
        "workbox-precaching": "^6.5.4",
        "workbox-range-requests": "^6.5.4",
        "workbox-routing": "^6.5.4",
        "workbox-strategies": "^6.5.4",
        "workbox-streams": "^6.5.4"
      },
      "devDependencies": {
        "ajv": "^8.11.0",
        "cross-env": "^7.0.3",
        "eslint": "^8.22.0",
        "eslint-config-react-app": "^7.0.1",
        "eslint-plugin-flowtype": "^8.0.3",
        "eslint-plugin-import": "^2.26.0",
        "eslint-plugin-jsx-a11y": "^6.6.1",
        "eslint-plugin-react": "^7.30.1",
        "nan": "^2.16.0",
        "typescript": "^4.7.4"
      },
      "overrides": {
        "autoprefixer": "10.4.5"
      },
      "resolutions": {
        "css-what": "^5.0.1",
        "nth-check": "^3.0.1"
      },
      "scripts": {
        "prestart": "node aspnetcore-https && node aspnetcore-react",
        "start": "rimraf ./build && react-scripts start",
        "build": "react-scripts build",
        "test": "cross-env CI=true react-scripts test --env=jsdom",
        "eject": "react-scripts eject",
        "lint": "eslint ./src/"
      },
      "eslintConfig": {
        "extends": [
          "react-app"
        ]
      },
      "browserslist": {
        "production": [
          ">0.2%",
          "not dead",
          "not op_mini all"
        ],
        "development": [
          "last 1 chrome version",
          "last 1 firefox version",
          "last 1 safari version"
        ]
      }
    }
    
  • Скрипт prestart вызывает:

    • aspnetcore-https.js, который отвечает за обеспечение доступности сертификата HTTPS сервера разработки для прокси-сервера SPA.
    • aspnetcore-react.js Вызывается для настройки соответствующего .env.development.local файла для использования локального сертификата разработки HTTPS. aspnetcore-react.js настраивает сертификат локальной разработки HTTPS, добавив SSL_CRT_FILE=<certificate-path> и SSL_KEY_FILE=<key-path> в файл.
  • Файл .env.development определяет порт для сервера разработки и задает ПРОТОКОЛ HTTPS.

Прокси-сервер src/setupProxy.js SPA настраивает перенаправление запросов на серверную часть. Общий набор параметров определяется в ПО промежуточного слоя http-proxy-.

Следующий выделенный код использует ClientApp/src/setupProxy.js логику на основе переменных среды, заданных во время разработки, чтобы определить порт, на котором выполняется серверная часть:

const { createProxyMiddleware } = require('http-proxy-middleware');
const { env } = require('process');

const target = env.ASPNETCORE_HTTPS_PORTS ? `https://localhost:${env.ASPNETCORE_HTTPS_PORTS}` :
  env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'http://localhost:51783';

const context = [
  "/weatherforecast",
];

const onError = (err, req, resp, target) => {
    console.error(`${err.message}`);
}

module.exports = function (app) {
  const appProxy = createProxyMiddleware(context, {
    target: target,
    // Handle errors to prevent the proxy middleware from crashing when
    // the ASP NET Core webserver is unavailable
    onError: onError,
    secure: false,
    // Uncomment this line to add support for proxying websockets
    //ws: true, 
    headers: {
      Connection: 'Keep-Alive'
    }
  });

  app.use(appProxy);
};

Поддерживаемая версия платформы SPA в шаблонах SPA ASP.NET Core

Шаблоны проектов SPA, которые поставляются с каждым выпуском ASP.NET Core, ссылаются на последнюю версию соответствующей платформы SPA.

Платформы SPA обычно имеют более короткий цикл выпуска, чем .NET. Из-за двух разных циклов выпуска поддерживаемая версия платформы SPA и .NET может выйти из синхронизации: основная версия платформы SPA, от которой зависит основной выпуск .NET, может выйти из поддержки, в то время как версия .NET, отправленная на платформу SPA, по-прежнему поддерживается.

Шаблоны spa ASP.NET Core можно обновить в выпуске исправлений до новой версии spa framework, чтобы шаблоны сохранялись в поддерживаемом и безопасном состоянии.

Дополнительные ресурсы