История одной миграции типов: как эволюционирует простейший редактор графов

Главный герой этой статьи — не код

Это файл сохранения save_node_editor.qap.
Пока мы 5 раз меняли структуры данных, добавляли поля и новые типы — этот файл продолжал работать. Без миграционных скриптов, без ручных конвертеров, без потери данных пользователей.
Это возможно благодаря самоописывающейся сериализации с автоматической миграцией.

В реальном проекте сложность появляется не из-за алгоритмов, а из-за сохранённых данных пользователей.

Если формат данных не умеет расти — проект умирает.

Я покажу эволюцию простого редактора графов — от «чёрных точек» до узлов с портами, цветами и красиво переливающимися цветами каналов. Картинки для удобства пронумерованы от 006 до 011 и соответствуют разным версиям формата данных.

Проект — экспериментальный визуальный редактор графов.
Он развивается итеративно: структура данных меняется чаще, чем логика.
Сохранённые файлы используются постоянно между сборками.

Прежде чем приступить опишем основные структуры редактора.

// слой/документ в котором будет хранится наша геометрия
class t_layer{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_layer)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(string,name,DEF,$,$)\
ADDEND()
//=====+>>>>>t_layer
#include "QapGenStruct.inl"
//<<<<<+=====t_layer
public:
};
// наш мир с котором будет работать редактор
// добавим для удобства в него несколько слоёв/документов помимо текущего слоя
class t_world{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_world)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(int,tick,SET,0,$)\
ADDVAR(vector<t_layer>,layers,DEF,$,$)\
ADDVAR(int,layer_id,SET,-1,$)\
ADDVAR(t_layer,cur,DEF,$,$)\
ADDEND()
//=====+>>>>>t_world
#include "QapGenStruct.inl"
//<<<<<+=====t_world
public:
};

В реальном проекте изменения происходили в другом порядке и с откатами, но для статьи путь упрощён и выпрямлен — чтобы наглядно показать сам принцип разработки при использовании last_hope_loader на основе RTTI.

Начнём с минимального возможного формата.

Версия 006 — просто узлы

Модель данных

class t_node{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_node)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TSelfPtr<SelfClass>,Self,DEF,$,$)\
ADDVAR(bool,enabled,SET,true,$)\
ADDVAR(vec2d,pos,DEF,$,$)\
ADDVAR(double,r,SET,32,$)\
ADDEND()
//=====+>>>>>t_node
#include "QapGenStruct.inl"
//<<<<<+=====t_node
};
// Добавим "vector<t_node> t_layer::nodes;"
class t_layer{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_layer)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(string,name,DEF,$,$)\
ADDVAR(vector<t_node>,nodes,DEF,$,$)\
ADDEND()
//=====+>>>>>t_layer
#include "QapGenStruct.inl"
//<<<<<+=====t_layer
public:
};

Никакой логики. Просто геометрия и сериализация.

На экране — только чёрные шары, раскиданные по плоскости.

Картинка 006      | Базовые узлы                 | Чёрные шары
Картинка 006 | Базовые узлы | Чёрные шары

Версия 007 — появляются провайдеры цвета

Миграция данных

Мы аккуратно расширяем тип, не ломая текущий файл сохранения с состоянием редактора:

// Добавляем "QapColor t_node::color=0xff000000;"
// Добавляем     "bool t_node::provider=false;"
class t_node{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_node)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TSelfPtr<SelfClass>,Self,DEF,$,$)\
ADDVAR(bool,enabled,SET,true,$)\
ADDVAR(vec2d,pos,DEF,$,$)\
ADDVAR(double,r,SET,32,$)\
ADDVAR(QapColor,color,SET,0xff000000,$)\
ADDVAR(bool,provider,SET,false,$)\
ADDEND()
//=====+>>>>>t_node
#include "QapGenStruct.inl"
//<<<<<+=====t_node
};

Никаких миграционных скриптов мы писать не будем в этой статье, т.к они не нужны(это не означает, что миграции не нужны вообще — это означает, что в данном классе изменений они не требуются), в этом примере используется сериализатор с поддержкой автоматической миграции типов (проект QapSerialize), поэтому отдельные миграционные скрипты здесь не требуются. Всё сделает автоматика.

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

class TGame:public TQapGameV2{
public:
  IEnvRTTI*pEnv=nullptr;
  string fn="save_node_editor.qap";
  t_world w;
public:
  void DoMigrate(){
    // пробуем загрузиться из бинарного файла методом "последняя надежда"
    // не самый быстрый метод, но зато самый универсальный(с миграцией).
    // загрузчик сопоставляет поля по именам и типам,
    // инициализируя новые значениями по умолчанию и игнорируя отсутствующие
    if(!QapPublicUberFullLoaderBinLastHope(*pEnv,QapRawUberObject(w),fn)){
      DoInit(); // на случай если не получилось
    }
  }
  void DoMove()override{
    if(kb.OnDown(VK_ESCAPE)){
      // сохраняем в универсальный бинарный формат
      QapPublicUberFullSaverBin(*pEnv,QapRawUberObject(w),fn);
      TerminateProcess(GetCurrentProcess(),0);
    }
    if(w.tick==0){
      DoMigrate();
    }
    DoUpdate();
    w.tick++;
  }
  ...
};

Запускаем и смотрим на экран

Некоторые шары окрашены, остальные — чёрные.

Картинка 007      | Цвет + флаг provider         | Появились цветные источники
Картинка 007 | Цвет + флаг provider | Появились цветные источники

Версия 008 — добавляем связи (линки)

Новые типы

class t_endpoint{
public:
#define DEF_PRO_AUTO_COPY
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_endpoint)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TWeakPtr<t_node>,n,DEF,$,$)\
ADDEND()
//=====+>>>>>t_endpoint
#include "QapGenStruct.inl"
//<<<<<+=====t_endpoint
public:
};
class t_link{
public:
#define DEF_PRO_AUTO_COPY
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_link)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(bool,enabled,SET,true,$)\
ADDVAR(t_endpoint,a,DEF,$,$)\
ADDVAR(t_endpoint,b,DEF,$,$)\
ADDVAR(double,line_size,SET,4,$)\
ADDEND()
//=====+>>>>>t_link
#include "QapGenStruct.inl"
//<<<<<+=====t_link
public:
};

И ещё добавим vector<t_link> links; в t_layer:

class t_layer{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_layer)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(string,name,DEF,$,$)\
ADDVAR(vector<t_node>,nodes,DEF,$,$)\
ADDVAR(vector<t_link>,links,DEF,$,$)\
ADDEND()
//=====+>>>>>t_layer
#include "QapGenStruct.inl"
//<<<<<+=====t_layer
public:
};

На этом этапе граф уже есть, но он ещё топологически бедный.

Запускаем и смотрим на экран

Между шарами появляются линии, соединяющие центры.

Картинка 008      | Связи (линки)                | Линии между центрами
Картинка 008 | Связи (линки) | Линии между центрами

Версия 009 — распространение цвета (BFS)

Здесь появляется поведенческая логика, но формат данных почти не меняется.

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

void DoPropWithBFS(){
  auto&nodes=w.cur.nodes;
  auto&links=w.cur.links;
  vector<t_node*> cur,next;map<t_node*,int> V;
  map<t_node*,t_color_accum> acc;
  for(auto&ex:nodes)if(ex.provider){cur.push_back(&ex);acc[&ex].add(ex.color);}
  for(int iter=1;cur.size();iter++){
    for(auto&f:cur){
      for(auto&ex:links){
        if(!ex.a.n||!ex.b.n)continue;
        if(ex.a.n.get()!=f&&ex.b.n.get()!=f)continue;
        auto*oe=ex.a.n.get()==f?ex.b.n.get():ex.a.n.get();
        if(oe->provider||oe==f)continue;
        acc[oe].add(acc[f].get());
        auto&v=V[oe];if(v)continue;v=iter;
        next.push_back(oe);
      }
    }
    cur=std::move(next);
  }
  for(int i=0;i<nodes.size();i++){
    auto&n=nodes[i];
    if(n.provider)continue;
    if(!acc[&n].n)continue;
    n.color=acc[&n].get();
    n.name=IToS(acc[&n].n);
  }
}

Результат:

Цвет от провайдеров «растекается» по графу.

Картинка 009      | (логика BFS)                 | Цвет "растекается"
Картинка 009 | (логика BFS) | Цвет "растекается"

Версия 010 — порты

Новые изменения

// добавляем новый тип t_port
class t_port{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_port)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(vec2d,offset,DEF,$,$)\
ADDVAR(double,r,SET,8,$)\
ADDVAR(string,type,DEF,$,$)\
ADDEND()
//=====+>>>>>t_port
#include "QapGenStruct.inl"
//<<<<<+=====t_port
public:
};
// добавляем "vector<t_port> t_node::ports;"
class t_node{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_node)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TSelfPtr<SelfClass>,Self,DEF,$,$)\
ADDVAR(bool,enabled,SET,true,$)\
ADDVAR(vec2d,pos,DEF,$,$)\
ADDVAR(double,r,SET,32,$)\
ADDVAR(QapColor,color,SET,0xff000000,$)\
ADDVAR(bool,provider,SET,false,$)\
ADDVAR(vector<t_port>,ports,DEF,$,$)\
ADDEND()
//=====+>>>>>t_node
#include "QapGenStruct.inl"
//<<<<<+=====t_node
public:
};
// добавляем "int t_endpoint::port_id;"
class t_endpoint{
public:
#define DEF_PRO_AUTO_COPY
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_endpoint)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TWeakPtr<t_node>,n,DEF,$,$)\
ADDVAR(int,port_id,SET,0,$)\
ADDEND()
//=====+>>>>>t_endpoint
#include "QapGenStruct.inl"
//<<<<<+=====t_endpoint
public:
};

Обратите внимание: мы не «переделываем» существующие линки, а лишь расширяем их интерпретацию. Старые данные по-прежнему валидны — просто теперь они могут быть отображены более точно.

Добавляем простой алгоритм выбора лучших портов:

void DoFixLinkPorts(t_link&ref){
  if(!ref.a.n||!ref.b.n)return;
  struct t_best{bool ok=false;int a;int b;int d;void aib(const t_best&c){if(!ok||c.d<d)*this=c;}};
  t_best best;
  int aid=0;
  for(auto&a:ref.a.n->ports){
    int bid=0;
    for(auto&b:ref.b.n->ports){
      auto pa=ref.a.n->pos+a.offset;
      auto pb=ref.b.n->pos+b.offset;
      t_best cur={true,aid,bid,pa.sqr_dist_to(pb)};
      best.aib(cur);
      bid++;
    }
    aid++;
  }
  if(!best.ok)return;
  ref.a.port_id=best.a;
  ref.b.port_id=best.b;
}

Результат:

Линки подключаются не к центру, а к портам.

Картинка 010      | Порты + привязка к ним       | Линии цепляются к портам
Картинка 010 | Порты + привязка к ним | Линии цепляются к портам

Версия 011 — красивые кривые между портами

Последнее расширение

// Добавляем "vector<vec2d> t_link::line;"
class t_link{
public:
#define DEF_PRO_AUTO_COPY
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_link)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(bool,enabled,SET,true,$)\
ADDVAR(t_endpoint,a,DEF,$,$)\
ADDVAR(t_endpoint,b,DEF,$,$)\
ADDVAR(double,line_size,SET,4,$)\
ADDVAR(vector<vec2d>,line,DEF,$,$)\
ADDEND()
//=====+>>>>>t_link
#include "QapGenStruct.inl"
//<<<<<+=====t_link
public:
};

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

Финальный вид: линки — это уже геометрия, а не просто абстрактная связь.

Картинка 011      | Геометрия линий              | Плавные кривые
Картинка 011 | Геометрия линий | Плавные кривые

Этот пример не про редактор графов, а про демонстрацию работы алгоритма сериализации. Про то, как живёт формат данных в реальном проекте.

Когда система развивается, мы неизбежно:

  • добавляем новые поля

  • меняем интерпретацию старых

  • усложняем логику

И если формат данных спроектирован так, чтобы расти, а не ломаться, то история проекта перестаёт быть балластом и становится активом.

Алгоритмы можно переписать. Сохранённые данные — нет.

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

Что это даёт на практике?

  1. Быстрые эксперименты — можно пробовать радикальные изменения структур

  2. Параллельная разработка — разные версии редактора могут работать с одними данными

  3. Отладка — всегда можно сохранить состояние и воспроизвести баг

  4. Пользовательские моды — сообщество может расширять форматы без страха сломать всё

Технический итог

// Мы прошли путь от:
struct t_node{vec2d pos;double r;};
// До:
struct t_node{
  vec2d pos; double r; QapColor color; bool provider;
  vector<t_port> ports;
};
// + связи, геометрия, состояние

И ни один сохранённый объект не был потерян.

Хочешь так же?

Проект с сериализатором: [QapSerialize] :: Исходники редактора
Эта статья — лишь демо. В реальной системе есть:

  • Поддержка полиморфизма и наследования

  • Циклические ссылки без утечек

  • Текстовый дамп для отладки

  • И многое другое... Пиши в комментариях, если хочешь подробностей про внутреннее устройство!

Only registered users can participate in poll. Log in, please.
Как вы обычно решаете проблему миграции данных?
20%Пишу ручные миграционные скрипты1
0%Поддерживаю несколько версий формата0
20%Ломаю формат и прошу пересохранить данные1
0%Стараюсь не менять формат вообще0
60%Никогда об этом не задумывался3
5 users voted. 6 users abstained.
Only registered users can participate in poll. Log in, please.
Считаете ли вы автоматическую миграцию формата оправданной?
14.29%Да, это must-have для долгих проектов1
14.29%Да, но только для внутренних инструментов1
57.14%Иногда, зависит от проекта4
14.29%Нет, ручные миграции надёжнее1
0%Нет, формат не должен меняться0
7 users voted. 5 users abstained.