Статьи

Богатые объектные модели и Angular.js: идентификационные карты

 

angularIdentityMap

В моем предыдущем посте, посвященном  Rich Object Models и Angular.js,  я представил простую стратегию настройки богатых объектных моделей в Angular.js. Оказывается, что после того, как мы ввели понятие богатой объектной модели, ряд более продвинутых методов объектно-ориентированного программирования становятся легко реализуемыми. Первый из них, который я собираюсь обсудить, — карты личности.

В этой статье я расскажу о том, что такое карта идентичности, зачем она вам нужна, и я представлю простую реализацию карты идентичности для Angular.js. Я предполагаю, что вы уже прочитали мой основополагающий пост . Если вы хотите увидеть часть моего выступления на ng-conf, посвященную картам личности, перейдите к видео .

Что такое карта личности?

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

Карты идентичности обычно используются в объектно-реляционных сопоставителях, чтобы гарантировать, что для каждой строки таблицы в базе данных существует только один соответствующий объект. В одностраничных приложениях Javascript также может быть полезно иметь карту идентификации на стороне клиента, чтобы гарантировать, что когда JSON, относящийся к конкретному бизнес-объекту, получен из серверной части, только один фактический экземпляр объекта когда-либо используется для представления этого объекта в бизнес логика

Платформы расширенной модели на стороне клиента, такие как Breeze.js , Ember Data и  Backbone Relational,  содержат карты идентификации. Мой коллега Грег Гросс даже недавно создал отличную автономную карту идентификации для Backbone.js . Из всех этих сред только Angle может использоваться только Breeze, и даже это может быть излишним для многих проблем.

Пример

Если все это выглядит немного академично, рассмотрите следующий реальный пример, взятый из веб-приложения Aircraft Proposal, которое я представил в моем предыдущем посте . Давайте вернемся к объектной модели для этого приложения, но сделаем акцент на конкретном аспекте модели:

Инструмент предложения

Я покрасил объекты валюты в красный. Каждая денежная сумма в системе имеет валюту. Валюты представляют интерес, потому что колебания их относительных значений могут повлиять на прибыль. Например, если валюта, с которой платит клиент (  внешняя  валюта), существенно отличается от валюты, используемой внутри страны для оплаты основной массы рабочей силы и материалов для предложения, может произойти убыток.

Чтобы смоделировать влияние изменений валюты на предложение, может быть полезно изменить валютный курс, а затем пересчитать расчеты прибылей и убытков для предложения. Однако, если у нас есть несколько объектов, представляющих одну и ту же валюту, это может быть трудно сделать. Например, если бы все затраты на рабочую силу и запчасти были в евро, но цена для клиента была в долларах США, то для моделирования изменения в евро нам нужно было бы найти в системе каждый экземпляр объекта, представляющий валюту евро, и настроить его. стоимость.

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

Вернуться к карте

So to be clear, an identity map maps an class/ID pair to an object instance. In our particular example, the contents might look something like this:

identityMap

Note that:

  • We’re just using strings to identify the ‘classes’. There are more sophisticated ways to accomplish the same thing, but I’ll keep it simple for now.
  • We can also put other types of objects in the map – for example, departments. However, the remainder of this demo will focus on currencies.

An Angular Identity Map

Implementing an identity map with Angular.js is relatively straightforward, the simplest approach simply being to write a factory service that returns a function:

angular.module('shinetech.models').factory('identityMap', 
  function() {
    var identityMap = {};
    return function(className, object) {
      if (object) {
        var mappedObject;
        if (identityMap[className]) {
          mappedObject = identityMap[className][object.id];
          if (mappedObject) {
            angular.extend(mappedObject, object);
          } else {
            identityMap[className][object.id] = object;
            mappedObject = object;
          }
        } else {
          identityMap[className] = {};
          identityMap[className][object.id] = object;
          mappedObject = object;
        }
        return mappedObject;
      }
    };
  }
);

You’ll find the full implementation and tests in the angular-models project on Github. A couple of things to note:

  • We’re always assuming that objects are identified by an id property
  • If an object has previously been loaded into the map, but another object with the same ID is presented to the map, it’ll merge the properties from the new object into the old one – then return the old one.
  • We don’t deal with the case where an object hasn’t got an ID yet. There are strategies for handling this (see Greg Gross’s Backbone Identity Map for an example), but we’ll keep it simple for now.

Using the Identity Map

Now let’s see how this identity map fits in with the mixin approach to rich object models. The key is to use the identityMap function at the point where deserialized objects and their children are being decorated with business behaviour. If you’re dealing with an object that has been previously identity-mapped, this is the point where you substitute in the original object.

Consider the internalCurrency and externalCurrency objects that are decorated when mixing Proposal behaviour into an object. To refresh your memory from my last post, the code originally looked like this:

angular.module('models', ['shinetech.models']).
  factory('Proposal', function(
    RecurringEngineering, NonRecurringEngineering, Currency,
    Base
  ) {
    return Base.extend({
      beforeMixingInto: function(obj) {
        RecurringEngineering.mixInto(
          obj.recurringEngineering
        );
        NonRecurringEngineering.mixInto(
          obj.nonRecurringEngineering
        );
        Currency.mixInto(obj.internalCurrency);
        Currency.mixInto(obj.externalCurrency))
      },
      profit: function() {
         return this.revenue().minus(this.cost());
      },
      ...
    });
  });

To identity-map the currencies, we’d change it as follows:

angular.module('models', ['shinetech.models']).
  factory('Proposal', function(
    RecurringEngineering, NonRecurringEngineering, Currency,
    Base, identityMap
  ) {
    return Base.extend({
      beforeMixingInto: function(obj) {
        RecurringEngineering.mixInto(
          obj.recurringEngineering
        );
        NonRecurringEngineering.mixInto(
          obj.nonRecurringEngineering
        );
        angular.extend(proposal, {
          internalCurrency: identityMap('currency',
            Currency.mixInto(proposal.internalCurrency)
          ),
          externalCurrency: identityMap('currency',
            Currency.mixInto(proposal.externalCurrency)
          )
        });     
      },
      ...
    });
  });

Note how we explicitly set the internalCurrency and externalCurrency. This is so that, if an object representing a particular currency has previously been instantiated and put into the identity map, then we can substitute that instance into the proposal rather than using the one that’s been provided by the object.

Where else should we use the identity map? Well, if you’ll recall from the data-structure diagram above, there’s also a bunch of objects representing monetary amounts. To support this, we have a Money mixin that we’re mixing into all monetary amounts. Here’s an example of it being used to decorate the cost of a MaterialCostItem:

angular.module('models').
  factory('MaterialCostItem', function(Base, Money) {
    return Base.extend({
      beforeMixingInto: function(object) {
        Money.mixInto(object.cost);
      }
    });
  });

The Money mixin in turn decorates the currency that is attached to the monetary amount:

angular.module('models').
  factory('Money', function(Currency, Base) {
    return Base.extend({
      beforeMixingInto: function(object) {
        Currency.mixInto(object.currency);
      },
      ...
    });

To identity map the currency, we would do the following:

angular.module('models').
  factory('Money', function(identityMap, Currency, Base) {
    return Base.extend({
      beforeMixingInto: function(object) {
        object.currency = identityMap('currency',
          Currency.mixInto(object.currency)
        );
      },
      ...
    });

By making this update in a single place, we’ll be identity-mapping the currency of all monetary amounts.

Finally, what if we want to identity-map currencies received directly from a server call (rather than those deserialized as part of a nested data structure)? Say that we had a service that got a list of all currencies from a back-end end-point called /currencies:

angular.module('services').
  factory('CurrencySvc', function(Restangular, Currency) {
    Restangular.extendModel('currencies', function(object) {
      return Currency.mixInto(object);
    });
 
    return Restangular.all('currencies');
  });

Fortunately, Restangular’s extendModel method lets us substitute in a completely different object if we want. So we can identity-map the currencies by simply altering it as follows:

angular.module('services').
  factory('CurrencySvc', function(
    Restangular, Currency, identityMap
  ) {
    Restangular.extendModel('currencies', function(object) {
      return identityMap('currency', Currency.mixInto(object));
    });
 
    return Restangular.all('currencies');
  });

Having put in all this identity-mapping of currencies, we can see it in action by tweaking the exchange rate of one of them, and then re-executing the calculations for a proposal – jump to the demo from my presentation at ng-conf to see it in action (unfortunately I can’t put all of the code online because it contains customer-sensitive information).

Caveats

Identity maps come with a couple of caveats that are always worth considering.

Most importantly, they leak memory, as they’re basically a hash of objects that can only increase in size the longer your app is sitting in the browser. Normally this is when somebody says “if you use an ES6 WeakMap to map class/ID pairs to object instances, those object instances will get garbage-collected if nothing else is referring to them”.

Unfortunately this is not the case as WeakMaps garbage-collect items if nothing is referring to the key, not the value. Whilst personally I mourn this missed opportunity, it’s worth noting that I haven’t had many issues in reality with identity maps causing memory blow-outs.

That said, you shouldn’t do identity-mapping just for its own sake, only when you need it. Identity maps effectively make object instances globally accessible, which introduces the possibility of unintended side-effects. Sometimes you don’t want there to be one global instance of an object – for example, if you’re editing instances but don’t yet want those changes to be reflected globally.

Wrapping Up

In this post I’ve shown how using rich object models with Angular opens up the possibility of employing identity mapping in your codebase. I’ve introduced a simple identity map implementation and demonstrated how it can be slotted into the mixin approach I described in my previous post. Identity maps have tradeoffs and shouldn’t be used indiscriminately, but are a handy tool to add to your toolbox for certain situations.

If you’re interested in more things you can do with Rich Object Models and Angular.js, check out the next post on Angular.js and Getter Methods.