• Главная
  • Контакты
Подписаться:
  • Twitter
  • Facebook
  • RSS
  • VK
  • PushAll
logo

logo

  • Все
    • Положительные
    • Отрицательные
  • За сегодня
    • Положительные
    • Отрицательные
  • За вчера
    • Положительные
    • Отрицательные
  • За 3 дня
    • Положительные
    • Отрицательные
  • За неделю
    • Положительные
    • Отрицательные
  • За месяц
    • Положительные
    • Отрицательные
  • За год
    • Положительные
    • Отрицательные
  • Сортировка
    • По дате (возр)
    • По дате (убыв)
    • По рейтингу (возр)
    • По рейтингу (убыв)
    • По комментам (возр)
    • По комментам (убыв)
    • По просмотрам (возр)
    • По просмотрам (убыв)
Главная
  • Все
    • Положительные
    • Отрицательные
  • За сегодня
    • Положительные
    • Отрицательные
  • За вчера
    • Положительные
    • Отрицательные
  • За 3 дня
    • Положительные
    • Отрицательные
  • За неделю
    • Положительные
    • Отрицательные
  • За месяц
    • Положительные
    • Отрицательные
  • Главная
  • Архитектура модульных React + Redux приложений 2. Ядро

Архитектура модульных React + Redux приложений 2. Ядро +12

24.04.2017 07:04
marshinov 3 4400 Источник
Разработка веб-сайтов*, Проектирование и рефакторинг*, Анализ и проектирование систем*, JavaScript*
В первой части я уделил внимание только общей концепции: редюсеры, компоненты и экшны чаще меняются одновременно, а не по отдельности, поэтому и группировать и их целесообразнее по модулям, а не по отдельным папкам actions, components, reducers. Также к модулям были предъявлены требования:

  1. быть независимыми друг от друга
  2. взаимодействовать с приложением через API ядра

В этой части я расскажу о структуре ядра, подходящей для разработки data-driven систем.
Начнем с определения модуля. Работать с простым объектом не совсем удобно. Добавим немного ООП:

const _base = Symbol('base')
const _ref = Symbol('ref')

class ModuleBase{
  constructor(base){
    this[_base] = base
    this[_ref] = getRef(this)
  }

  /**
   * unique module id
   * @returns {string}
   */
  get id(){
    return this.constructor.name
  }

  /**
   * full module ref including all parents
   * @returns {string}
   */
  get ref(){
    return this[_ref]
  }

  /**
   * module title in navigation
   * @returns {string}
   */
  get title(){
    return this.id
  }

  /**
   * module group in navigation
   * @returns {string}
   */
  get group(){
    return null
  }

  /**
   * react component
   * @returns {function}
   */
  get component() {
    return null
  }

  /**
   * router route
   * @return {object}
   */
  get route(){
    return getRoute(this)
  }

  /**
   * router path
   * @return {string}
   */
  get path(){
    return this.id
  }

  /**
   * children modules
   * @return {Array}
   */
  get children(){
    return []
  }

  /**
   * @type {function}
   */
  reduce
  //....
}
В коде выше для реализации инкапсуляции используются символы.
Теперь объявление модуля более привычно – необходимо унаследовать класс ModuleBase, переопределить необходимые геттеры и по желанию добавить функцию reduce, которая будет выполняться функцию редюсера.

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

Например, если мы хотим создать стандартный CRUD над сущностью в БД логично организовать модули так:

/SomeEntity
  /components
    /Master.js
  /children
    /index.js
    /create.js
    /update.js
  /index.js

Считаем, что для create и update используются стандартный компонент формы, а для вывода данных стандартный компонент Grid из ядра системы, поэтому достаточно определить только модули для этих операций.

Родительский модуль отвечает за вывод лейаута, ссылок «создать», «назад к списку» и сообщений об успешности или не успешности запросов к серверу. Index – за фильтрацию, пагинацию и ссылки. Create и Update выводят формы на создание и редактирование.

Таким образом, редюсер родительского модуля должен иметь доступ ко всему подграфу состояния модуля, а дочерние – каждый к своей части. Реализуем две функции компоновки.

Для роутов


const getRoute = module => {
  const route = {
    path: module.path,
    title: module.title,
    component: module.component
  }

  const children = module.children
  if(children) {
    ModuleBase.check(children)
    const index = children.filter(x => x.id.endsWith(INDEX))
    if(index.length > 0){
      // share title with parent module
      route.indexRoute = {
        component: index[0].component
      }
    }

    route.childRoutes = module.children
      .filter(x => !x.id.endsWith(INDEX))
      .map(getRoute)
  }

  return route
}

И для реюсеров


class ModuleBase{
  //....
  combineReducers(){
    const childrenMap = {}

    let children = Array.isArray(this.children) ? this.children : []
    ModuleBase.check(children)

    const withReducers = children.filter(x => typeof(x.reduce) === 'function' || x.children.length > 0)
    for (let i = 0; i < withReducers.length; i++) {
      childrenMap[children[i].id] = children[i]
    }

    if(withReducers.length == 0){
      return reducerOrDefault(this.reduce)
    }

    const reducers = {}
    for(let i in childrenMap){
      reducers[i] = childrenMap[i].combineReducers()
    }

    const parent = this
    const reducer = typeof(this.reduce) === 'function'
      ? (state, action) => {
        if(!state){
          state = parent.initialState
        }

        const nextState = parent.reduce(state, action)

        if(typeof(nextState) !== 'object'){
          throw Error(parent.id + '.reduce returned wrong value. Reducers must return plain objects')
        }

        for(let i in childrenMap){
          if(!nextState[i]){
            nextState[i] = childrenMap[i].initialState
          }

          nextState[i] = {...reducers[i](nextState[i], action)}
          if(typeof(nextState[i]) !== 'object'){
            throw Error(childrenMap[i].id + '.reduce returned wrong value. Reducers must return plain objects')
          }
        }

        return {...nextState}
      }
      : combineReducers(reducers)


    return reducer
  }

Это не самая эффективная реализация подобного редюсера. К сожалению, даже она заняла у меня достаточно много времени. Буду благодарен, если кто-то в комментариях подскажет, как можно сделать лучше.

Соответствие роутов и стейта


Данная реализация модульной системы полагается на соответствие стейта и роутов один к одному, с небольшими исключениями:

  1. /Update заменяется на /:id
  2. /Index опускается (используется indexRoute)
  3. Для Delete нет своего роута. Удаление производится из модуля Index

Метод path можно переопределить и тогда роут будет отличать от названия модуля. Можно конструировать цепочки модулей любой вложенности. Более того, если в вашем приложении только один корневой роут /, то целесообразно сделать модуль App и вложить в него все остальные, чтобы использовать один подход повсеместно.
Это позволит в редюсере App (если такой нужен) обрабатывать любые события приложения и модифицировать состояние любого дочернего модуля. Пожалуй, это слишком круто для любого, даже самого крутого редюсера. Я не рекомендую вообще переопределять reduce для родительского модуля приложения. Однако, такой редюсер может быть полезен для каких-то системных операций.

С роутингом покончено, осталось «законектить» компоненты к стейту. Так как редюсеры скомпонованы рекурсивно в соответствие со вложенностью дочерних модулей коннектить будем также. Здесь все просто. Реализацию mapDispatchToProps рассмотрим чуть ниже.

Компоненты ядра


Итак, ModuleBase– первая и неотъемлемая часть ядра. Без него свой код к приложению вообще не подцепить. ModuleBase предоставляет следующее API:

  1. Регистрация компонента в роутере
  2. Регистрация редюсера модуля
  3. Connect компонентов к стейту redux

Не плохо, но недостаточно. CRUD должно быть делать просто. Добавим DataGridModuleBase и FormModuleBase. До текущего момента мы не уточняли какие компоненты используются в модулях.

Компоненты и контейнеры


Контейнеры – один из широко распространённых паттернов в React. Если коротко, то разница между компонентами и контейнерами в следующем:

  1. Компоненты (или презентационные компоненты) не содержат внешних зависимостей и логики
  2. Контейнеры (как понятно из названия) оборачивают компоненты, реализуя байндинг между внешним миром и компонентами

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

Для разработки DataGridModule нам потребуются:

  1. компонент DataGrid
  2. его контейнер DataGridContainer
  3. редюсер для связи между контейнером и состоянием приложения в redux

Реализацию презентационного компонента я опускаю. Для подключения к стейту у нас есть функция ModuleBase.connect. Осталось получать данные с сервера. Можно на каждый грид создавать новый класс и переопределять componentDidMount или другие методы жизненного цикла компонента. Подход, в целом, рабочий, но имеющий два значительных недостатка:

  1. гигантское количество boilerplate и копипасты. А копи-пейст, как известно, всегда приводит к ошибкам
  2. низкая скорость разработки модулей (ядро пока не предоставляет никакого API для ускорения разработки, это неправильно)

Примеси (mixin)


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

Расширим возможности компоновки компонентов и контейнеров с помощью mixin’ов. class и extends – это объекты первого класса в ES6. Иными словами, запись const Enhanced = superclass => class extends superclass корректна. Это возможно, благодаря системе наследования JavaScript, основанной на прототипах.

Добавим в ядро функцию mix и примеси Preloader и ServerData:

const Preloader = Component => class extends Component {
  render() {
    const propsToCheck = subset(this.props, this.constructor.initialState)
    let isInitialized = true
    let isFetching = false

    for(let i in propsToCheck){
        if(typeof(propsToCheck[i][IS_FETCHING]) === 'boolean'){
          if(!isFetching && propsToCheck[i][IS_FETCHING]){
            isFetching = true
          }

          // if something except "isFetching" presents it's initialized
          if(isInitialized && Object.keys(propsToCheck[i]).length === 1){
            isInitialized = false
          }
        }
    }

    return isInitialized
      ? (<Dimmer.Dimmable dimmed={isFetching}>
        <Dimmer active={isFetching} inverted>
          <Loader />
        </Dimmer>
        {super.render()}
      </Dimmer.Dimmable>)
      : (<Dimmer.Dimmable dimmed={true}>
        <Dimmer active={true} inverted>
          <Loader />
        </Dimmer>
        <div style={divStyle}></div>
      </Dimmer.Dimmable>)
  }
}

const ServerData = superclass => class extends mix(superclass).with(Preloader) {
  componentDidMount() {
    this.props.queryFor(
      this.props.params,
      subset(this.props, this.constructor.initialState))
  }

Первый проверяет все ключи в стейте и если находит хотя-бы один с определенным свойством isFetching: true выводит поверх компонента диммер. Если кроме isFetching в объекте свойств нет, считаем, что они должны прийти с сервера и вообще не отображаем компонент (считаем не инициализированным).

Миксин ServerData автоматически подмешивает прелоадер и переопределяет componentDidMount.

queryFor


Рассмотрим более подробно реализацию queryFor. Ее передал Module.connect через mapDispatchToProps.

export const queryFactory = dispatch => {
  if(typeof (dispatch) != 'function'){
    throw new Error('dispatch is not a function')
  }

  return (moduleId, url, params = undefined) => {
    dispatch({
      type: combinePath(moduleId, GET),
      params
    })

    return new Promise(resolve => {
      dispatch(function () {
        get(url, params).then(response => {
          const error = 'ok' in response && !response.ok
          const data = error
            ? {ok: response.ok, status: response.status}
            : response

          dispatch({
            type: combinePath(moduleId, GET + (error ? FAILED : SUCCEEDED)),
            ...data
          })

          resolve(data)
        })
      })
    })
  }
}

export const queryAll = (dispatch, moduleRef, params, ...keys) => {
  const query = queryFactory(dispatch)
  if(!keys.length){
    throw new Error('keys array must be not empty')
  }

  const action = combinePath(moduleRef, keys[0])
  let promise = query(action, fixPath(action), params)
  for(let i = 1; i < keys.length; i++){
    promise.then(() => {
      let act = combinePath(moduleRef, keys[i])
      query(act, fixPath(act), params)
    })
  }
}

export const queryFor = (dispatch, moduleRef, params, state) => {
  const keys = []

  for (let i in state) {
    if (state[i].isFetching !== undefined) {
      keys.push(toUpperCamelCase(i))
    }
  }

  return queryAll(dispatch, moduleRef, params, ...keys)

С помощью queryFactory создаем функцию query, которая делает запрос на сервер, диспатчит в store соответствующие события и возвращает promise, чтобы можно было выстроить цепочку запросов функции в queryAll, список запросов в которую передаст та самая функция queryFor, которая ориентируется на наличие isFetching в объекте в доме, который построил Джек.

Допишем «обогощалку» для стейта, требующего серверных данных:

ServerData.fromServer = (initialState, ...keys) => {
  for(let i = 0; i < keys.length; i++){
    initialState[keys[i]].isFetching = false
  }

  return initialState
}

Теперь достаточно знать правила использования миксина, чтобы сделать из любого компонента, работающего с клиентскими данными на серверный. Достаточно правильно настроить initialState и подключить mixin.

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

ServerData.reducerFor


ServerData.reducerFor = (moduleRef, initialState, next = null, method = GET) => {
  if(!moduleRef){
    throw Error('You must provide valid module name')
  }

  if(!initialState){
    throw Error('You must provide valid initialState')
  }

  const reducer = {}

  for (let i in initialState) {
    reducer[i] = hasFetching(initialState, i)
      ? ServerData.serverRequestReducerFactory(combinePath(moduleRef, i), initialState[i], next, method)
      : passThrough(initialState[i])
  }

  if(Object.keys(reducer) < 1){
    throw Error('No "isFetching" found. Cannot build reducer')
  }

  const combined = combineReducers(reducer)
  return combined
}

export default class DataGridModuleBase extends ModuleBase {
  constructor(base){
    super(base)
    // Create is required due to children module
    this.reduce = ServerData.reducerFor(this.ref, DataGridContainer.initialState)
  }

  get component () {
    return this.connect(DataGridContainer)
  }
}

Добавляем модуль с гридом в приложение


export default class SomeEntityGrid extends DataGridModuleBase {
}
//..
const _children= Symbol('children')
export default class App extends ModuleBase{
  constructor(base){
    super(base)
    this[_children] = [new SomeEntityGrid(this)]
  }

  get path (){
    return '/'
  }

  get component () {
    return AppComponent
  }

  get children(){
    return this[_children]
  }

Если вы дочитали до конца, то FromModuleBase сможете реализовать по аналогии.

Финальная структура ядра


/core
  /ModuleBase.js
  /api.js
  /components
  /containers
  /modules
  /mixins

  1. Базовые модули содержат повторно-используемую логику и наборы стандартных компонентов, часто используемых вместе (например, CRUD).
  2. Папки components и containers содержат часто-используемые компоненты и контейнеры, соответственно.
  3. С помощью примесей можно компоновать компоненты и контейнеры: грид с серверными данными, грид с инлайн-вводом, грид с серверными данными и инлайн-вводом и т.д.
  4. api.js содержит функции для работы с сервером: fetch, get, post, put, del,…

Разделение ответственности


  1. Модули: роутинг, создание контейнера, передача необходимых функций в контейнер, редюсер для компонента, предоставление мета-информации.
  2. Компоненты: повторно-используемые части UI. Хорошо сочетаются с БЭМ. Могут разрабатываться независимо от основного приложения отдельной командой.
  3. Контейнеры: отображение состояния приложения и набора API на презентационные компоненты.
  4. Дополнительные middleware: не используются. Вместо них только redux-thunk. Дополнительные middleware не используются потому что это усложняет систему. Использование redux-saga сильно задирает кривую обучения и повышает размер бандла, поэтому предпочтение отдается thunk
Поделиться с друзьями
-->

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


  1. justboris
    24.04.2017 11:45
    #10188274
    +1

    Вы используете символы для приватных свойств, например [_children], но непонятно, откуда они берутся. В последнем примере кода:


    export default class App extends ModuleBase{
      constructor(base){
        super(base)
        this[_children] = [new SomeEntityGrid(this)]
      }
    }

    Откуда здесь берутся _children?


    1. marshinov
      24.04.2017 12:09
      #10188312
      -1

      Выше объявлены
      const _children= Symbol('children')

      Дописал в статью


  1. comerc
    24.04.2017 14:12
    #10188558
    +7

    Простите меня, ассоциация: "и со всем этим мы попробуем взлететь".

МЕТКИ

  • Хабы
  • Теги

Разработка веб-сайтов

Проектирование и рефакторинг

Анализ и проектирование систем

JavaScript

react

redux

модульное программирование

СЕРВИСЫ
  • logo

    CloudLogs.ru - Облачное логирование

    • Храните логи вашего сервиса или приложения в облаке. Удобно просматривайте и анализируйте их.
Все публикации автора
  • Архитектура модульных React + Redux приложений 2. Ядро +12

    • 24.04.2017 07:04

    Архитектура модульных React + Redux приложений +14

    • 14.04.2017 00:27

    Введение в React и Redux для бекенд-разработчиков +21

    • 10.04.2017 22:40

    Функциональный C# +25

    • 30.03.2017 18:25

    Шаблон проектирования «Спецификация» в C# +6

    • 30.03.2017 11:51

    Union Type, TPT, DDD, ORM и RDBMS +7

    • 20.02.2017 06:05

    DotNext — Moscow 2016. Как это было +33

    • 14.12.2016 15:55

    Устранение дублирования Where Expressions в приложении +14

    • 23.10.2016 16:52

    Как мы попробовали DDD, CQRS и Event Sourcing и какие выводы сделали +39

    • 19.10.2016 12:22

    Pipelining в C#-приложениях +13

    • 18.08.2016 13:12

Подписка


ЛУЧШЕЕ

  • Сегодня
  • Вчера
  • Позавчера
07:00

Нейро сети для самых маленьких. Часть первая (которая после нулевой). Удобство в прокрустовом ложе оптимизации +27

07:52

«Гражданин, обновитесь»: анализ вредоносной кампании Falcon +24

08:30

Хватит прятать ключи под ковром: переносим их в облачный сервис управления ключами (KMS) +21

08:11

Не только красные и синие — новые серверные процессоры от Qualcomm и Hygon +20

07:00

Сегментация опухолей головного мозга на МРТ-снимках с акцентом на точность границы +19

06:52

Работодатели давно играют в волков. Просто теперь соискатели научились отвечать +18

06:31

Лучший способ изучить разработку с Qt +16

08:00

Кастомизация Битрикс24 на платформе Вайбкод: создаём паспорт клиента +15

07:06

Клод Шеннон. Информатика на максималках +15

09:40

Как вайбкодинг довел меня до депрессии, а потом привел к вайб‑инжинирингу +11

08:57

TPC-DS в 07.2026. Lakehouse: Spark, Trino, StarRocks, Impala и Doris. Greenplum & Cloudberry vs StarRocks как MPP +10

07:35

Контекстная инженерия для слабой локальной модели: как мы делаем среднюю модель надёжной +10

11:01

Как установить Hermes на VPS. Один из лучших агентов в 2026 году +9

08:20

Навигация в Jetpack Compose без магии: что на самом деле делают библиотеки навигации +8

07:01

Автомобильные сигнализации РФ и их безопасность. Часть 1 +8

08:49

Не Bluetooth, а Wi-Fi: третья версия моей мощной портативной Hi-Fi акустики +7

07:00

Go vs GoF: положите паттерны ООП на пол и отойдите +7

08:11

65 бесплатных уроков июля: от LLM и RAG до Kubernetes, Go и QA +6

08:10

Совет требует ИИ, а данные не готовы. Как ESM даёт фундамент, который не провалит пилот +6

08:10

Машинное зрение: патенты в мире и в России +6

08:32

Что такое парадокс изогнутой трубы — и почему интуиция нас подводит +169

10:05

Почему я ухожу из Timeweb Cloud: 46 часов простоя в Амстердаме за два месяца — по данным самого хостера +96

12:00

Пузырь ИИ лопнул? Бизнес отказывается от ChatGPT, а Microsoft пытается спасти положение софтом: ML-дайджест +58

12:30

Passkey без Apple, Google и облаков: делаем собственный аппаратный ключ за 4 евро +55

18:44

PostgreSQL для бэкендера: 10 фич, которыми мало пользуются, а зря +51

09:02

Почему cron — самый опасный инструмент в Linux +44

07:03

Почтовый ящик пандоры. Какие сервисы работают с внешними почтовыми клиентами в России в 2026 году +35

13:01

Flappy Bird: делаем игру сложнее и добавляем автопилот на чистой математике +34

14:42

Почему сгорел Нотр-Дам: человеческий фактор, SHERPA и HTA в UX-проектировании интерфейса +29

07:05

kafkactl — другой взгляд на работу с Kafka +29

07:04

Умеют ли трансформеры водить машину +28

07:01

Шахматная память: как гроссмейстеры запоминают тысячи партий и почему это не мнемотехника +28

17:57

Антенный ротатор на PTZ 3050DZ +23

09:17

Почему опасно покупать Intel Core i7 и i9 с рук: разбираемся с деградацией процессоров 13900K и 14900K +23

09:09

Проектируем с нуля калькулятор на FPGA. Часть 6: CPU +23

08:01

Распределённый монолит: как одну проблему превратить в целый кластер проблем +21

14:27

Я больше не объясняю нейросети контекст. Вот что я сделал вместо этого… +18

10:26

Я декомпилировал свою зависимость: как программист раскидал курение на 100+ субличностей и перестал курить +17

08:00

Как мы реализовали оптимальное обучение CV-моделей в Luna Line. Часть 2. Сегментация +17

10:13

Бездумное использование ИИ ведёт к вырождению специалистов +16

15:10

Народная карта бензина собрала почти 2 млн посетителей за три дня. Её навайбкодил один человек +109

05:26

Как Яндекс меня кинул на фестивале для будущих яндексоидов, или Хроники одного YoungCon +62

07:01

Манипуляция ответами нейронок — как сеошники убивают интернет +59

05:05

Как одна операция из линейной алгебры захватила мир ИИ +55

07:59

Как мы работаем со студентами: дипломы, которые становятся частью YDB +46

07:59

Как мы работаем со студентами: дипломы, которые становятся частью YDB +46

11:13

Айсберг Доменов Верхнего Уровня (ICANN и не только) +44

07:05

Размагничивание кораблей: мины, физика и Курчатов. Часть 2 +43

11:12

Event Sourcing в платформе данных: миграция с JSON на Avro +42

09:01

TeamPCP: как команда хакеров-любителей «Дюны» закинула в наши Node.js-пакеты червей Shai-Hulud +42

21:02

Рунет без Google Login: что теперь делать с авторизацией +38

13:01

Триллионы километров данных: ваш автомобиль следит за вами, и это только начало +36

06:00

Человек-легенда, давший нам Спектрум и предсказавший ИИ +34

11:19

Как я переехал с Altium Designer на KiCAD +33

07:06

Семь раз подумай, один раз пошардируй: как мы начали горизонтально масштабировать метаданные чатов Телемоста +33

08:09

Обзор необычного и очень мощного мини-ПК Khadas Mind 2 +31

17:14

Что на самом деле означают теоремы Гёделя о неполноте? +27

15:05

И снова самый быстрый парсер JSON. Очередной +27

18:13

Как «ужать» мегаполис до размеров iPhone 4 +25

06:28

Забор из волшебных палочек +24

ОБСУЖДАЕМОЕ

  • Народная карта бензина собрала почти 2 млн посетителей за три дня. Её навайбкодил один человек +109

    • 199   45000

    Почему я ухожу из Timeweb Cloud: 46 часов простоя в Амстердаме за два месяца — по данным самого хостера +96

    • 151   22000

    Рунет без Google Login: что теперь делать с авторизацией +38

    • 134   25000

    Кажется я выяснил кто открыл Америку -9

    • 100   11000

    Что такое парадокс изогнутой трубы — и почему интуиция нас подводит +169

    • 84   26000

    Что лучше — C++ или C#? +6

    • 67   9600

    Как Let's Encrypt, только роднее: автоматическое получение бесплатного RSA DV-сертификата НУЦ +19

    • 59   11000

    Что на самом деле означают теоремы Гёделя о неполноте? +27

    • 57   14000

    Дело на вечер: собираем домашний ИИ-сервер +17

    • 54   17000

    Почему Минобрнауки боится ИИ и правда ли, что дипломные работы утратили смысл? +6

    • 39   12000

    Почтовый ящик пандоры. Какие сервисы работают с внешними почтовыми клиентами в России в 2026 году +35

    • 39   9700

    И все‑таки самая реальная защита дома или квартиры — электронно‑механический шлюз -10

    • 39   8500

    Почему «удалёнка» съедает ROI компании: расчёты потерь -1

    • 32   11000

    Passkey без Apple, Google и облаков: делаем собственный аппаратный ключ за 4 евро +55

    • 31   15000

    Отрежьте мне миллиард символов: как C++20, string_view и шаблонный ад могут скрывать баг годами +19

    • 31   11000
  • Главная
  • Контакты
© 2026. Все публикации принадлежат авторам.