Force DI, або Як створити гнучку архітектуру в SalesForce
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Всім привіт! Я — Віталій Драпак, Salesforce Developer в Redtag. З CRM-системою Salesforce працюю близько трьох років. Працював над різноманітними проєктами, як від маленьких internal систем, так і над enterprise проєктами з великими обсягами даних (останній проєкт нараховував близько 100 мільйонів рекордів сумарно).
За весь цей час пробував різні фреймворки і технології, але до душі припав дивовижний фреймворк — Force DI. На превеликий жаль, моє початкове знайомство з Force DI не було успішним, оскільки на той момент не було достатньо якісної документації і хороших прикладів. Проте, методом спроб і помилок я зміг розвинути знання і досвід, які й постараюсь передати в цій статті якомога ясніше.
Надіюсь, ця стаття допоможе комусь швидше і всеохопніше зрозуміти, як працювати з Force DI.
Початок
Уявімо собі, що вам доручено створити логіку, яка збиратиме дані про погоду із зовнішньої бази даних і повертатиме результат. Ви успішно виконали це завдання, написавши controller і callout клас:
WeatherController:
/*...*/ public class WeatherController { /** * ───────────────────────────────────────────────────────────────────────────────────────────────┐ * Retrieves weather information by calling the appropriate WeatherCallout implementation. * ──────────────────────────────────────────────────────────────────────────────────────────────── * @return List of weather results as Objects. * ───────────────────────────────────────────────────────────────────────────────────────────────┘ */ @AuraEnabled(cacheable=true) public static List<Object> getWeather(){ WeatherCallout c = new OpenWeatherMapCallout(); return c.getWeather(); } }
WeatherCallout:
/*...*/ public interface WeatherCallout { List<Object> getWeather(); }
OpenWeatherMapCallout:
/*...*/ public class OpenWeatherMapCallout implements WeatherCallout{ /** * ───────────────────────────────────────────────────────────────────────────────────────────────┐ * Retrieves the weather information using OpenWeatherMap API * ──────────────────────────────────────────────────────────────────────────────────────────────── * @return A list of weather results as Objects. * ───────────────────────────────────────────────────────────────────────────────────────────────┘ */ public List<Object> getWeather() { HttpResponse response = getHttpData(getOpenWeatherParams()); List<Object> weatherResults = new List<Object>(); if (response.getStatusCode() == 200) { Map<String, Object> results = (Map<String, Object>) JSON.deserializeUntyped(response.getBody()); if (results.containsKey('weather')) { weatherResults = (List<Object>) results.get('weather'); } } return weatherResults; } /** * ───────────────────────────────────────────────────────────────────────────────────────────────┐ * Performs an HTTP callout to retrieve weather data based on the provided parameters. * ──────────────────────────────────────────────────────────────────────────────────────────────── * @param params WeatherRequestParams object containing necessary parameters for the API request. * @return The HttpResponse object containing the response from the API. * ───────────────────────────────────────────────────────────────────────────────────────────────┘ */ private HttpResponse getHttpData(WeatherRequestParams params) { Http http = new Http(); HttpRequest request = new HttpRequest(); request.setEndpoint(getEndpoint(params)); request.setMethod('GET'); HttpResponse response = http.send(request); return response; } /** * ───────────────────────────────────────────────────────────────────────────────────────────────┐ * Retrieves the WeatherRequestParams object with the OpenWeatherMap API parameters. * ──────────────────────────────────────────────────────────────────────────────────────────────── * @return The WeatherRequestParams object with the OpenWeatherMap API parameters. * ───────────────────────────────────────────────────────────────────────────────────────────────┘ */ private WeatherRequestParams getOpenWeatherParams(){ WeatherRequestParams params = new WeatherRequestParams(); params.setUrl('https://api.openweathermap.org/data/2.5/weather?'); params.setCity('CITY_INFO'); params.setApiKey('YOUR_APP_ID'); params.setMetric('METRIC_INFO'); return params; } /** * ───────────────────────────────────────────────────────────────────────────────────────────────┐ * Constructs the endpoint URL based on the provided WeatherRequestParams. * ──────────────────────────────────────────────────────────────────────────────────────────────── * @param params The WeatherRequestParams object containing the necessary parameters. * @return The constructed endpoint URL as a String. * ───────────────────────────────────────────────────────────────────────────────────────────────┘ */ private String getEndpoint(WeatherRequestParams params){ return params.getUrl() + 'city=' + params.getCity() + '&appid=' + params.getApiKey() + '&units=' + params.getMetric(); } }
WeatherRequestParams:
/*...*/ public class WeatherRequestParams { private String url; private String city; private String latitude; private String longitude; private String apiKey; private String metric; public String getUrl(){ return url; } public void setUrl(String url){ this.url = url; } public String getCity(){ return city; } public void setCity(String city){ this.city = city; } public String getLatitude(){ return latitude; } public void setLatitude(String latitude){ this.latitude = latitude; } public String getLongitude(){ return longitude; } public void setLongitude(String longitude){ this.longitude = longitude; } public String getApiKey(){ return apiKey; } public void setApiKey(String apiKey){ this.apiKey = apiKey; } public String getMetric(){ return metric; } public void setMetric(String metric){ this.metric = metric; } }
Але через декілька місяців ви отримуєте запит замінити сервіс отримання даних про погоду на новий, і з цього моменту починаються проблеми. Щоб змінити сервіс у вашому коді, потрібно перелопатити цей код і змінити його структуру, що суперечить принципам SOLID. І тут вступає в хід Force DI.
Що таке Force DI
Force DI — це фреймворк для управління залежностями між компонентами у програмному забезпеченні. Він дозволяє створити зовнішні звʼязки між класами, замість прямих залежностей. Це дозволяє оперативно і якісно збудувати архітектуру програмного забезпечення, яка може бути розширена без значних змін коду.
У Force DI залежності передаються в об’єкт через конструктор, методи або властивості. Це дозволяє замінювати реальні залежності на фейкові об’єкти під час тестування, а також легко замінювати реалізацію залежностей без внесення змін у код.
За допомогою даної технології ми можемо реалізовувати дані пункти:
- Ін’єкція залежностей: залежності передаються в об’єкти ззовні, замість їх створення всередині самого об’єкта.
- Інверсія керування: об’єкти не самостійно керують своїми залежностями, а отримують їх від зовнішнього джерела, яке відповідає за їх створення та конфігурацію.
- Розділення відповідальності: код класу концентрується на своїй основній функціональності, а залежності знаходяться відокремлено і можуть бути легко змінені.
Інсталювання:
Щоб оцінити всі переваги та недоліки цього фреймворку, нам потрібно для початку встановити його на свій проєкт. Force DI — це open-source unmanaged package, тому вихідний код можна брати напряму з репозиторію.
Алгоритм встановлення Force DI на оргу:
1. Клонуємо Force DI фреймворк з репозиторію: git clone github.com/...ise-patterns/force-di.git
2. Створюємо новий проєкт в vs code на основі клонованого репозиторію.
3. Авторизуємось на свою оргу з vs code. Цю операцію можна зробити, використовуючи графічний інтерфейс: «Change Default Org» -> «Authorize an Org» -> Вибираєте «Sandbox» -> вводите креденшали -> надаєте доступ, або прописавши sfdx force:auth:web:login -r test.salesforce.com в терміналі.
4. Деплоїмо проєкт, виконуючи команду sfdx force:source:deploy в терміналі або через графічний інтерфейс
Вуаля! Force DI готовий для використання. На цьому моменті нам потрібно створити новий Callout клас, який буде робити запит до нового сервісу даних про погоду:
/*...*/ public class OpenMeteoCallout implements WeatherCallout{ /** * ───────────────────────────────────────────────────────────────────────────────────────────────┐ * Retrieves the weather information using OpenMeteo API. * ──────────────────────────────────────────────────────────────────────────────────────────────── * @return A list of weather results as Objects. * ───────────────────────────────────────────────────────────────────────────────────────────────┘ */ public List<Object> getWeather() { HttpResponse response = getHttpData(getOpenMeteoParams()); List<Object> weatherResults = new List<Object>(); if (response.getStatusCode() == 200) { Map<String, Object> results = (Map<String, Object>) JSON.deserializeUntyped(response.getBody()); Map<String, Object> currentWeatherHourly = (Map<String, Object>) results.get('hourly'); if (currentWeatherHourly != null) { weatherResults = (List<Object>) currentWeatherHourly.get('temperature_2m'); } } return weatherResults; } /** * ───────────────────────────────────────────────────────────────────────────────────────────────┐ * Performs an HTTP callout to retrieve weather data based on the provided parameters. * ──────────────────────────────────────────────────────────────────────────────────────────────── * @param params WeatherRequestParams object containing necessary parameters for the API request. * @return The HttpResponse object containing the response from the API. * ───────────────────────────────────────────────────────────────────────────────────────────────┘ */ private HttpResponse getHttpData(WeatherRequestParams params) { Http http = new Http(); HttpRequest request = new HttpRequest(); request.setEndpoint(getEndpoint(params)); request.setMethod('GET'); HttpResponse response = http.send(request); return response; } /** * ───────────────────────────────────────────────────────────────────────────────────────────────┐ * Retrieves the WeatherRequestParams object with the OpenMeteo API parameters. * ──────────────────────────────────────────────────────────────────────────────────────────────── * @return The WeatherRequestParams object with the OpenMeteo API parameters. * ───────────────────────────────────────────────────────────────────────────────────────────────┘ */ private WeatherRequestParams getOpenMeteoParams(){ WeatherRequestParams params = new WeatherRequestParams(); params.setUrl('https://api.open-meteo.com/v1/forecast?'); params.setCity('СITY_INFO'); params.setMetric('METRIC_INFO'); return params; } /** * ───────────────────────────────────────────────────────────────────────────────────────────────┐ * Constructs the endpoint URL based on the provided WeatherRequestParams. * ──────────────────────────────────────────────────────────────────────────────────────────────── * @param params The WeatherRequestParams object containing the necessary parameters. * @return The constructed endpoint URL as a String. * ───────────────────────────────────────────────────────────────────────────────────────────────┘ */ private String getEndpoint(WeatherRequestParams params){ return params.getUrl() + 'city=' + params.getCity() + '&temperature_unit=' + params.getMetric(); } }
Також потрібно буде модифікувати WeatherController:
/*...*/ public class WeatherController { /** * ───────────────────────────────────────────────────────────────────────────────────────────────┐ * Retrieves weather information by calling the appropriate WeatherCallout implementation. * ──────────────────────────────────────────────────────────────────────────────────────────────── * @return List of weather results as Objects. * ───────────────────────────────────────────────────────────────────────────────────────────────┘ */ @AuraEnabled(cacheable=true) public static List<Object> getWeather(){ WeatherCallout c = (WeatherCallout) di_Injector.Org.getInstance( WeatherController.class ); return c.getWeather(); } }
І тут починається найцікавіше:
- Що таке di_Injector.Org.getInstance()?
- Як ми отримуємо інстанс WeatherCallout інтерфейсу?
- Для чого ми передаємо WeatherController.class як параметр?
Пояснення:
1. di_Injector.Org.getInstance() - використовується для отримання екземпляра класу, вказаного як параметр, з контейнера DI. Контейнер DI керує створенням екземплярів і керуванням залежностями, дозволяючи створення слабкого зв’язку (loose coupling) між компонентами. В цьому випадку, створення та керування залежностями є відокремленим від коду, який покладається на них. Це полегшує тестування, оскільки ви можете імітувати або замінювати залежності під час модульного тестування. Він також сприяє модульному коду, який можна підтримувати, зменшуючи щільний зв’язок (tight coupling), і збільшуючи можливість повторного використання коду.
2. Force DI динамічно створює екземпляр класу за допомогою конфігурації метадати, яку ми розглянемо трошки пізніше.
3. Ми передаємо WeatherController.class
як параметр у метод di_Injector.Org.getInstance()
, оскільки так ми отримуємо екземпляр потрібного класу. У Force DI, назва di_binding
метадата рекорду може бути ідентичною до класу, для якого потрібно створити екземпляр інтерфейсу(вважається Best Practice). Передаючи WeatherController.class
як параметр у di_Injector.Org.getInstance()
, di_binding
рекорду і створення екземпляра класу, який реалізує WeatherCallout
, згенерований в di_binding
рекордом, забезпечуючи коректне створення екземплярів потрібної логіки та уникнення проблем, пов’язаних з неправильною конфігурацією
Metadata Configuration
1. Для початку потрібно зайти в Setup;
2. Заходимо на сторінку Home → В пошуковому вікні набираємо Metadata → Вибираємо Custom Metadata Types → Натискаємо Manage Records посилання на рекорді Binding:
3. Натискаємо кнопку «New» і в нас висвітлюється вікно для створення нового рекорду. Нижче пояснене призначення для кожного поля:
- Label — назва binding рекорду;
- Binding Name — це унікальний ідентифікатор звʼязку, за яким ми шукаємо потрібний рекорд і звʼязуємо функціонал. В нашому випадку це WeatherController;
- To — вибрана імплементація (в нашому випадку може бути: OpenWeatherMapCallout, або OpenMeteoCallout);
- Type — тип звʼязку. Можливі варіанти: Apex, Lightning Component, Visualforce Component, Flow, Module. Ми вибираємо Apex, оскільки викликаємо створення звʼязку з Apex коду;
- Binding Object — можна вибрати потрібний sObject, коли Binding Record потрібно звʼязати з конкретним обʼєктом. В нашому випадку нерелевантно;
- Binding Object Alternate — альтернатива конкретного обʼєкту;
- Binding Name(Additional Binding Criteria) — якщо в системі є пара рекордів з ідентичним Binding Name(b), то назва з цього поля буде використана для пошуку коректного рекорду;
- Binding Sequence — сортує звʼязки за вказаним номером, якщо є декілька рекордів з ідентичним Binding Object.
4. Наш створений рекорд виглядає ось так:
Якщо зробити запит WeatherController.getWeather() в анонімному вікні, то ми отримаємо такий результат:
(19.1, 18.5, 18.0, 17.5, 17.7, 18.6, 20.3, 21.9, 23.1, 24.6, ...)
Якщо в метадаті змінити значення з «OpenMeteoCallout» на «OpenWeatherMapCallout», то результат буде такий:
({description=clear sky, icon=01n, id=800, main=Clear}
Підсумовуючи, Force DI є потужним інструментом, який дозволяє гнучко розбудовувати архітектуру та перевикористовувати код. Використання цієї технології дозволяє зменшити залежність між компонентами системи і полегшити підтримку коду.
Однак, важливо мати на увазі, що недоцільне або неправильне використання Force DI може призвести до збільшення складності коду. Тому, важливо використовувати цей функціонал з розумом і дотримуватися best practices.
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів