FHIR (Fast Healthcare Interoperability Resources) - это стандарт обмена медицинскими данными, разработанный HL7. Сегодня именно он стал основой для взаимодействия между EHR-системами, мобильными приложениями и медицинскими сервисами. Актуальные версии - R4 (2019) и R5 (2023).
Если совсем коротко: FHIR описывает ресурсы (Patient, Observation, Encounter, MedicationRequest и десятки других), которые доступны через REST API в JSON или XML. Поверх этого можно строить как пациентские приложения, так и интеграции между медицинскими организациями.
На этой базе появились patient-facing API - интерфейсы, через которые сам пациент или доверенные приложения могут получить доступ к своим данным: диагнозам, назначениям, результатам лабораторных анализов, изображениям, выпискам. В США это закреплено на уровне регуляторов (ONC Cures Act), а для разработчиков сильно помогает инициатива SMART on FHIR (OAuth2, scopes вида patient/*.read, PKCE, рефреш-токены).
Но всё это работает в теории. На практике даже два провайдера с «поддержкой FHIR R4» могут сильно отличаться, у них будут разные лимиты по количеству запросов в минуту, различный список поддерживаемых ресурсов, и даже иногда отличия в API - тот же Epic не позволяет искать Observation без указания категории. К тому же часть ресурсов вообще нельзя найти поиском по PatientID а нужно искать через проход по ссылкам внутри ранее загруженных ресурсов, что превращается в длинный и непредсказуемый процесс обхода всего дерева объектов, загрузку, опять обход и так пока не загрузили все до чего можно дотянуться.
Чтобы с этим справиться, я написал проект на Go, который решает четыре задачи:
Выгрузка - скачивает полный набор данных у провайдера, обходя дерево ссылок и учитывая лимиты API.
Обработка - нормализует ресурсы, убирает дубликаты, чинит циклы и ошибки сериализации.
Хранение - складывает данные в модель
account → patient → snapshot, чтобы обеспечить офлайн-доступ и консистентные срезы.Отдача - быстро отдает на клиент данные в FHIR формате
Зачем нужен проект
Когда я впервые пробовал FHIR-API, всё выглядело просто: авторизация по OAuth2, пара запросов, и вот JSON с данными. Но очень быстро стало ясно, что для большой части пациентов с более менее объемной медицинской историей это не работает.
Проблема для пациент-facing приложений
Пациент открывает приложение и хочет увидеть актуальные анализы или записи за пару секунд. А реальность такая:
жёсткие rate limit - список из тысяч Observation может грузиться десятки секунд;
часть ресурсов не возвращается поиском и вытягивается итеративно по ссылкам;
у «особо объемных» пациентов обход дерева может занять десятки минут.
Такое UX для конечного пользователя просто неприемлемо.
Проблема для организаций
Другая сторона - организации (клиники, страховые, исследовательские центры), с которыми пациент делится данными. Для аналитики им зачастую нужны все Observation, все DiagnosticReport и все Encounter сразу, и желательно быстро. Подключаться к Epic или Cerner каждый раз напрямую - плохая идея: это медленно, неэффективно и зависит от того, доступен ли сервер провайдера в конкретный момент.
Моё решение
Я сделал промежуточный слой:
один раз прохожу всё дерево FHIR-ресурсов у провайдера и складываю их в своём хранилище;
дальше пациентское приложение работает с этими данными напрямую, без прохода через third-party API провайдеров;
организация получает тот же срез данных целиком и может быть уверена в его консистентности.
Такой подход решает сразу обе задачи: быстрый UX для пациента и надёжный доступ для организаций.
Модель данных: Account → Patient → Snapshot
В основе системы лежит простая идея: каждый аккаунт содержит пациентов, у каждого пациента есть несколько snapshot-ов - «срезов» данных, полученных при синхронизации с FHIR-провайдерами.
Почему именно snapshot
FHIR-провайдеры медленные и с ограничениями. Но мне нужно:
быстро показывать пациенту полные данные;
иметь возможность повторной синхронизации и не тратить время на проверку существования ресурсов в процессе;
отдавать организациям стабильный срез данных без дубликатов и частичных обновлений;
не зависеть от доступности внешнего API;
переиспользовать immutable-ресурсы (старые Observation, Binary и т.п.), чтобы ускорять повторные синхронизации;
Поэтому я никогда не отдаю «сырые» данные с провайдера. Каждый сеанс формирует snapshot - изолированный набор ресурсов, который доступен клиентам только после завершения синхронизации и его обработки.
Хранение в базе
Есть несколько ключевых сущностей:
accounts - владелец данных (сам пациент или организация);
providers - настройки OAuth-провайдера;
patients - отдельные FHIR-пациенты, каждый связан с конкретным источником;
snapshots - создаются при каждой синхронизации;
fhir_resources - отдельные ресурсы (Observation, Encounter и т. д.), принадлежащие snapshot.
CREATE TABLE accounts ( id SERIAL PRIMARY KEY, public_id UUID UNIQUE NOT NULL, created_at TIMESTAMPTZ NOT NULL ); CREATE TABLE providers ( id SERIAL PRIMARY KEY, public_id UUID UNIQUE NOT NULL, fhir_api_url TEXT NOT NULL, oauth_auth_url TEXT NOT NULL, oauth_token_url TEXT NOT NULL, oauth_client_id TEXT NOT NULL, oauth_client_secret TEXT NOT NULL, provider_type TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL ); CREATE TABLE patients ( id SERIAL PRIMARY KEY, public_id UUID UNIQUE NOT NULL, account_id INTEGER NOT NULL, provider_id INTEGER NOT NULL, provider_patient_id TEXT NOT NULL, name TEXT, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT fk_patients_accounts FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE, CONSTRAINT fk_patients_providers FOREIGN KEY (provider_id) REFERENCES providers (id) ON DELETE CASCADE ); CREATE TABLE snapshots ( id SERIAL PRIMARY KEY, public_id UUID UNIQUE NOT NULL, patient_id INTEGER NOT NULL, status TEXT NOT NULL, status_description TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT fk_snapshots_patients FOREIGN KEY (patient_id) REFERENCES patients (id) ON DELETE CASCADE ); CREATE TABLE fhir_resources ( id SERIAL PRIMARY KEY, snapshot_id INT NOT NULL, resource_id VARCHAR(128) NOT NULL, resource_type VARCHAR(128) NOT NULL, resource_data JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT fk_fhir_resources_snapshots FOREIGN KEY (snapshot_id) REFERENCES snapshots (id) ON DELETE CASCADE );
Как я качаю данные из FHIR-провайдеров
FHIR вроде бы единый, но на деле каждый провайдер - свой мир. Если ограничиться GET /Observation?patient=123, можно получить неполный или даже пустой результат. А некоторые провайдеры (например Epic) вообще вернут ошибку. Поэтому я сделал гибкий пайплайн, который учитывает специфику каждого источника.
Разные стратегии
Epic: приходится запрашивать Observation по типам (
vital-signs,laboratory,social-historyи т. д.), других вариантов просто нет.Cerner: работает заметно медленнее. Я использую более агрессивную параллельность (до 20 воркеров). При этом access token выдаётся всего на 10 минут - иногда не хватает, приходится обновлять прямо в процессе. Если пациент не разрешил offline-доступ (нет refresh-токена), то синхронизацию приходится останавливать и просить его авторизоваться повторно.
VA Lighthouse: ближе к «чистому» FHIR, но часть данных тоже приходится догружать отдельными вызовами.
Пример конфигурации для EPIC выглядит примерно так:
// NewEpicDownloader creates a new instance of EpicDownloader func NewEpicDownloader(repo *repository.FhirRepository) *EpicDownloader { return &EpicDownloader{ repo: repo, observationCategories: []string{ "vital-signs", "imaging", "laboratory", "social-history", "functional-mental-status", "core-characteristics", "genomics", "labor-delivery", "lda", "newborn-delivery", "obstetrics-gynecology", "periodontal", "smartdata", }, medicationRequestCategories: []string{ "inpatient", "outpatient", "community", "discharge", }, diagnosticReportCategories: []string{ "cardiology", "radiology", "pathology", "genetics", "laboratory", "microbiology", "toxicology", "cytology", "hearing", "neurology", }, } } // DownloadSnapshot downloads FHIR resources for the given patient and stores them in the database func (c *EpicDownloader) DownloadSnapshot(ctx context.Context, provider *model.Provider, patient *model.Patient, snapshot *model.Snapshot, refreshToken string) error { accessToken, err := getAccessToken(ctx, provider, refreshToken) if err != nil { return fmt.Errorf("failed to get token: %w", err) } downloader := NewResourceDownloader(provider, patient, snapshot, accessToken, c.repo, 5) downloader.AddResourceByIdLoader(model.ResourceTypePatient, patient.ProviderPatientID) downloader.AddResourceBundleByCategoriesLoader(model.ResourceTypeObservation, c.observationCategories) downloader.AddResourceBundleByCategoriesLoader(model.ResourceTypeMedicationRequest, c.medicationRequestCategories) downloader.AddResourceBundleByCategoriesLoader(model.ResourceTypeDiagnosticReport, c.diagnosticReportCategories) downloader.AddResourceBundleLoader(model.ResourceTypeAllergyIntolerance) downloader.AddResourceBundleLoader(model.ResourceTypeAppointment) downloader.AddResourceBundleLoader(model.ResourceTypeCondition) downloader.AddResourceBundleLoader(model.ResourceTypeDeviceRequest) downloader.AddResourceBundleLoader(model.ResourceTypeDevice) downloader.AddResourceBundleLoader(model.ResourceTypeDocumentReference) downloader.AddResourceBundleLoader(model.ResourceTypeEncounter) downloader.AddResourceBundleLoader(model.ResourceTypeImmunization) downloader.AddResourceBundleLoader(model.ResourceTypeProcedure) downloader.Run(ctx) return ctx.Err() }
Обход ссылок: мой FHIRReferenceVisitor
В процессе получения ресурсов мне нужно проверять наличие в них ссылок на другие FHIR объекты которые не возвращаются search-запросами (зависит от провайдера и его API). Для этого приходится проходиться по каждому FHIR объекту и искать в нем вложенные Reference.
Для этого я сделал отдельный компонент — FHIRReferenceVisitor. Формально это не «чистый» паттерн Visitor в стиле GoF, а скорее ручной обходчик с диспетчеризацией по типам и колбэком для найденных ссылок. Но по сути идея та же: вынести логику обработки ссылок из моделей в отдельный объект.
Пример использования:
visitor := model.NewFHIRReferenceVisitor(func(ref *fhir.Reference, stack *types.ListStack[any], names *types.ListStack[string]) { fmt.Printf("found reference: %s (path: %v)\n", ref.Reference, names.ToSlice()) }) // запускаю обход ресурса visitor.Visit(observation)
Под капотом Visit(obj any) делает switch по типам и вызывает нужный visitXxx. Например, для Observation обходятся поля Subject, Encounter, Device, Result и т. д.
func (v *FHIRReferenceVisitor) visitObservation(obj *fhir.Observation) { v.visitWithName("Observation", func() { visitSlice(v, obj.BasedOn, "BasedOn") visitSlice(v, obj.PartOf, "PartOf") visitObject(v, obj.Subject, "Subject") visitObject(v, obj.Encounter, "Encounter") visitObject(v, obj.Device, "Device") visitSlice(v, obj.HasMember, "HasMember") visitSlice(v, obj.DerivedFrom, "DerivedFrom") }) }
Чтобы не потерять контекст, я использую два стека:
objects- текущая цепочка объектов, через которые иду;names- имена полей (например,Observation.Result → DiagnosticReport → Subject).
Почему так удобно
Явный контроль: видно, какие поля обходятся. Никакой «магической» рефлексии.
Расширяемость: добавился новый ресурс => я пишу
visitSomethingи встраиваю его в общий обход, изменение достаточно простое и локализованное.Гибкость: одна колбэк-функция может и собирать ссылки, и строить граф для догрузки, и логировать путь.
Минусы
Код многословный: десятки
visit***.Обновление FHIR-версии требует ручного апдейта обходчика.
Для моего сценария это оправдано: я хочу полный контроль над тем, какие ссылки я тяну из ресурсов, и возможность гибко работать с ними в пайплайне синхронизации.
Ограничение параллельности
Чтобы не «заддосить» провайдера и не вылететь по лимитам, я использую sync.WaitGroup и семафор на базе канала:
// executeLoaders executes all resource loaders func (s *state) executeLoaders(ctx context.Context, ch chan<- *model.FHIRResourceRaw, loaders []loader, maxDegreeOfParallelism int) { var wgDownloads sync.WaitGroup // Semaphore to limit concurrency sem := make(chan struct{}, maxDegreeOfParallelism) for _, l := range loaders { wgDownloads.Add(1) go func(loader loader) { logger := logs.LoggerFromContext(ctx) defer wgDownloads.Done() // Acquire semaphore sem <- struct{}{} defer func() { <-sem }() // Release semaphore select { case <-ctx.Done(): return default: if err := loader(ctx, ch); err != nil { logger.Error("Error loading resource", "error", err) } } }(l) } wgDownloads.Wait() }
degreeOfParallelism задаётся отдельно для каждого провайдера: для Cerner - 20, для Epic/VA - 5.
API для отдачи данных и важность GZIP
Когда данные уже выгружены и сохранены в snapshot, следующий шаг - отдать их клиенту максимально быстро. Для этого у меня в go-fhir-storage есть слой API, который позволяет:
получить последний завершённый snapshot пациента;
скачать отдельные ресурсы или весь набор в виде FHIR Bundle;
Поскольку данные FHIR формируют высокосвязанный граф, для клиентских приложений целесообразно иметь возможность однократно загрузить их полный набор (за исключением бинарных документов) на свою сторону.
Такой подход обеспечивает локальную навигацию и выполнение запросов без постоянных обращений к серверу и избыточного сетевого обмена. В связи с этим основное внимание было уделено оптимизации механизма загрузки бандлов, содержащих полную коллекцию ресурсов на клиент.
А значит нам просто необходимо GZIP
FHIR-данные в JSON могут быть огромными. У пациента с тысячами Observation общий размер ответа легко превышает мегабайт. Если отдавать это «как есть», то даже при быстрой сети клиент будет ждать, а при низком качестве мобильной связи - будет ждать слишком долго.
Здесь спасает сжатие на уровне HTTP. В моих тестах для пациента с ~9000 Observation это даёт драматическую разницу (на примере запроса с рабочего компьютера к серверу в ДЦ Azure):
Формат ответа | Время загрузки | Размер ответа |
|---|---|---|
JSON (без gzip) | ~15 секунд | ~5 МБ |
JSON (с gzip) | ~1 секунда | ~500 КБ |
То есть ускорение и уменьшение объема траффика - почти на порядок.
Поэтому я считаю gzip не «опциональным бонусом», а обязательным элементом API, если речь идёт о работе с FHIR и JSON. Причем таким образом сокращается траффик даже на бинарных ресурсах, т.к. они передаются фактически в JSON с BASE64 контентом, который тоже вполне имеет запас по сжатию.
Простая реализация на Go
В Go поддержка компрессии ответов встроена. Достаточно обернуть обработчик в handlers.CompressHandler():
// Create the server server := &http.Server{ Addr: address + ":" + port, Handler: handlers.CompressHandler(r), }
Практические результаты
Небольшое лирическое отступление.
Когда я начинал этот проект, у меня уже был продуктовый опыт работы с полноценным FHIR-сервисом. Там всё было «по канону»: микросервисная архитектура, нормализованное хранение сущностей в PostgreSQL, гибкое API, которое закрывало все наши продуктовые задачи.
У такого подхода было два минуса:
Скорость. Для тестового пациента с тысячами Observation сервер отдавал данные клиенту до минуты. А полная загрузка или повторная синхронизация данных с провайдера занимала до 30 минут изза постоянных roundtrip-в в API нашего FHIR сервера, т.к. он не поддерживал нужные нам для пакетного импорта фичи.
Ресурсы. Поддержка сервиса требовала с полдюжины pod-ов в Kubernetes, каждый с JVM внутри. Решение было тяжёлым и прожорливым.
Я начал go-fhir-storage как эксперимент: а что если отказаться от «слоёного пирога» микросервисов и попробовать работать с FHIR-данными на более низком уровне, ближе к железу и проще по архитектуре?
Что получилось
Скорость:
Повторная синхронизация «тяжёлого» пациента с 9000 Observation сократилась с 30 минут до 1–2 минут за счёт параллельной обработки запросов, оптимизации вставки в базу, а так же есть запас на ускорение путем переиспользования иммутабельных ресурсов из предыдущих снапшотов.
Отдача данных клиенту из готового snapshot вместо запросов к полноценному FHIR сервису или тем более провайдеру в большинстве случае укладывается в доли секунды вместо десятков секунд если не минут.
Есть возможность легко кешировать данные либо на клиенте либо на сервере, с простой инвалидацией кеша т.к. snapshot является неизменяемым.
Ресурсоёмкость:
Весь сервис работает как монолит на Go, потребляет заметно меньше CPU и памяти.
На тестовом стенде достаточно одного pod-а без тяжёлой JVM.
Размер данных:
При включённом gzip объем данных загружаемый на клиента на типовых пациентах не превышает сотен килобайт.
Для клиента это разница ощущается как «сразу открылось» вместо «ждём полминуты».
Выводы
Главное, что я вынес из этого эксперимента: не всегда нужна сложная микросервисная архитектура, особенно если речь идёт о задаче «выгрузить → обработать → отдать». Иногда проще и эффективнее собрать аккуратный монолит, заточенный под конкретный сценарий.
Для меня go-fhir-storage стал способом проверить гипотезу: можно ли упростить архитектуру и при этом улучшить UX для пациента и организаций. Ответ кажется положительным.
