Dart Native Assets: Полное руководство от новичка до профи

от автора

Для быстрого ознакомления:

  • Что такое Native Assets — объяснение для новичков

  • История и эволюция

  • Архитектура системы

  • Build Hooks — сердце системы

  • Практические примеры

  • Продвинутые концепции

  • Лучшие практики

  • Troubleshooting и отладка

  • Экосистема и дальнейшее развитие

  • Реальные кейсы использования

  • Производительность и оптимизация

Что такое Native Assets — объяснение для новичков

Простыми словами

Представьте, что у вас есть Dart-программа, и вы хотите использовать готовую библиотеку, написанную на C, C++, Rust или другом языке. Раньше это было сложно — нужно было вручную компилировать библиотеку, следить за тем, чтобы она попала в нужное место, и писать много дополнительного кода.

Native Assets — это система, которая автоматизирует весь этот процесс. Она позволяет вашему Dart-пакету «включать в себя» нативный код и автоматически его компилировать и подключать.

Аналогия из жизни

Это как заказ еды с доставкой:

  • Раньше: Вы сами покупали продукты, готовили, мыли посуду.

  • С Native Assets: Вы просто говорите «хочу пиццу», а система сама заказывает, доставляет и даже убирает за собой.

Техническое определение

Native Assets — это официальная система в Dart, которая позволяет пакетам содержать не только Dart-код, но и исходный код на других языках (C, C++, Rust и др.). Система автоматически управляет сборкой этого нативного кода в динамические библиотеки (.so, .dll, .dylib), их упаковкой в приложение и обеспечивает прозрачный доступ к ним из Dart-кода во время выполнения через FFI (Foreign Function Interface).

История и эволюция

До Native Assets (темные времена)

Разработчик хочет использовать C-библиотеку:

  1. Вручную компилирует библиотеку для каждой платформы.

  2. Копирует .so/.dll/.dylib файлы в правильные места.

  3. Пишет FFI-биндинги.

  4. Молится, чтобы все работало на разных устройствах.

  5. Повторяет все это при каждом обновлении.

С Native Assets (светлое будущее)

Разработчик хочет использовать C-библиотеку:

  1. Добавляет зависимость в pubspec.yaml.

  2. Описывает процесс сборки в hook/build.dart (один раз).

  3. Система автоматически все компилирует и подключает.

  4. Работает везде «из коробки».

Timeline развития

  • Dart 3.2: Появилась первая версия Native Assets за экспериментальным флагом --enable-experiment=native-assets.

  • Dart 3.4 (Май 2024): Стабилизация Native Assets. Функция стала доступна по умолчанию, флаг больше не требуется.

  • Flutter 3.22 (Май 2024): Интеграция Native Assets во Flutter. Система стала официально поддерживаться для сборки приложений на всех платформах.

Архитектура системы

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐ │   Dart Package  │───▶│   Build Hooks   │───▶│  Native Assets  │ │                 │    │                 │    │                 │ │ - pubspec.yaml  │    │ - hook/build.dart│   │ - .so/.dll/.dylib│ │ - lib/*.dart    │    │                  │   │ - metadata.json │ │ - src/*.c       │    │                  │   │                 │ └─────────────────┘    └─────────────────┘    └─────────────────┘ 
  1. Package Structure

    my_native_package/ ├── pubspec.yaml          # Зависимости и конфигурация пакета ├── lib/ │   └── my_package.dart   # Dart API для взаимодействия с нативным кодом ├── src/ │   ├── my_lib.c          # Нативный исходный код │   └── my_lib.h          # Заголовочные файлы └── hook/     └── build.dart        # Скрипт-инструкция по сборке нативного кода 
  2. Build System Flow

    1. dart pub get или flutter build обнаруживает наличие hook/build.dart.

    2. Запускает этот скрипт (build hook) в изолированной среде для каждой целевой платформы.

    3. Hook компилирует нативный код, используя системные компиляторы (GCC, Clang, MSVC) или специализированные инструменты (Cargo, CMake).

    4. Hook генерирует метаданные о созданных ассетах (библиотеках).

    5. Система сборки упаковывает эти ассеты вместе с приложением.

    6. Dart-код может использовать FFI для вызова нативных функций, при этом загрузка библиотеки происходит автоматически.

Build Hooks — сердце системы

Что такое Build Hook

Build Hook — это специальный Dart-скрипт (hook/build.dart), который говорит системе сборки:

  • Какой нативный код нужно скомпилировать.

  • Как именно его компилировать (какие флаги, зависимости).

  • Где разместить результат.

Простой пример hook/build.dart (для демонстрации)

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

// hook/build.dart import 'package:native_assets_cli/native_assets_cli.dart'; import 'dart:io';  void main(List<String> args) async {   await build(args, (config, output) async {     // Определяем, что мы хотим скомпилировать     final packageName = config.packageName;     final sourceFile = config.packageRoot.resolve('src/my_lib.c');     final outDir = config.outputDirectory;      // Имя библиотеки зависит от платформы     String libName;     if (config.targetOS == OS.windows) {       libName = 'my_lib.dll';     } else if (config.targetOS == OS.macOS) {       libName = 'libmy_lib.dylib';     } else {       libName = 'libmy_lib.so';     }     final outFile = outDir.resolve(libName);      // Компилируем C код в динамическую библиотеку     final result = await Process.run(       'gcc',       [         '-shared',         '-fPIC',         '-o',         outFile.toFilePath(),         sourceFile.toFilePath(),       ],     );      if (result.exitCode != 0) {       throw Exception('Compilation failed: ${result.stderr}');     }      // Сообщаем системе о созданной библиотеке     output.addAsset(NativeCodeAsset(       package: packageName,       name: 'src/my_lib.c', // Имя ассета должно соответствовать пути к исходнику       file: outFile,       linkMode: LinkMode.dynamic,       os: config.targetOS,       architecture: config.targetArchitecture,     ));   }); } 

Продвинутый и рекомендуемый пример с native_toolchain_c

Этот пакет предоставляет удобный CBuilder для кроссплатформенной компиляции.

// hook/build.dart import 'package:native_assets_cli/native_assets_cli.dart'; import 'package:native_toolchain_c/native_toolchain_c.dart';  void main(List<String> args) async {   await build(args, (config, output) async {     final cbuilder = CBuilder.library(       name: 'my_complex_lib',       assetName: 'path/to/my_lib.dart', // Путь к Dart файлу, который будет использовать библиотеку       sources: [         'src/core.c',         'src/utils.c',       ],       includes: [         'src/include/',       ],       defines: {         'VERSION': '1.0.0',         'DEBUG': config.buildMode == BuildMode.debug ? '1' : '0',       },     );      await cbuilder.run(       buildConfig: config,       buildOutput: output,       // Для отладки можно добавить логгер       // logger: Logger()..level = Level.all,     );   }); } 

Практические примеры

Пример 1: Простая математическая библиотека

Структура проекта

math_native/ ├── pubspec.yaml ├── lib/ │   └── math_native.dart ├── src/ │   ├── math_ops.c │   └── math_ops.h └── hook/     └── build.dart 

C-код (src/math_ops.c) (C нет в редакторе хабра в выпадающем списке языков, поставил С++)

// src/math_ops.c #include "math_ops.h"  // Простой итеративный Фибоначчи int fibonacci(int n) {     if (n <= 1) return n;     int a = 0, b = 1, c;     for (int i = 2; i <= n; i++) {         c = a + b;         a = b;         b = c;     }     return b; } 

Заголовочный файл (src/math_ops.h)

// src/math_ops.h #ifndef MATH_OPS_H #define MATH_OPS_H  // Dart FFI требует явного указания видимости для Windows #if defined(_WIN32) #define API __declspec(dllexport) #else #define API #endif  API int fibonacci(int n);  #endif 

Build Hook (hook/build.dart)

// hook/build.dart // Примечание: для этого кода нужно добавить пакет native_toolchain_rust import 'package.native_assets_cli/native_assets_cli.dart'; import 'package.native_toolchain_rust/native_toolchain_rust.dart';  void main(List<String> args) async {   await build(args, (config, output) async {     // Создаем сборщик для Rust-библиотеки     final rustBuilder = RustBuilder.library(       name: 'string_processor', // Имя вашего пакета из файла Cargo.toml       // Указываем Dart-файл, который будет использовать эту библиотеку       assetName: 'package:имя_вашего_пакета/имя_dart_файла.dart',     );      // Запускаем сборку     await rustBuilder.run(       buildConfig: config,       buildOutput: output,     );   }); }

Dart API (lib/math_native.dart)

// lib/math_native.dart import 'dart:ffi';  // Аннотация @Native указывает FFI на имя нативной функции. // Система сборки Native Assets автоматически найдет и загрузит // нужную библиотеку, скомпилированную для текущей платформы. @Native<Int32 Function(Int32)>('fibonacci') external int _fibonacci(int n);  /// Вычисление числа Фибоначчи с использованием нативной реализации. int fibonacci(int n) => _fibonacci(n); 

pubspec.yaml

name: math_native description: Fast native math operations. version: 1.0.0 publish_to: 'none' # Для локального примера  environment:   sdk: '>=3.4.0 <4.0.0'  dependencies:   ffi: ^2.1.0  # Пакеты для сборки теперь не нужно указывать, # Dart SDK находит и использует их автоматически. # dev_dependencies: #   native_assets_cli: ... #   native_toolchain_c: ... 

Пример 2: Интеграция с Rust

Rust-код (src/lib.rs)

// src/lib.rs use std::ffi::{CStr, CString}; use std::os::raw::c_char;  #[no_mangle] pub extern "C" fn process_string(input: *const c_char) -> *mut c_char {     let c_str = unsafe { CStr::from_ptr(input) };     let rust_string = c_str.to_string_lossy();      // Обработка строки в Rust (реверс и верхний регистр)     let processed = rust_string.chars().rev().collect::<String>().to_uppercase();      let c_string = CString::new(processed).unwrap();     c_string.into_raw() }  #[no_mangle] pub extern "C" fn free_string(ptr: *mut c_char) {     if !ptr.is_null() {         unsafe {             // Восстанавливаем CString из указателя и освобождаем память             let _ = CString::from_raw(ptr);         };     } } 

Cargo.toml

Ini, TOML (TOML нет в редакторе хабра в выпадающем списке языков)

[package] name = "string_processor" version = "0.1.0" edition = "2021"  [lib] crate-type = ["cdylib"] 

Build Hook для Rust (hook/build.dart)

// hook/build.dart import 'dart:io'; import 'package:native_assets_cli/native_assets_cli.dart';  void main(List<String> args) async {   await build(args, (config, output) async {     // Вызов Cargo для сборки Rust проекта     final result = await Process.run(       'cargo',       ['build', '--release'],       workingDirectory: config.packageRoot.toFilePath(),     );      if (result.exitCode != 0) {       throw Exception('Rust build failed: ${result.stderr}');     }      // Находим путь к скомпилированной библиотеке     // ... логика поиска .dll/.so/.dylib в target/release/ ...     // Для простоты примера, опустим эту часть      // Добавляем ассет в вывод (в реальном коде нужно найти файл)     // output.addAsset(...)   }); } // Примечание: для Rust существуют более удобные пакеты-обертки, // такие как `native_toolchain_rust`, которые автоматизируют этот процесс. 

Продвинутые концепции

1. Условная компиляция

Build hook может анализировать конфигурацию сборки (config) и включать разные исходники или флаги.

// hook/build.dart void main(List<String> args) async {   await build(args, (config, output) async {     final sources = <String>['src/core.c'];     final defines = <String, String>{};      // Платформо-зависимый код     switch (config.targetOS) {       case OS.windows:         sources.add('src/platform/windows.c');         defines['PLATFORM_WINDOWS'] = '1';         break;       case OS.linux:         sources.add('src/platform/linux.c');         defines['PLATFORM_LINUX'] = '1';         break;       case OS.macOS:         sources.add('src/platform/macos.c');         defines['PLATFORM_MACOS'] = '1';         break;       default:         // Обработка неподдерживаемых платформ     }      final cbuilder = CBuilder.library(       name: 'cross_platform_lib',       assetName: 'package:my_package/my_package.dart',       sources: sources,       defines: defines,     );      await cbuilder.run(buildConfig: config, buildOutput: output);   }); } 

2. Управление зависимостями

В build hook можно скачивать и компилировать сторонние библиотеки.

// hook/build.dart // (Концептуальный пример) void main(List<String> args) async {   await build(args, (config, output) async {     final depsDir = config.outputDirectory.resolve('deps');      // Скачиваем и распаковываем зависимость (например, libjpeg)     if (!await Directory.fromUri(depsDir).exists()) {       await downloadAndExtract(         url: 'https://example.com/libjpeg.tar.gz',         destination: depsDir,       );       // Запускаем ./configure && make для сборки зависимости       await buildDependency(depsDir);     }      final cbuilder = CBuilder.library(       name: 'my_image_lib',       assetName: 'package:my_package/my_package.dart',       sources: ['src/image_utils.c'],       includes: [         'src/',         depsDir.resolve('include').path,       ],       // Линкуемся со статически собранной зависимостью       libraries: [         depsDir.resolve('lib/libjpeg.a').path,       ],     );      await cbuilder.run(buildConfig: config, buildOutput: output);   }); } 

Вспомогательные функции downloadAndExtract и buildDependency должны быть реализованы отдельно.

Лучшие практики

1. Структура проекта

Хорошо организованный проект облегчает поддержку.

my_native_package/ ├── pubspec.yaml ├── README.md ├── lib/ │   ├── my_package.dart          # Публичный Dart API │   └── src/ │       ├── bindings.dart        # FFI-биндинги (@Native) │       └── exceptions.dart      # Кастомные исключения ├── src/                         # Нативный код │   ├── core/ │   │   ├── api.h                # Публичный C API │   │   └── api.c │   └── platform/                # Платформо-специфичный код └── hook/     └── build.dart               # Build hook 

2. Обработка ошибок в Build Hooks

Build hook должен быть надежным и предоставлять понятные сообщения об ошибках.

// hook/build.dart void main(List<String> args) async {   await build(args, (config, output) async {     try {       // Проверяем наличие необходимых инструментов (например, CMake)       await _ensureToolExists('cmake');        final cbuilder = CBuilder.library(...);       await cbuilder.run(         buildConfig: config,         buildOutput: output,         logger: Logger.root, // Включаем логирование       );      } on ToolNotFoundException catch (e) {       print('Ошибка сборки: ${e.message}');       print('Рекомендация: Установите ${e.toolName} и добавьте в PATH.');       rethrow;     } catch (e, stackTrace) {       print('Непредвиденная ошибка во время сборки: $e');       print('Стек вызовов: $stackTrace');       rethrow;     }   }); } // Вспомогательная функция _ensureToolExists и исключение ToolNotFoundException 

3. Кроссплатформенная совместимость в Dart

Правильный подход: С Native Assets вам не нужно писать код для загрузки библиотек под разные платформы в Dart. Эта логика полностью находится в hook/build.dart. Dart-код остается чистым и платформо-независимым.

Устаревший подход (НЕ ИСПОЛЬЗОВАТЬ С NATIVE ASSETS):

// НЕПРАВИЛЬНО: Ручная загрузка библиотеки if (Platform.isWindows) {   DynamicLibrary.open('my_lib.dll'); } // ... и т.д. 

Правильный Dart-код:

// lib/src/bindings.dart import 'dart:ffi';  // Этот код будет работать на Windows, macOS, Linux, Android и iOS // без каких-либо изменений. @Native<Int32 Function(Int32)>('my_function') external int _myFunction(int value);  class MyLib {   static int myFunction(int value) {     try {       return _myFunction(value);     } catch (e) {       // Можно обернуть ошибку FFI в кастомное исключение       throw NativeException('Failed to call my_function: $e');     }   } }  class NativeException implements Exception {   final String message;   NativeException(this.message); } 

4. Тестирование Native Assets

Тесты должны проверять как успешное выполнение, так и обработку ошибок.

// test/native_test.dart import 'package:test/test.dart'; import 'package:math_native/math_native.dart'; // Импортируем наш пакет  void main() {   group('Native Fibonacci Tests', () {     test('should return correct values for base cases', () {       expect(fibonacci(0), 0);       expect(fibonacci(1), 1);     });      test('should calculate fibonacci correctly for a small number', () {       expect(fibonacci(10), 55);     });      // Можно добавить тесты на производительность     test('should execute within reasonable time', () {       final stopwatch = Stopwatch()..start();       fibonacci(40); // Достаточно большая нагрузка       stopwatch.stop();       expect(stopwatch.elapsedMilliseconds, lessThan(100));     });   }); } 

Troubleshooting и отладка

Частые проблемы и решения

  1. Сборка падает с непонятной ошибкой

    • Решение: Запустите сборку с максимальной детализацией логов: flutter build <platform> -v. Ищите ошибки от компилятора (Clang, GCC, MSVC) или от вашего build hook.

  2. UnsatisfiedLinkError или Lookup failed

    • Причина: Dart FFI не может найти нативную функцию.

    • Решение:

      • Проверьте, что имя функции в аннотации @Native<...> ('my_function') точно совпадает с именем в нативном коде.

      • Для C++ убедитесь, что функция обернута в extern "C".

      • Для Windows убедитесь, что функция экспортируется с помощью __declspec(dllexport).

      • Проверьте assetName в CBuilder.library() в hook/build.dart. Он должен указывать на Dart-файл, где используется @Native.

  3. Build Hook не выполняется

    • Проверьте:

      • Файл точно называется build.dart и находится в папке hook в корне пакета.

      • Выполните dart pub get или flutter clean и flutter pub get, чтобы система сборки заново обнаружила hook (горячая перезагрузка здесь не работает).

Экосистема и дальнейшее развитие

Текущие возможности (не будущее)

  • Декларативные биндинги: Аннотация @Native<...> является основным способом связывания с нативным кодом.

  • Интеграция с Flutter: Native Assets полностью поддерживаются во Flutter для сборки на Android, iOS, Windows, macOS и Linux.

  • Интеграция с IDE: IDE (VS Code, Android Studio) предоставляют базовую поддержку, включая подсветку синтаксиса и возможность отладки Dart-кода. Прямой переход к нативному коду пока ограничен.

  • Сторонние инструменты: Сообщество активно развивает пакеты-обертки для популярных систем сборки (native_toolchain_c, native_toolchain_cmake, native_toolchain_rust), которые значительно упрощают написание build-хуков.

Планируемые улучшения

  • Генерация биндингов: Инструменты, такие как package:ffigen, адаптируются для работы с Native Assets, что позволит автоматически генерировать Dart-код из C/C++ заголовочных файлов.

  • Улучшенная отладка: Работа над возможностью бесшовной отладки с переходом между Dart и нативным кодом.

  • Поддержка WebAssembly (Wasm): Интеграция с Wasm позволит использовать тот же нативный код (скомпилированный в Wasm) в веб-приложениях.

Реальные кейсы использования

Кейс 1: Высокопроизводительная обработка изображений

Проблема: Приложение для обработки фотографий работало медленно из-за операций над пикселями в Dart-коде.

Решение с Native Assets: Вынести ресурсоемкие алгоритмы (фильтры, размытие) в C/C++ и вызывать их через FFI.

Dart-код (c корректным FFI):

// lib/image_processor.dart import 'dart:ffi'; import 'dart:typed_data'; import 'package:ffi/ffi.dart';  @Native<Void Function(Pointer<Uint8>, Int32, Int32, Double)>('apply_sepia_filter') external void _applySepiaFilter(Pointer<Uint8> imageData, int width, int height, double intensity);  void applySepiaFilter(Uint8List imageData, int width, int height, {double intensity = 1.0}) {   // Выделяем память в нативной куче   final pointer = calloc<Uint8>(imageData.length);   // Копируем данные из Dart-массива в нативную память   pointer.asTypedList(imageData.length).setAll(0, imageData);    try {     // Вызываем быструю нативную функцию     _applySepiaFilter(pointer, width, height, intensity);     // Копируем результат обратно в Dart-массив     imageData.setAll(0, pointer.asTypedList(imageData.length));   } finally {     // Обязательно освобождаем память     calloc.free(pointer);   } } 

Нативный код для фильтра apply_sepia_filter пишется на C.

Результат: Обработка изображений становится в теории в 10-15 раз быстрее, но у меня ускорение в пределах от 3 до 8 раз, если кто-то в курсе что я делаю не так — пожалуйста прокоментируйте.

Кейс 2: Интеграция с существующей C++ библиотекой

Проблема: Есть готовая, протестированная C++ библиотека для работы с 3D-геометрией, которую нужно использовать в Dart-приложении.

Решение: Создать тонкую C-обертку (wrapper) для C++ кода и подключить ее через Native Assets.

Обертка C API (src/geometry_wrapper.cpp)

#include "geometry_lib.hpp" // Существующая C++ библиотека  // extern "C" отключает C++ name mangling, делая функции видимыми для C FFI extern "C" {     API double calculate_distance(Point3D* p1, Point3D* p2) {         // Кастуем указатели к C++ типам и вызываем C++ код         auto* point1 = reinterpret_cast<geometry::Point3D*>(p1);         auto* point2 = reinterpret_cast<geometry::Point3D*>(p2);         return geometry::distance(*point1, *point2);     } } 

Build hook для этого будет компилировать C++ файлы с помощью g++ или clang++.

Производительность и оптимизация

Benchmarking Native Assets

Используйте package:benchmark_harness для сравнения производительности Dart и нативной реализации.

// benchmark/performance_test.dart import 'package:benchmark_harness/benchmark_harness.dart'; import 'package:my_native_package/my_native_package.dart'; // Наш пакет  // Benchmark для нативной реализации class NativeBenchmark extends BenchmarkBase {   NativeBenchmark() : super('MatrixMultiplication.Native');   @override   void run() => nativeMatrixMultiply(); // вызов нативной функции }  // Benchmark для Dart-реализации class DartBenchmark extends BenchmarkBase {   DartBenchmark() : super('MatrixMultiplication.Dart');   @override   void run() => dartMatrixMultiply(); // вызов Dart-функции }  void main() {   NativeBenchmark().report();   DartBenchmark().report(); } 

Оптимизация Build Hooks

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

  • Используйте флаги оптимизации: В hook/build.dart для релизных сборок добавляйте флаги -O3 (максимальная оптимизация) и -flto (Link Time Optimization).

  • Кэширование: Реализуйте в build hook логику кэширования. Если исходные файлы не изменились, используйте уже скомпилированные артефакты вместо повторной сборки.

  • Платформо-специфичные флаги: Используйте флаги, оптимизированные для конкретных архитектур, например -march=native.

  • Проверьте assetName в CBuilder.library() в файле hook/build.dart. Он должен точно указывать на тот Dart-файл, где используется аннотация @Native. Если вы используете @Native в файле lib/src/bindings.dart, то и assetName должен быть 'package:имя_пакета/src/bindings.dart'.

// hook/build.dart  // Функция для получения флагов компилятора C List<String> _getOptimizedCFlags(BuildMode buildMode) {   if (buildMode == BuildMode.release) {     return ['-O3', '-DNDEBUG']; // Оптимизация и отключение assert'ов   }   return ['-g', '-DDEBUG']; // Флаги для отладки }  // Функция для получения флагов компоновщика List<String> _getOptimizedLdFlags(BuildMode buildMode) {   if (buildMode == BuildMode.release) {     return ['-flto']; // Оптимизация на этапе компоновки   }   return []; }  void main(List<String> args) async {   await build(args, (config, output) async {     final cbuilder = CBuilder.library(       name: 'my_optimized_lib',       assetName: 'package:my_package/my_package.dart',       sources: ['src/my_lib.c'],       // Правильная передача флагов       cFlags: _getOptimizedCFlags(config.buildMode),       ldFlags: _getOptimizedLdFlags(config.buildMode),     );          await cbuilder.run(buildConfig: config, buildOutput: output);   }); }

Заключение

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

Ключевые преимущества

  • Автоматизация: Полностью автоматизированный процесс компиляции и подключения нативных библиотек.

  • Кроссплатформенность: Единый build-скрипт для сборки под все целевые платформы (мобильные, десктопные).

  • Производительность: Прямые вызовы нативных функций без накладных расходов, присущих Method Channels.

  • Простота: Значительно упрощенный процесс разработки и поддержки пакетов с нативным кодом.

На 2025 год система Native Assets является зрелой и рекомендованной для всех задач, требующих высокой производительности или интеграции с существующими C/C++/Rust библиотеками в экосистеме Dart и Flutter.


ссылка на оригинал статьи https://habr.com/ru/articles/918528/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *