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

Я участвую в создании крючков Feathers Populate с 2016 года. Это мои личные размышления об их эволюции. (*) Почему они развивались именно так. Какие проблемы с дизайном. Куда они собираются отсюда?

Вначале Дэвид Люке создал Feathers.

Популярность FeathersJS Дэвида (a.k.a. daffl) резко возросла с выпуском версии 2 в апреле 2016 года, в немалой степени благодаря продвижению Эрика Крыски (a.k.a. ekryski).

Перья первоначально служили некоторыми крючками в перьях-обычных крючках. Существовала элементарная поддержка присоединения связанных записей к родительской записи (также известной как заполнение) в соответствии с минималистской философией Feathers.

var hooks = require('feathers-common-hooks);
find: hooks.populate('sender', {
  service: '/users',
  field: 'senderId'
})

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

И Майкл Эрмер сказал: да будет свет

В то время как Майкл Эрмер разрабатывал свой впечатляющий проект Feathers, он усовершенствовал технологию, выпустив в июле 2016 года свой примечательный перья-заселение-крючок.

var populate = require('feathers-populate-hook');
find: populate({
  user: {
    service: 'users',
    f_key: 'id',
    one: true
  },
  comments: {
    service: 'comments',
    f_key: 'message',  // Foreign key
    l_key: 'id',       // Local key
  },
  resolvedCategories: {
    service: 'categories',
    f_key: 'id',
    l_key: 'categories'
  },
  creator: {
    service: 'users',
    f_key: 'email',
    l_key: 'createdByEmail',
    one: true,
    query:  {
    $select: ['name','profile_image']
  }
})

Это заполняет данные, объединенные записями из нескольких сервисов. Для каждой службы значение l_key («локальное») данных должно соответствовать значению f_key («внешнее») одной или нескольких служб записи. Один или оба ключа могут быть массивом.

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

Этот крючок был серьезным достижением.

Пусть крючки соберутся в одно место

Я выпустил перья-крючки-общие в июле 2016 года в качестве зависимости для значительного стартового проекта Feathers-React-Redux. Дэвид обратился ко мне, и мы согласились добавить перья-крючки-обычные в Feathers и объединить в него перья-общие-крючки. Объединение и создание новых документов были завершены в августе 2016 года.

Исходные требования к заселению перьев

Несколько лет назад я был техническим руководителем по разработке корпоративной системы закупок, которая состояла из более чем 250 объектов баз данных. (Подумайте, 250 услуг Feathers.) Было много присоединений.

На основе этого опыта я остановился на некоторых требованиях к крючку Feathers 'Populate.

Каскаду не хватает контроля.

Другие службы вызываются, когда вы заполняете данные связанными записями. Также будут запущены любые хуки заполнения, прикрепленные к так называемым сервисам.

Это повторяющееся присоединение может выйти из-под контроля. На приведенной выше ER-диаграмме есть таблица users, связанная с таблицей posts. При заполнении таблицы users считываются записи post. Если эти данные сообщений заполнены сами по себе, к каждой записи сообщений будет добавлена ​​одна запись users. Но ждать! Эти записи пользователей должны быть заполнены… записями сообщений. И мы находимся в бесконечном цикле.

Каскадом можно управлять с помощью внешнего пользовательского кода; код решает, следует ли запускать ловушку populate или нет. Однако полученный код непонятен и труден для понимания.

Обработчик populate должен формировать окончательную форму данных.

Вам могут понадобиться результирующие данные, которые выглядят как

user-1
  post-1
    comment-1
    comment-2

Запись comment-1 является внучкой user-1. Если вы не зависите от каскада присоединения к записям комментариев, вы должны указать, что они необходимы. Таким образом, ловушка populate должна определять желаемую окончательную форму популяции.

Очистить данные.

Вы можете заполнить данные одними и теми же записями для просмотра менеджером по расчету заработной платы и служащим по расчету заработной платы. Менеджеру по расчету заработной платы, вероятно, будет разрешено видеть зарплаты руководителей, а клерку - нет.

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

Пока мы занимаемся этим, мы можем также вычислить новые производные поля на основе существующих данных.

Схема заполнения может зависеть от обстоятельств.

Разные отделы могут захотеть, чтобы данные были заполнены по-разному. Это легко реализовать в Feathers.

const schemas = {
  accounting: { ... },
  'food&beverage': { ... }
};
find: context =>
  populate(schemas[context.params.user.dept])(context);

Заполненные данные можно записать обратно в таблицу.

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

Легко, мы просто удаляем записи комментариев, верно? Что ж ... возможно, записи сообщений иногда заполняются записями комментариев, а в других случаях - автором запись. Легко… мы просто удалим их обоих.

Но это становится небрежным по мере того, как ваше приложение становится все сложнее. Было бы удобно, если бы ловушка populate отслеживала, что было добавлено, чтобы ловушка dePopulate знала, какие записи были объединены, удаляла их и возвращала базовую запись в форме, подходящей для изменения базы данных.

Сохранение статистики эффективности.

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

И были перья заселены

Хуки populate, serialize и depopulate были выпущены в декабре 2016 года. Синтаксис, показанный ниже, предназначен для следующей схемы .

var hooks = require('feathers-hooks-common');
// For posts data
const populateSchema = {
  include: [
    { service: 'users',
      nameAs: 'authorRec',
      parentField: 'authorId',
      childField: 'id',
      include: [
        { service: 'relationships',
          nameAs: 'relationshipRecs',
          parentField: 'id',
          childField: 'followerId',
          include: [
            { service: 'users',
              nameAs: 'followerRec',
              parentField: 'followerId',
              childField: 'id'
            }]
        },
    ]},
    { service: 'comments',
      parentField: 'id',
      childField: 'postId',
      query: {
        $limit: 5,
        $select: ['body'],
        $sort: { archieved: 1 }
      },
      select: (hook, parent, depth) => ({ $limit: 6 }),
      asArray: true
    }
  ],
};
// For populated posts data
const serializeSchema = {
  post: {
    exclude: ['id', 'authorId'],
    authorRec: {
      exclude: ['id', 'email'],
    },
    commentsInfo: {
      only: ['body'],
    },
  },
};
before: {
  update: depopulate()
},
after: {
  find: [
    populate({ populateSchema }),
    serialize(serializeSchema)
  ]}
// posts-rec-1
//   authorRec
//     relationshipRecs
//       relationship-rec-1
//         followerRec
//           users-rec-1
//           users-rec-2
//       relationship-rec-2
//         followerRec
//           users-rec-3
//   comments
//     comments-rec-1
//     comments-rec-2
// posts-rec-2
//   ...

Профи populate и др .:

  • Они сработали и не вызвали много вопросов.

Минусы заселения:

  • Как ни странно, одной частой проблемой была путаница между parentField и childField. Люди постоянно меняли свои значения. У нас было несколько продолжительных дискуссий о том, что называть внешним ключом в API, и мы были вполне довольны выбором этих имен.
  • Вскоре были добавлены параметры provider, useInnerPopulate и paginate. parentField и childField стали необязательными. У меня возникло ощущение, что мы находимся на пути к добавлению параметров для каждой синтаксической единицы вызова службы find, поскольку в конечном итоге люди захотят контролировать все.
  • Люди были разочарованы тем, что populate не мог выполнять соединения с нетипичными структурами. populate может работать с массивом внешних ключей, то есть массивом roleId. Однако в приведенном ниже случае внешние ключи являются реквизитами в массиве объектов. Необходимо использовать хаки, поскольку populate не может справиться с этим как есть.
teamName: 'Expos',
members: [
  { id: 1, name: 'John', roleId: '123' },
  { id: 2, name: 'Jane', roleId: '456' }
]
  • Люди были разочарованы тем, что объединенные записи нельзя было переформатировать в структуру, отличную от массива объектов. Однажды меня спросили, как вызвать populate для преобразования данных, подобных показанным ниже. Человек хотел, чтобы объединенная информация была объектом, имена свойств которого были одним из полей в объединенной записи. Мне кажется, что некоторые люди надеялись, что populate преобразует данные в структуру, идеально подходящую для их пользовательского интерфейса.
// before
teamName: 'Expos',
emplNumbers: [ 1234, 5678 ]
// requested after
teamName: 'Expos',
emplNumbers: [ 1234, 5678 ],
emplRecords: {
  John: { emplNumber: 1234, age: 32 },
  Jane: { emplNumber: 5678, age: 35 },
}
  • populate выполняет служебный вызов для каждого отдельного присоединения. При заполнении данных сообщений мы будем многократно читать одну и ту же запись users для каждого сообщения автора. Ситуация может быть хуже в более сложных схемах, подобных схеме в начале этого раздела, где записи users являются частью многих возможных объединений. Запросы на кеш начали поступать в конце 2017 года.

// The eddyystop record is read each time by the populate hook
Automatic fake data with feathers-plus/cli
  eddyystop
Automatically seeding data with feathers-plus/cli
  eddyystop
Automatic testing with feathers-plus/cli
  eddyystop
Automatically keep your app up-to-the-minute with feathers-plus/cli
  eddyystop
Use decision tables to write better tests faster
  eddyystop
  • В обычном кеше вы индексируете каждую кешированную запись по ее идентификатору. Однако populate читает записи не с service.get, а с service.find. Ключ кеша должен быть params.query плюс другие значения, такие как params.provider. Нам также понадобится предсказуемая сериализация, чтобы {a:1,b:2} и {b:2,a:1} приводили к одному и тому же ключу.
  • Меня беспокоила другая проблема с производительностью. populate по-прежнему будет выполнять отдельные служебные вызовы для новых записей даже с кешем. Был ли способ их совместить?
// instead of
users.find({ query: { id: 10 } })
users.find({ query: { id: 10 } })
users.find({ query: { id: 10 } })
// could we do
users.find({ query: { id: { $in: [10, 20, 30] } } })
  • За последние 2 года я получил несколько вопросов по поводу эриализации и депопуляции. Так что они используются, но, может быть, не слишком часто. Возможно, вызовы служб старательно используют $ select для ограничения возвращаемых полей, но я считаю, что большинство вызовов служб возвращают все поля, и пользовательский интерфейс решает, какие из них отображать. Это вряд ли безопасно, поскольку все поля можно легко отобразить в консоли браузера.
  • Мне не задавали ни единого вопроса относительно информации о времени, хранящейся для каждой популяции. Мой вывод - это бесполезная функция.

Декларативный и императивный дизайн API

Я придумал эти термины для этой статьи. Эти примеры помогают проиллюстрировать, что я имею в виду.

// declarative API
hooks.populate('sender', {
  service: '/users',
  field: 'senderId'
})
populate({
  user: {
    service: 'users',
    f_key: 'id',
    one: true
} })
// imperative API
alterItems(rec => { delete rec.password; })
alterItems(rec => rec.email = email.lowerCase())

Декларативные API

  • Абстрагируйте то, что они делают. Вы не осведомлены о внутренней механике.
  • Они особенно полезны, когда вы новичок в Feathers. Вы еще не очень хорошо знакомы с деталями Feathers, а с декларативным дизайном и не должны быть знакомы.
  • В итоге вы получаете множество декларативных хуков, по одному на все, что вы можете делать в Feathers. Это затрудняет поиск того, что вам нужно. Нередко люди на нашем канале Slack спрашивают, как что-то сделать, когда соответствующий крючок уже существует в общих хуках и разделен на несколько тегов, чтобы его было легче найти.

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

Императивные API

  • Эти типы ловушек в основном содержат шаблонный код, например повторение каждой записи в context.data или context.result. Их параметр (ы) состоит из кода, выполняемого в основе шаблона.
  • Вы должны понимать детали Feathers, чтобы использовать императивные хуки, потому что вы будете напрямую манипулировать этими деталями.
  • Только ваше воображение ограничивает ваши возможности. Они очень сильные.
  • Возможно, их придется изменить с помощью критических изменений в перьях.

fastJoin

fastJoin был выпущен в ноябре 2017 года. Он имеет обязательный API.

const { fastJoin } = require('feathers-hooks-common');
  
const postResolvers = {
  joins: {
    comments: {
      resolver: ($select, $limit, $sort) => async post =>
        post.comments = await comments.find({
          query: {
            postId: post.id,
            $select: $select,
            $limit: $limit || 5,
            [$sort]: { createdAt: -1 }
          },
          paginate: false
        }),
        
      joins: {
        author: $select => async comment =>
          comment.author = (await users.find({
            query: { id: comment.userId, $select: $select },
            paginate: false
          }))[0],
      },
    },
  }
};
  
const query = {
  comments: {
    args: [...],
    author: [['id', 'name']]
  },
};
    
module.exports = {
  after: {
    all: [ fastJoin(postResolvers, query) ],
  }
};
// Original record
[{
  id: 1, body: 'John post', userId: 101, starIds: [102, 103, 104]
}]
  
// Result
[{ id: 1,
    body: 'John post',
    userId: 101,
    starIds: [ 102, 103, 104 ],
    comments: 
     [ { id: 11,
         text: 'John post Marshall comment 11',
         postId: 1,
         userId: 102,
         author: { id: 102, name: 'Marshall' } },
       { id: 12,
         text: 'John post Marshall comment 12',
         postId: 1,
         userId: 102,
         author: { id: 102, name: 'Marshall' } },
       { id: 13,
         text: 'John post Marshall comment 13',
         postId: 1,
         userId: 102,
         author: { id: 102, name: 'Marshall' } } ]
}]

и его можно использовать с недавно представленным BatchLoader.

const { fastJoin } = require('feathers-hooks-common');
const { loaderFactory } = require('@feathers-plus/batch-loader');

const postResolvers = {
  before: context => {
    context._loaders = { user: {} };
    context._loaders.user.id = loaderFactory(users, 'id', false, {
      paginate: false
    })(context);
  },
  joins: {
    author: () => async (post, context) =>
      post.author =
        await context._loaders.user.id.load(post.userId),
      
    starers: () => async (post, context) => !post.starIds ? null :
      post.starers =
        await context._loaders.user.id.loadMany(post.starIds),
  }
};
    
module.exports = {
  after: {
    find: [ fastJoin(postResolvers) ],
  }
};
// Original record
[{
  id: 1, body: 'John post', userId: 101, starIds: [102, 103, 104]
}]
  
// Result
[{ id: 1,
    body: 'John post',
    userId: 101,
    starIds: [ 102, 103, 104 ],
    author: { id: 101, name: 'John' },
    starers: 
     [ { id: 102, name: 'Marshall' },
       { id: 103, name: 'Barbara' },
       { id: 104, name: 'Aubree' } ]
}]

Плюсы fastJoin:

  • Благодаря императивному дизайну и функциям преобразователя fastJoin достаточно гибок, чтобы обрабатывать структуры, которые заполнить не могут.
  • Функции распознавателя определяют внешний ключ в родительской записи. Итак, теперь можно обрабатывать нетипичные структуры, например roleId в этой:
teamName: 'Expos',
members: [
  { id: 1, name: 'John', roleId: '123' },
  { id: 2, name: 'Jane', roleId: '456' }
]
  • Функции распознавателя заполняют родительскую запись. Таким образом, могут быть созданы нетипичные структуры вывода, такие как emplRecords в этой:
// before
teamName: 'Expos',
emplNumbers: [ 1234, 5678 ]
// requested after
teamName: 'Expos',
emplNumbers: [ 1234, 5678 ],
emplRecords: {
  John: { emplNumber: 1234, age: 32 },
  Jane: { emplNumber: 5678, age: 35 },
}
  • Функции распознавателя полностью контролируют вызовы сервисов. Вызовы выполняются естественным образом в JavaScript, а не на основе декларативных параметров.
post.comments = await comments.find({
  query: {
    postId: post.id,
    $select: $select,
    $limit: $limit || 5,
    [$sort]: { createdAt: -1 }
  },
  paginate: false
})
  • Функции распознавателя могут быть настолько сложными, насколько это необходимо. Они могут звонить в любую службу поддержки. Они могут делать вызовы API. Они могут быть целым приложением. Вот хороший пример, недавно опубликованный Уиллом Мюрреем (он же wdmtech) на форуме Slack.
const usersResolvers = {
  joins: {
    settings: () => async (user, context) => {
      try {
        user.usersSettings = await context.app
          .service('users-settings')
          .get(user.usersSettingsId);
      } catch (err) {
        user.usersSettings = await context.app
          .service('users-settings')
          .create({ userId: user._id });
        await context.app.service('users').patch(
          user._id, { usersSettingsId: user.usersSettings._id }
        );
      }
    },
  }
};
  • Родительские данные могут быть обновлены расчетными данными или из них может быть удалена конфиденциальная информация.
joins: {
  // ...,
  starerCount: () => post => {
    post.starerCount = post.starIds.length
 },
}
  • С помощью populate клиент может формировать результат только косвенно, тогда как fastJoin может делать это напрямую. Клиент пользовательского интерфейса может запрашивать новые формы без каких-либо изменений на сервере.
// populate
const schemas = {
  accounting: { ... },
  'food&beverage': { ... }
};
find: context =>
  populate(schemas[context.params.dept])(context);
// fastJoin
find: context =>
  fastJoin(postResolvers, context.params.query)(context);
  • Хук serialize будет работать с fastJoin. dePopulate не будет, поскольку ловушка не знает, как функции преобразователя изменили родительскую запись.
  • заполнить содержит 256 строк кода (LOC); fastJoin всего 73. fastJoin короче, потому что ему не нужно создавать service.find из параметров ловушки; функция распознавателя содержит вызов службы. Делать больше, используя только 30% LOC, - это победа.

Минусы fastJoin:

  • У fastJoin более крутая кривая обучения, чем у populate.
  • Разработчики JavaScript привыкли работать с асинхронными действиями, но BatchLoader объединяет их из разных мест в один асинхронный вызов. Это не типичный шаблон JavaScript, и к нему нужно привыкнуть.
  • Функция преобразователя должна быть предоставлена ​​для каждого соединения в fastJoin, по одной для каждого потомка, внука, правнука и т. Д. Однако одна и та же функция может использоваться более чем в одном экземпляре fastJoin. Вы можете поделиться ими, как показано ниже, но это не идеально.
const resolvers: {
  postsUsers: () => async (post, context) => ...,
  postsComments: () => async (post, context) => ...,
  // ...
};
const resolversToPopulatePosts = {
  joins: {
    users: resolvers.postUsers,
    comments: resolvers.postComments
} };
const resolversToPopulateUsers = {
  joins: {
    posts: {
      resolver: () => async (user, context) => {
        // ...
        return posts;
      },
      joins: {
        users: resolvers.postUsers,
        comments: resolvers.postComments
} } } };

Потому что я знаю планы, которые у меня есть на тебя

Мы рассмотрели историю заполнения данных от Feathers v2 до настоящего времени. Куда мы отправимся отсюда?

Сначала мы расскажем, как работает BatchLoader. Это интересное чтение, которое развеивает мистификацию BatchLoader.

Во-вторых, мы рассмотрим новую ловушку fgraphql.

  • Он использует схемы и преобразователи GraphQL. Когда вы ознакомитесь с ними, вы также многое поймете в GraphQL.
  • Вам больше не нужно повторять какие-либо функции распознавателя. Просто напишите каждый раз.
  • Feathers-plus / cli (cli +) автоматически генерирует схемы и преобразователи GraphQL для вас. Итак ... угадайте, что ... вы можете использовать их с fgraphql и вообще не писать никаких преобразователей.
  • Вы заполняете данные пользователей с помощью таких инструкций, как
{
  email: 1,
  posts: {
    _args: { query: { $sort:{ body: 1 } } },
    body: 1,
    author: {
      email: 1,
    }
  },
  likes: {
    author: {},
    comment: {},
  },
};
  • fgraphql не вызывает никаких сервисов GraphQL. Он легкий и просто использует схему и преобразователи.
  • Вам не нужно переходить с текущего хука fastJoin на fgraphql;, например, не будет улучшения скорости. Однако преобразовать его несложно.

Как видите, мы еще не закончили ... подпишитесь на публикации Feathers-plus, чтобы оставаться в курсе.

Как всегда, не стесняйтесь присоединяться к Feathers Slack, чтобы присоединиться к обсуждению или просто подкрасться.

(*) Пожалуйста, свяжитесь со мной с любыми исправлениями