От сервера к десктопу: эксперимент с ASP.NET AOT и WebKitGTK

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Введение

Пару лет назад для .NET в Linux не было не то чтобы production-ready фреймворков для создания native desktop приложений, но и экспериментальных. На тот момент существовали Uno Platform и Avalonia (тогда еще бета). Они запускались, работали, но написать реальное и относительно сложное приложение было практически нереально. Сегодня ситуация значительно лучше. Avalonia уже вполне production-ready продукт и является, на мой взгляд, лидером среди desktop фреймворков на .NET для Linux.

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

Цель этого эксперимента — проверка жизнеспособности стека ASP .NET + WebKitGTK + frontend на JS/TS. Этот стек предназначен для работы на ОС Linux. В проекте весь код написан на C# за исключением клиентской части, которая реализована при помощи TypeScript и Angular 19.1.

Этот концепт представляет из себя приложение, которое получает данные из Github API и для выбранного языка программирования проводит анализ количества новых репозиториев github по годам, строя линейную диаграмму, показывая тем самым тренд языка программирования. Этот анализ изначально не слишком объективен, но для тестового приложения вполне нормальный сценарий использования. В приложении на том же WebKitGTK реализована OAuth аутентификация Github. Полученные токены хранятся в системе при помощи библиотеки libsecret в зашифрованном (AES) виде. После первого входа пользователю необходимо установить пин-код, который является частью ключа. Немного заморочился с безопасностью токенов, да =)

Архитектура приложения

Главным в приложении является хост-процесс - консольное приложение C#. Оно управляет запуском окон WebKitGTK и бекендом.

Подробности на схеме:

Архитектура приложения
Архитектура приложения

Работа с WebKitGTK

Для работы с WebKitGTK необходимо написать байндинги PInvoke к соответствующим shared-библиотекам. Поскольку я использую net 9, буду использовать новое для .net API — атрибут LibraryImport.

internal static partial class WebKitGtk
{
    public static class Events
    {
        public const string Close = "close";
        public const string LoadChanged = "load-changed";
        public const string DecidePolicy = "decide-policy";
        public const string ContextMenu = "context-menu";
    }
    
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    [return: MarshalAs(UnmanagedType.I1)] // gboolean => bool
    internal delegate bool CloseCallback(IntPtr webView, IntPtr userData);

    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    [return: MarshalAs(UnmanagedType.I1)] // gboolean => bool
    internal delegate bool DecidePolicyCallback(IntPtr webView, IntPtr decision, IntPtr type, IntPtr userData);

    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    [return: MarshalAs(UnmanagedType.I1)] // gboolean => bool
    internal delegate bool ContextMenuCallback(IntPtr webView, IntPtr menu, IntPtr @event, IntPtr hitTestResult,
        IntPtr userData);

    internal enum WebKitUserContentInjectedFrames : uint
    {
        AllFrames = 0,
        TopFrame = 1
    }

    internal enum WebKitUserScriptInjectionTime : uint
    {
        AtDocumentStart = 0,
        AtDocumentEnd = 1
    }

    public enum WebkitPolicyDecisionType : int
    {
        NavigationAction,
        NewWindowAction,
        Response,
    }

    public enum MenuAction : int
    {
        NoAction = 0,

        OpenLink,
        OpenLinkInNewWindow,
        DownloadLinkToDisk,
        CopyLinkToClipboard,
        OpenImageInNewWindow,
        DownloadImageToDisk,
        CopyImageToClipboard,
        CopyImageUrlToClipboard,
        OpenFrameInNewWindow,
        GoBack,
        GoForward,
        Stop,
        Reload,
        Copy,
        Cut,
        Paste,
        Delete,
        SelectAll,
        InputMethods,
        Unicode,
        SpellingGuess,
        NoGuessesFound,
        IgnoreSpelling,
        LearnSpelling,
        IgnoreGrammar,
        FontMenu,
        Bold,
        Italic,
        Underline,
        Outline,
        InspectElement,
        OpenVideoInNewWindow,
        OpenAudioInNewWindow,
        CopyVideoLinkToClipboard,
        CopyAudioLinkToClipboard,
        ToggleMediaControls,
        ToggleMediaLoop,
        EnterVideoFullscreen,
        MediaPlay,
        MediaPause,
        MediaMute,
        DownloadVideoToDisk,
        DownloadAudioToDisk,
        InsertEmoji,
        PasteAsPlainText,

        Custom = 10000
    }
    
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    internal delegate void DestroyHandler(IntPtr widget, IntPtr data);

    internal static partial class WebView
    {
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_web_view_new")]
        internal static partial IntPtr New();
        
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_web_view_load_uri",
            StringMarshalling = StringMarshalling.Utf8)]
        internal static partial void LoadUri(IntPtr webView, string uri);
        
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_web_view_get_uri")]
        internal static partial IntPtr GetUri(IntPtr webView);
        
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_web_view_get_settings")]
        internal static partial IntPtr GetSettings(IntPtr webView);
        
        [LibraryImport(Libraries.WebKit,
            EntryPoint = "webkit_web_view_get_user_content_manager")]
        internal static partial IntPtr GetUserContentManager(IntPtr webView);
    }

    internal static partial class Navigation
    {
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_navigation_policy_decision_get_navigation_action")]
        internal static partial IntPtr PolicyDecisionGetNavigationAction(IntPtr navDecision);

        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_navigation_action_get_request")]
        internal static partial IntPtr ActionGetRequest(IntPtr navAction);
        
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_navigation_policy_decision_get_request")]
        internal static partial IntPtr PolicyDecisionGetRequest(IntPtr navDecision);
    }

    internal static partial class Settings
    {
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_settings_set_enable_developer_extras")]
        internal static partial void SetEnableDeveloperExtras(IntPtr settings, Interop.GObject.GBoolean enabled);
    }

    internal static partial class ContextMenu
    {
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_context_menu_get_n_items")]
        public static partial uint GetNItems(IntPtr menu);

        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_context_menu_get_item_at_position")]
        public static partial IntPtr GetItemAtPosition(IntPtr menu, uint position);

        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_context_menu_remove")]
        public static partial void Remove(IntPtr menu, IntPtr item);

        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_context_menu_item_get_stock_action")]
        public static partial MenuAction ItemGetStockAction(IntPtr item);
    }

    internal static partial class UserContentManager
    {
        [LibraryImport(Libraries.WebKit,
            EntryPoint = "webkit_user_content_manager_add_script")]
        internal static partial void AddScript(
            IntPtr manager,
            IntPtr userScript);
    }

    [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_uri_request_get_uri")]
    internal static partial IntPtr UriRequestGetUri(IntPtr uriRequest);

    [LibraryImport(Libraries.WebKit,
        EntryPoint = "webkit_user_script_new",
        StringMarshalling = StringMarshalling.Utf8)]
    internal static partial IntPtr UserScriptNew(
        string source,
        WebKitUserContentInjectedFrames injectedFrames,
        WebKitUserScriptInjectionTime injectionTime,
        IntPtr allowList,
        IntPtr blockList);

    [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_policy_decision_ignore")]
    internal static partial void PolicyDecisionIgnore(IntPtr policyDecision);
}

Аналогичным образом создаём байндинги для Gtk и GObject...

Далее пишем обёртку для WebKitGTK

public static partial class WebkitGtkWrapper
{
    private static WebViewConfig? _config;
    
    private static IntPtr _window = IntPtr.Zero;
    private static IntPtr _webView = IntPtr.Zero;
    private static IntPtr _overlay = IntPtr.Zero;
    private static IntPtr _animation = IntPtr.Zero;
    private static IntPtr _loadingAnimation = IntPtr.Zero;
    private static bool _splashVisible;

    /// <summary>
    /// Запустить WebKit с указанными настройками.
    /// </summary>
    /// <param name="config">Конфигурация Webkit</param>
    /// <param name="loadChangedCallback">Колбек состояния, адресации и закрытия окна.</param>
    /// <exception cref="InvalidOperationException">Когда не удаётся инициализировать WebKit.</exception>
    public static void RunWebkit(WebViewConfig config,
        Action<WebKitLoadEvent, string?, Action> loadChangedCallback)
    {
        if (!GtkWrapper.Initialized.Value)
        {
            throw new NullReferenceException("Gtk is not initialized.");
        }
        
        if (_window != IntPtr.Zero) // Если уже открыто
        {
            throw new InvalidOperationException("Webkit is already running.");
        }

        _config = config;

        _window = Gtk.Window.New(Gtk.WindowType.GtkWindowToplevel);

        if (_window == IntPtr.Zero)
        {
            throw new InvalidOperationException("Failed to create GtkWindow.");
        }

        Gtk.Window.SetTitle(_window, _config.WindowTitle);
        Gtk.Window.SetDefaultSize(_window, config.Width, config.Height);

        // Подписываемся на событие закрытия окна.
        GObject.GSignalConnectData(
            _window, Gtk.Events.Destroy, Marshal.GetFunctionPointerForDelegate(WindowDestroySignalHandler),
            IntPtr.Zero, IntPtr.Zero, GObject.GConnectFlags.GConnectDefault);

        _overlay = Gtk.Overlay.New();
        
        Gtk.Container.Add(_window, _overlay);
        
        _webView = WebKitGtk.WebView.New();
        if (_webView == IntPtr.Zero)
        {
            GObject.Unref(_window);
            _window = IntPtr.Zero;
            throw new Exception("Failed to create webview.");
        }

        if (!_config.AllowSelection)
        {
            DisableWebViewSelection();
        }

        Gtk.Container.Add(_overlay, _webView);

        // Подписываемся на событие закрытия WebView
        GObject.GSignalConnectData(
            _webView, WebKitGtk.Events.Close, Marshal.GetFunctionPointerForDelegate(WebviewCloseSignalHandler),
            IntPtr.Zero, IntPtr.Zero, GObject.GConnectFlags.GConnectDefault);

        // Подписываемся на событие навигации
        _onLoadChangedHandler = (webView, loadEventPtr, _) =>
        {
            int loadEventInt = loadEventPtr.ToInt32();
            var loadEvent = (WebKitLoadEvent)loadEventInt;

            // Выключение splash загрузки
            if (_splashVisible && loadEvent is WebKitLoadEvent.WebkitLoadStarted)
            {
                Gtk.Widget.Hide(_loadingAnimation);
                _splashVisible = false;
            }

            IntPtr uriPtr = WebKitGtk.WebView.GetUri(webView);
            string? currentUri = uriPtr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(uriPtr);
            loadChangedCallback.Invoke(loadEvent, currentUri, () =>
            {
                DestroyWindow();
                Gtk.MainQuit();
            });
        };

        GObject.GSignalConnectData(
            _webView, WebKitGtk.Events.LoadChanged, Marshal.GetFunctionPointerForDelegate(_onLoadChangedHandler),
            IntPtr.Zero, IntPtr.Zero, GObject.GConnectFlags.GConnectDefault);

        if (config.StrictMode) // Строгий режим.
        {
            GObject.GSignalConnectData(_webView, WebKitGtk.Events.DecidePolicy,
                Marshal.GetFunctionPointerForDelegate(DecidePolicyHandler),
                IntPtr.Zero, IntPtr.Zero, GObject.GConnectFlags.GConnectDefault);
        }

        if (config.DebugMode)
        {
            IntPtr settings = WebKitGtk.WebView.GetSettings(_webView);
            WebKitGtk.Settings.SetEnableDeveloperExtras(settings, GBoolean.True);
        }
        else
        {
            // Отключение контекстного меню
            GObject.GSignalConnectData(_webView, WebKitGtk.Events.ContextMenu,
                Marshal.GetFunctionPointerForDelegate(ContextMenuHandler),
                IntPtr.Zero, IntPtr.Zero, GObject.GConnectFlags.GConnectDefault);
        }

        // Если задан splash, то устанавливаем.
        if (!string.IsNullOrWhiteSpace(config.SplashFilename) && File.Exists(config.SplashFilename))
        {
            _animation = GdkPixbuf.gdk_pixbuf_animation_new_from_file(config.SplashFilename, IntPtr.Zero);
            if (_animation != IntPtr.Zero)
            {
                _loadingAnimation = Gtk.Image.NewFromAnimation(_animation);
                Gtk.Overlay.AddOverlay(_overlay, _loadingAnimation);
                Gtk.Widget.SetHAlign(_loadingAnimation, Gtk.GtkAlign.GtkAlignCenter);
                Gtk.Widget.SetVAlign(_loadingAnimation, Gtk.GtkAlign.GtkAlignCenter);
                _splashVisible = true;
            }
        }

        WebKitGtk.WebView.LoadUri(_webView, config.StartUri.ToString());

        Gtk.Window.SetPosition(_window, _config.WindowPosition);
        Gtk.Widget.ShowAll(_window);
        Gtk.Main();
    }
}

Часть с делегатами намеренно опустил, чтобы не раздувать листинги.

Благодаря этой обёртке теперь имеем нехитрый нативный API для WebKitGTK:

Uri homepageUri = new(_baseAddress, "index.html");
        WebViewConfig viewConfig =
            new(homepageUri.ToString())
            {
                Width = 1250,
                Height = 1000,
                SplashFilename = SplashFilename,
#if DEBUG
                WindowTitle = "Github App (DEBUG MODE)",
                AllowSelection = true,
                StrictMode = false,
                DebugMode = true,
#else
                WindowTitle = "Github App", 
                AllowSelection = false,
                StrictMode = true,
                DebugMode = false,
                AllowedUrls = [_baseAddress],
#endif
            };

        WebkitGtkWrapper.RunWebkit(viewConfig, 
                                   (navigationEvent, uri, terminator) =>
        {
            Console.CancelKeyPress += (_, e) =>
            {
                e.Cancel = true;
                Console.WriteLine("Exiting...");
                backendHost.StopAsync().Wait();
                terminator();
            };
        });

Для профиля сборки Debug я разрешил отладчик webview и выделение курсором для удобства отладки. В профиле Release webview не сможет перейти вне заданных адресов AllowedUrls, отладчик и контекстное меню будут отключены. В контекстном меню оставляем доступными только пункты «Копировать, Вставить, Вырезать».

Интеграция libsecret

Аналогичным образом пишу interop-код для LibSecret и обёртку для него.

Опустив PInvoke и unsafe код, имеем такую удобную обёртку:

public static class TokenManager
{
    private const string ServiceName = "GithubApp";
    private const string Account = "Token";
    private const int SaltSize = 16;
    private const int KeySize = 32;
    private const int Iterations = 100_000;

    public static bool IsTokenExist()
    {
        string? data = LibSecret.GetSecret(ServiceName, Account);
        return !string.IsNullOrWhiteSpace(data);
    }
    
    public static bool SetRefreshToken(string token, string pinCode)
    {
        if (pinCode is not { Length: 4 })
        {
            throw new ArgumentException("Invalid pin code");
        }
        
        LibSecret.DeleteSecret(ServiceName, Account);
        
        byte[] bytes = Encoding.UTF8.GetBytes(token);
        byte[] salt = GenerateSalt(SaltSize);
        
        SecretInfo secretInfo = 
            EncryptToBase64(bytes, pinCode, salt);
        
        string json = JsonSerializer.Serialize(secretInfo, AssemblySerializationContext.Default.SecretInfo);
        return LibSecret.SetSecret(ServiceName, Account, json);
    }
    
    private static SecretInfo EncryptToBase64(byte[] payload, string pinCode, byte[] salt)
    {
        using Aes aes = Aes.Create();
        aes.GenerateIV();
        byte[] iv = aes.IV;
        aes.Key = DeriveKey(pinCode, salt);

        using MemoryStream memoryStream = new MemoryStream();
        using CryptoStream cryptoStream = new CryptoStream(memoryStream, aes.CreateEncryptor(), CryptoStreamMode.Write);
        cryptoStream.Write(payload, 0, payload.Length);
        cryptoStream.FlushFinalBlock();
        
        return new SecretInfo()
        {
            Secret = Convert.ToBase64String(memoryStream.ToArray()),
            Iv = Convert.ToBase64String(iv),
            Salt = Convert.ToBase64String(salt)
        };
    }

    public static string? TryGetRefreshToken(string pinCode)
    {
        string? encryptedToken = LibSecret.GetSecret(ServiceName, Account);
        
        if (pinCode is not { Length: 4 })
        {
            throw new ArgumentException("Invalid pin code");
        }

        if (string.IsNullOrWhiteSpace(encryptedToken))
        {
            return null;
        }
        
        var secretInfo = JsonSerializer.Deserialize<SecretInfo>(encryptedToken, AssemblySerializationContext.Default.SecretInfo);
        byte[] iv = Convert.FromBase64String(secretInfo.Iv);
        byte[] salt = Convert.FromBase64String(secretInfo.Salt);
        byte[] key = DeriveKey(pinCode, salt);
        byte[] encryptedPayload = Convert.FromBase64String(secretInfo.Secret);
        string token = Decrypt(encryptedPayload, key, iv);

        return token;
    }
    
    private static string Decrypt(byte[] payload, byte[] key, byte[] iv)
    {
        using Aes aes = Aes.Create();
        aes.Key = key;
        aes.IV = iv;
        using MemoryStream memoryStream = new MemoryStream(payload);
        using CryptoStream cryptoStream = new CryptoStream(memoryStream, aes.CreateDecryptor(), CryptoStreamMode.Read);
        using StreamReader reader = new StreamReader(cryptoStream);
        return reader.ReadToEnd();
    }

    private static byte[] GenerateSalt(int size)
    {
        byte[] salt = new byte[size];
        RandomNumberGenerator.Fill(salt);
        return salt;
    }

    private static byte[] DeriveKey(string pinCode, byte[] salt)
    {
        using var pbkdf2 = new Rfc2898DeriveBytes(pinCode, salt, Iterations, HashAlgorithmName.SHA256);
        return pbkdf2.GetBytes(KeySize);
    }
}

Бекэнд на ASP.NET AOT

Переходим к реализации бекенда. Я использовал minimal API с прицелом на сборку AOT. У этого подхода есть свои сложности. Например, коробочный функционал стандартного MVC Asp .net приложения не доступен полностью ввиду ограничений aot для рефлексии. Недоступны контроллеры.

public static class Program
{
    public static void Main(string[] args)
    {
        CreateHost(args).Run();
    }

    public static IHost CreateHost(string[] args)
    {
        string? refreshToken = Environment.GetEnvironmentVariable("REFRESH_TOKEN");
        string? accessToken = Environment.GetEnvironmentVariable("ACCESS_TOKEN");

        if (string.IsNullOrWhiteSpace(refreshToken))
        {
            throw new InvalidOperationException("Please set environment variable REFRESH_TOKEN");
        }
        
        if (string.IsNullOrWhiteSpace(accessToken))
        {
            throw new InvalidOperationException("Please set environment variable ACCESS_TOKEN");
        }
        
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddTransient<HttpClient>(_ =>
        {
            HttpClient client = new();
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            return client;
        });

        builder.Services.AddMemoryCache();

        List<IEndpointDefinition> definitions =
        [
            new PlTrendingsEndpoints()
        ];
        foreach (var def in definitions)
        {
            def.DefineServices(builder.Services);
        }

#if DEBUG
        builder.Services.AddCors(options =>
        {
            options.AddPolicy("AllowAngularApp",
                policy =>
                {
                    policy.WithOrigins("http://localhost:4200")
                        .AllowAnyMethod()
                        .AllowAnyHeader();
                });
        });
#endif
        
        var app = builder.Build();
        
#if DEBUG
        app.UseCors("AllowAngularApp");
#endif
        

        // Загружаем встроенные файлы из ресурсов сборки
        EmbeddedFileProvider embeddedProvider = new(Assembly.GetExecutingAssembly(), "");
        // Настраиваем раздачу встроенных файлов
        app.UseStaticFiles(new StaticFileOptions
        {
            FileProvider = embeddedProvider,
            RequestPath = ""
        });
        // SPA Fallback
        app.MapFallback(async context =>
        {
            var file = embeddedProvider.GetFileInfo("index.html");
            if (file.Exists)
            {
                context.Response.ContentType = "text/html";
                await using var stream = file.CreateReadStream();
                await stream.CopyToAsync(context.Response.Body);
            }
            else
            {
                context.Response.StatusCode = 404;
                await context.Response.WriteAsync("Not found");
            }
        });

        foreach (var def in definitions)
        {
            def.DefineEndpoints(app.MapGroup(def.GroupEndpoint));
        }

        return app;
    }
}

List<IEndpointDefinition> - это коллекция групп эндпоинтов. В этом интерфейсе мы объявляем методы для регистрации эндпоинтов.

public interface IEndpointDefinition
{
    string GroupEndpoint { get; }
    void DefineEndpoints(RouteGroupBuilder group);
    void DefineServices(IServiceCollection services);
}

Интерфейс позволяет нам реализовать модули такого вида:

public class PlTrendingsEndpoints : IEndpointDefinition
{
    public string GroupEndpoint => "/trend";

    public void DefineEndpoints(RouteGroupBuilder group)
    {
        group.MapGet("/diagram",
            async (string language, int beginYear, int endYear, string? format, ApiClient client, IMemoryCache cache) =>
            {
                if (string.IsNullOrWhiteSpace(language)
                    || beginYear < 2008
                    || endYear > 2025
                    || beginYear >= endYear)
                {
                    Results.BadRequest();
                }
                
                string cacheKey = $"{nameof(PlTrendings)}-{language}-{beginYear}-{endYear}";
                string? base64;
                
                if (!cache.TryGetValue(cacheKey, out base64) || string.IsNullOrWhiteSpace(base64))
                {
                    base64 =
                        await client.GetBase64PngDiagramForYears(
                            language, beginYear, endYear, 1024, 768);
                    cache.Set(cacheKey, base64);
                }

                if (format == "base64")
                {
                    return Results.Ok(base64);
                }

                // Декодируем base64 в byte[]
                byte[] imageBytes = Convert.FromBase64String(base64);
                // Возвращаем картинку в виде файла
                return Results.File(imageBytes, "image/png");
            });
    }

    public void DefineServices(IServiceCollection services)
    {
        services.AddTransient<ApiClient>();
        services.Configure<JsonOptions>(options =>
        {
            options.SerializerOptions.TypeInfoResolver = AssemblySerializationContext.Default;
        });
    }
}

Замечание: Обязательным условием при использовании сериализации json в AOT является объявление контекста сериализации, где перечислены DTO, которые мы сериализуем/десериализуем. Без этого произойдёт «обрезание» при сборке - сериализация корректно работать не будет!

Контекст сериализации

[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(List<string>))]
public partial class AssemblySerializationContext : JsonSerializerContext
{
}

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

Класс ApiClient отвечает за генерацию диаграмм на базе данных запросов GraphQL Github API. Для создания диаграмм я написал собственную простую реализацию рендеринга при помощи SkiaSharp.

Так выглядит сгенерированная диаграмма для Python.

Диаграмма для Python
Диаграмма для Python

Так же я добавил in-memory кеширование с целью уменьшения повторных запросов к API Github.

Фронтент на Angular

В этом кейсе я выбрал Angular. Да, это далеко не самый рациональный выбор фреймворка с точки зрения производительности. Но аналогичная часть этой кодовой базы у меня уже была в одном из pet-проектов и я решил её переиспользовать.

А еще TypeScript мне ближе по духу, это тоже немаловажно =)

Главная страница довольно проста.

<main class="main">
  <app-select-language-dialog *ngIf="isDialogOpen" [languages]="availableLanguages" (close)="closeDialog()"
    (languageSelected)="addLanguage($event)">
  </app-select-language-dialog>

  <div class="content">
    <h1 id="title">Тренды языков программирования: анализ роста.</h1>
    <p>						
Источник: https://habr.com/ru/articles/880908/


Интересные статьи

Интересные статьи

Я много слышу от новых коллег, что у нас в компании классный онбординг, что это им легко, комфортно входить в процесс, нет стресса. А ещё внутри компании и команды аналитиков он работает классно, поэт...
История роботов начинается задолго до XX века. Две сотни лет назад появились автоматоны, способные писать тексты, рисовать и пускать стрелы, а в начале нашей эры встречаются упоминания о различных мех...
При загрузке данных в Highload-блоки возможна ситуация, когда объем загружаемых данных очень велик.Тем не менее, самый распространенный путь для добавления данных - их перебор в цикле, и последующее д...
В Челябинске проходят митапы системных администраторов Sysadminka, и на последнем из них я делал доклад о нашем решении для работы приложений на 1С-Битрикс в Kubernetes. Битрикс, Kubernetes, Сep...
На сегодняшний день у сервиса «Битрикс24» нет сотен гигабит трафика, нет огромного парка серверов (хотя и существующих, конечно, немало). Но для многих клиентов он является основным инструментом ...