Як модернізувати Java легасі-код
Всім привіт! На зв’язку Олексій. Я — Java Fullstack-розробник у компанії Yalantis на проєкті ринкових бірж, опціонів та облігацій. Мій досвід охоплює чимало напрямків Java-розробки, таких як SaaS-платформи, Computer vision, DNA processing, Gaming monetization, Bank activity та багато іншого.
Наразі моя діяльність зосереджена на фінансовій сфері.
Часто фінансові продукти здебільшого пов’язані із застарілим кодом (хоча є і новітні продукти, але не про них мовиться). Мені доводиться працювати з легасі-кодом, що може бути досить складним завданням.
Цей досвід спонукає мене поділитися власними переживаннями та «болем» з вами. Такий код часто вимагає більше часу на розуміння та виправлення, не кажучи вже про внесення будь-яких нововведень. Сподіваюся, моя історія знайде відгук в аудиторії, що стикається з подібними викликами.
Чому ж таки ця тема може бути цікавою? Ну, наприклад, коли зустрічаєш щось на зразок:
private List<Market> classicalmambojumbo() { List<Market> list = new ArrayList<>(activeAssets.keySet()); Collections.sort(list); if (user.getQuoteType() == QuoteType.BATS) { Set<Market> needed = new HashSet<>(); needed.add(Market.BATS); needed.add(Market.CANADA); needed.add(Market.OPRA); list.removeIf(m -> !needed.contains(m)); } else if (user.getQuoteType() == QuoteType.CBOE1) { Set<Market> needed = new HashSet<>(); needed.add(Market.CBOE1); needed.add(Market.CANADA); needed.add(Market.OPRA); list.removeIf(m -> !needed.contains(m)); } else list.removeAll(Arrays.asList(Market.BATS, Market.CBOE1)); return list; }
У фрагменту коду представлені складнощі роботи з колекціями та умовною логікою, що вимагає видалення або фільтрації даних залежно від певних умов.
Нам потрібно відфільтрувати дані залежно від заданих критеріїв, що збільшує ризик помилок і вимагає глибокого занурення в деталі. Це підкреслює важливість обережного підходу під час роботи з таким кодом, щоб уникнути проблем.
І якщо тут плюс-мінус дещо можливо зрозуміти (за винятком назви, звісно), наступний метод викликає моторошність:
protected void printHistoryForBilling(User user){ Map<Market, List<Exchange>> map = user.getExchangeMapping(); List<Exchange> agrMap = map.get(market); int size = agrMap.size(); if(size == 0 ){ }else if(size>1){ //find the recent contract based on pro and no npro status Exchange latestNonProAgr = null; Exchange latestProAgr= null; for(Exchange agr: agrMap) { //Ignore deleted contracts in billing history if(agr.getStatus().equalsIgnoreCase("D")){ continue; } if(agr.getIsProfessional()){ if(latestProAgr == null){ latestProAgr = agr; } //logic if(agr.getActivatedOn().getTime() > latestProAgr.getActivatedOn().getTime()){ latestProAgr = agr; } }else{ if(latestNonProAgr == null){ latestNonProAgr = agr; } //logic if(agr.getActivatedOn().getTime() > latestNonProAgr.getActivatedOn().getTime()){ latestNonProAgr = agr; } } } if(latestNonProAgr!=null) { printContractReport(latestNonProAgr, user); } if(latestProAgr!=null){ printContractReport(latestProAgr, user); } }else { if(!agrMap.get(0).getStatus().equalsIgnoreCase("D")){ printContractReport(agrMap.get(0), user); } } }
Річ у тім, що пустий блок,
if(size == 0 ){ }
викликає питання, як мінімум, чому він пустий і чому немає логування всередині для подальшого відстежування таких випадків?
Далі, у разі використання Java новіших версій, можливо, було б змінити цикл for(Exchange agr: agrMap)
на agrMap.stream
, хоча, кому як зручно.
Але найбільшу складність викликає багатошаровість if -> else
, і тому потрібно глибоко зануритись та чітко зрозуміти саму бізнес-логіку.
Рефакторинг цих функцій не буду розкривати у статті, дещо згодом наведу інші приклади, але розгляньмо загальні підходи до модернізації Java легасі-коду та приклади успішних реалізацій.
У світі інформаційних технологій легасі-код визначається як програмний код, який був розроблений десятиліття тому і залишається активним, незважаючи на зміни в технологіях та вимогах до ПЗ. Часто це стає викликом для компаній, оскільки старий код може ускладнювати розвиток, утримання та масштабування систем.
Чому ж таки потрібна модернізація легасі-коду або важливість його модернізації?
Однією з основних причин модернізації легасі-коду є забезпечення сумісності з останніми технологічними та безпековими стандартами. Старі версії програмного забезпечення можуть бути вразливими перед новими кіберзагрозами й не використовувати переваги нових технологій.
Модернізація дозволяє впроваджувати сучасні підходи до розробки, використовувати нові бібліотеки та інструменти для покращення ефективності та швидкодії системи. Ще однією важливою причиною модернізації є поліпшення масштабованості та обслуговуваності системи.
Легасі-код може бути монолітним та важко розширюваним, що ускладнює внесення змін та вдосконалення. Застосування модульного підходу, мікросервісної архітектури та інших сучасних принципів допомагає робити систему більш гнучкою та легше розширювальною.
Етапи модернізації легасі-коду
Аналіз коду
Першим етапом є докладний аудит і аналіз наявного коду. Це допомагає з’ясувати його стан, визначити слабкі місця та стратегію модернізації. Тут на допомогу можуть прийти статичні аналізатори коду, такі як SonarQube, Sonar lint які виявляють потенційні баги або вразливі місця, що жили роками.
На мою думку, найбільше в розумінні коду зможе допомогти побудова функціональних та флоу діаграм. Саме після складання такого роду діаграм можна чітко зрозуміти не тільки те, як влаштований код всередині, але й саму бізнес-логіку.
Й ось тоді можливо визначити, які частини коду потребують негайної уваги під час аудиту.
Реінжиніринг
На основі результатів аналізу виконується реінжиніринг коду. Це може містити виправлення помилок, оптимізацію, адаптацію до нових архітектур та впровадження сучасних практик розробки. Як варіант, можна згрупувати знайдені помилки та підходи оптимізацій за групами, скажімо, від найбільшого до найменшого:
Критичні, важливі, середні, мінорні та скласти план правок за категоріями.
Не слід забувати й про впровадження стилю коду, бо це значно полегшить його сприйняття. Як правило складають загальний файл формату xml, де й будуть прописані всі правила форматування та задані відступи, який надалі буде спільно використовуватись розробниками в середовищах розробки.
У випадку складних оптимізацій або перероблень, саме бізнес має вирішувати, наскільки критично та важливо переробити ту чи іншу частину функціонала з нуля, чи додати мінімально необхідні правки. Завдання розробника саме вказати на проблемне місце та запропонувати варіанти рішень.
Тестування
Після внесення змін важливо провести інтенсивне тестування. Це дозволяє виявити можливі проблеми та переконатися, що новий код працює стабільно та ефективно.
Важливий нюанс: скласти необхідний набір тестів потрібно до модернізації, для того, щоб можна було провести регресію. Хоча інколи це і проблематично за відсутності відділу тестування, і переважно тестувальники знають найбільше про продукт.
Важливим аспектом модернізації є також залучення команди розробників, яка має розуміння як наявної системи, так і нових технологій. Співпраця та обмін знанням допомагають забезпечити успішне завершення проєкту. Покриття тестами теж внесе вагомий вплив, особливо за наявності quality gates.
Документація
Під час модернізації легасі-коду важливо створювати та оновлювати документацію. Це сприяє розумінню нового коду та полегшує подальшу підтримку продукту.
Дуже часто в якості інструментів для введення та збереження документації, використовують Google Docs, Drive. Проте існує чудовий Google-плагін під назвою Scribe, який генерує покрокову інструкцію, просто записуючи виконані кроки додаючи скриншоти та описи. Як на мене, це дуже зручно, оскільки зберігає багато часу для написання документації.
Підходи до Модернізації
Рефакторинг коду
Рефакторинг є одним з основних методів модернізації легасі-коду. Цей процес містить зміну структури коду без зміни його зовнішньої функціональності. Використання сучасних підходів до проєктування, поділ коду на модулі та впровадження патернів програмування дозволяють покращити читабельність та обслуговуваність коду. Проте інколи такого роду рефакторинг може зламати наявний функціонал,
тому потрібно бути вкрай обережним під час перебудови чинного коду, застосовуючи покриття тестами.
Ось тут дуже важливий етап — ефективне код-рев’ю. І забезпечити цю ефективність допоможуть механізми (GitLab, GitHub servers) для створення так званих Pull request
або Merge request
.
Тепер розглянемо приклад оптимізації та рефакторингу наступного методу:
@POST @Path("/terminate") @Consumes(MediaType.APPLICATION_JSON) public BasicResponse terminate(@Context Context context, TerminationRequest request) { .... .... // Terminate all positions if (request.isRemoveAllPositions()) { try { UserPortfolio manager = new UserPortfolio(dbs); List<MarketSettings> markets = new Util(dbs).getMarketSettings(); MarketSettings primaryMarket = new Util(dbs).getPrimaryMarket(); for (Account account : accounts) { PortfolioList portfolio = manager.loadPositions(account.getAccountId()); portfolio.loadQuotes(proxyClient); for (PortfolioEntry entry : portfolio) { Quote quote = entry.getQuote(); MarketSettings market = getMarketSettings(markets, primaryMarket, quote); List<String> errors = uac.credit(account.getAccountNumber(), quote, "REM", "Terminate account with all positions", entry.getQuantity(), quote.getLastPrice(), entry.getPosType(), 0, bouser.getUsername(), market, quote.getLastPrice(), -1, null, IdentifierType.ExchangeSymbol); if (errors != null && errors.size() > 0) { return BasicResponse.Failure() .setContent(errors.stream().collect(Collectors.joining("\n"))); } } } } catch (Exception e) { Logger.error(e.getMessage(), e); return BasicResponse.Failure().setContent(e.getMessage()); } } // Terminate given user and accounts String note = String.format("Initiated by %s - (%s)", bouser.getUsername(), request.getNote()); if (control.terminateUserAndAccounts(bouser, request.getUsername(), note)) { return BasicResponse.Success(); } return BasicResponse.Failure().setContent("Termination failed"); }
Так виглядав старий робочий ендпоінт, який необхідно було оптимізувати у зв’язку з повільним відпрацюванням.
По суті, вся робота зводилась у завантаження всіх облікових записів для даного користувача, далі для кожного такого запису завантажувався фінансовий портфель і викликалась збережена процедура з видалення всіх позицій з портфеля. Свого роду очищення облікового запису.
Тут можна спостерігати циклічну вкладеність, а основною складністю є виклик дуже громіздкої збереженої процедури всередині методу credit.
Ще додатковим ускладненням був виклик метода portfolio.loadQuotes(proxyClient)
в середині першого циклу, бо це було звернення на сторонній сервіс. На перший погляд, для оптимізації можна було б створити довідник Map<Account, PortfolioList>
і вже потім, через ітерацію, викликати метод credit
. Проте основною вимогою було виконати всі дії в рамках однієї транзакції, тобто «все або нічого».
Як варіант, рішення можна було б зробити приблизно так:
try (Connection con = source.getConnection()) { con.setAutoCommit(false); //iterate over map and invoke credit method con.commit(); } catch(Exception e) { con.rollback(); } finally { con.setAutoCommit(true); }
Проте доцільніше зробити окрему збережену процедуру, яка б завантажувала всі необхідні дані (як виявилось, всі вони були у базі) на основі вхідних параметрів, і вже потім, у разі помилки на будь-якому етапі, транзакція відкотилася б.
Скажімо так:
CREATE PROCEDURE [dbo].[sp_TotalCredit] @UserId AS INTEGER, @RemoveAllPositions AS TINYINT = 0 AS DECLARE @Err AS INT = 0 DECLARE @Msg AS VARCHAR(255) = 'Success' BEGIN TRANSACTION IF (@RemoveAllPositions = 1) BEGIN //logic here IF @@ERROR <> 0 BEGIN SET @Err = 100 SET @Msg = 'Failed to complete operation' GOTO exit_call END END exit_call: IF @@TRANCOUNT > 0 BEGIN IF @Err = 0 COMMIT TRANSACTION ELSE ROLLBACK TRANSACTION END SELECT @Err AS Err, @Msg AS Msg GO
Знову ж таки, вибір підходу залежить винятково від наявних ресурсів та задіяних технологій. Загалом, реалізуючи цей підхід, вдалось досягти швидкодії майже в вдесятеро разів.
Ось так він почав виглядати після:
@POST @Path("/terminate") @Consumes(MediaType.APPLICATION_JSON) public BasicResponse terminate(@Context Context context, TerminationRequest request) { .... .... String note = String.format("Initiated by %s - (%s)", bouser.getUsername(), request.getNote()); if (control.terminateUserAndAccounts(bouser, request.getUsername(), note, request.isRemoveAllPositions())) { return BasicResponse.Success(); } return BasicResponse.Failure().setContent("Termination failed"); }
Помітна значна різниця, а більшість логіки просто перенесена в збережену процедуру.
Впровадження Unit-тестування та за можливості Integration-тестування
Введення системи тестування дозволяє виявляти та виправляти помилки на ранніх етапах розробки. Використання unit-тестів забезпечує стабільність та надійність програмного коду, а також полегшує процес внесення змін без необхідності великого масштабу тестування.
Варто згадати Test-Driven Development та Behaviour-Driven Development через те, що ці підходи розробки дозволяють глибше зрозуміти бізнес-логіку, особливо на етапі модернізації коду. Тому потрібно не нехтувати впровадженням розробки через написання тестів. TDD або BDD — вибір за вами!
Наведу приклад недосконалого тесту:
@Test public void test010_ServerTime() throws IOException { WatchmenService service = getService(); // ініціалізація сервісу ServerTime time = service.getTime(); Assert.assertNotNull(time); System.out.println("Time: " + time.getTime()); System.out.println("Timezone: " + time.getTimezone()); Assert.assertNotNull(time.getTime()); Assert.assertNotNull(time.getTimezone()); }
У цьому прикладі проста перевірка на null не забезпечить повної гарантії правильності методу, бажано додати перевірку відповідності формату часу, а також відповідність часової зони.
Покращити його можна так:
class FormattedDateMatcher implements DateMatcher { private static Pattern DATE_PATTERN = Pattern.compile( "^\\d{4}-\\d{2}-\\d{2}$"); @Override public boolean matches(String date) { return DATE_PATTERN.matcher(date).matches(); } } @Test public void test010_ServerTime() throws IOException { DateMatcher dateMatcher = getDateMatcher(); WatchmenService service = getService(); // ініціалізація сервісу ServerTime serviceTime = service.getTime(); Assert.assertNotNull(serviceTime); System.out.println("Time: " + serviceTime.getTime()); System.out.println("Timezone: " + serviceTime.getTimezone()); Assert.assertNotNull(serviceTime.getTime()); Assert.assertNotNull(serviceTime.getTimezone()); Assert.assertEquals(serviceTime.getTimezone(), TimeZone.getTimeZone(“Europe/Kyiv”)) Assert.assertTrue(dateMatcher.matches(serviceTime.getTime)) }
Особливого значення хотілося б приділити CI/CD-процесам. Вибір засобу забезпечення CI/CD (Gitlab, Jenkins, TeamCity), мабуть, не так критично важливий (за винятком масштабу продукту та фінансових можливостей), як цільова функція неперервної інтеграції змін та автоматичного тестування, особливо залучення цих систем для забезпечення регресивного тестування у процесі рефакторингу та модернізації легасі-коду.
Використання сучасних версій Java. Переваги
Оновлення версії Java є ефективним способом отримати доступ до нових функцій та покращень без значних змін у вихідному коді. Зазвичай під час переходу на нові версії Java розробники можуть використовувати нові API, удосконалені механізми управління пам’яттю та інші покращення.
Перехід з Java 8 або Java 11 на Java 17 або 21 має кілька плюсів. Ось деякі з можливих плюсів під час міграції програмного застосунку:
a) Поліпшення продуктивності:
Нові версії Java часто мають оптимізації та поліпшення продуктивності. Ось декілька цифр зі статистики відповідно до звіту — Java 17 на 8,66% швидше (при задіяному G1 збирачу сміття) аніж Java 11. Також швидше ніж попередні версії за паралельного збирача сміття на 6,54%. Водночас паралельний збирач на 6,39 % швидший за G1.
b) Нові функції мови та API:
Для прикладу, приблизно так би виглядав звичайний клас користувача, написаний до 17 версії Java:
public class User { private String name; private String login; private int age; private String phone; public String getName() { return this.name; } public void setName(String name) { this.name = name; } public String getLogin() { return this.login; } public void setLogin(String login) { this.login = login; } public int getAge() { return this.age; } public void setAge(int age) { this.age = age; } public String getPhone() { return this.phone; } public void setPhone(String phone) { this.phone = phone; } @Override public boolean equals(Object o) { if(this== o) return true; if(!(o instanceof User user)) return false; return this.age== user.age && Objects.equals(this.name, user.name) && Objects.equals(this.login, user.login) && Objects.equals(this.phone, user.phone); } @Override public int hashCode() { return Objects.hash(this.name, this.login, this.age, phone); } @Override public String toString() { return "User{" + "name='" + name + ‘\'’ + ", login='" + login +’\'' + ", age=" + age + ",phone='" + phone +’\'’ +'}; } }
А так, якби використовували Java 17:
public record User(String name, String login, int age, String phone) { }
Різниця відчутна.
Зараз багато хто б сказав, що використовує на всю бібліотеку lombok, проте у випадку record — це вже внутрішні можливості Java 17. Але насправді список таких можливостей можна продовжити — запечатані класи, покращене опрацювання NullPointerException
, матчинг патерну instance
of й switch
та чимало іншого.
c) Покращена безпека:
Нові версії зазвичай містять оновлення безпеки, що дозволяє захищати ваш код від потенційних загроз та вразливостей.
d) Перехід на нову версію може містити оновлення вбудованих бібліотек та фреймворків, що може полегшити використання нових можливостей та покращити роботу вашого застосунку.
e) Підтримка нових технологій:
Нові версії можуть підтримувати сучасні технології та стандарти, такі як вебсервіси, обробка JSON, робота з базами даних, що дозволяє використовувати останні досягнення в розробці.
Хотілося б зазначити таке важливе нововведення: у Java 12 ввели тести мікробенчмаркінгу, щоб продуктивність JVM легко тестувалася за допомогою вже наявних тестів.
Це було б дуже корисно для всіх, хто хоче працювати над JVM. Тести, що додаються, створюються з використанням Java Microbenchmark Harness (JMH). Ці тести дозволяють проводити безперервне тестування продуктивності JVM.
f) Підтримка довших термінів:
Однією з переваг переходу на нові версії є те, що вони зазвичай мають підтримку на довший термін, що дозволяє тривалий час використовувати стабільну та безпечну версію.
Перш ніж робити міграцію, важливо вивчити офіційну документацію та переконатися, що ваші бібліотеки та фреймворки також підтримують обрану версію Java. Також може бути корисним провести тестування для визначення можливих проблем або несумісностей.
Використання сучасних фреймворків та бібліотек
Сучасні фреймворки та бібліотеки можуть значно полегшити розробку та підтримку коду. Наприклад, використання Spring Framework дозволяє реалізувати інверсію управління, а Hibernate спрощує взаємодію з базою даних (хоча я прихильник бібліотеки MyBatis)
Використання мікросервісної архітектури
Перехід до мікросервісної архітектури дозволяє розділити великі за розміром додатки на менші та самостійні сервіси. Це полегшує масштабування, підтримку та розвиток системи. Для прикладу візьмемо псевдосервіс для відправки повідомлень різними каналами за підписками користувачів на основі вхідного запиту, скажімо, такого:
curl -x 'POST' \ https://host/notification/send' -H 'Content-Type: application/jison' -d '{ "requestId":"1234512075481” "eventTime": “2024-01-17T12:00:00Z", "system": "MY_SYSTEM" "eventCode": " MESSAGE_SENDING "userId": "123456" "contacts": { "phoneNumber": "0689765454" }, "channel": "sms" "params": [ { "info_id": "ID"}, {“text”:”Greetings!”}]
Відразу можна виділити декілька сервісів: UserService
, SubscriptionService
, TemplateAndMessagingService
, NotificationService
, HistoryService
.
UserService
буде перевіряти наявність користувача і передавати дані до Subscription Service
, який, своєю чергою, перевірятиме наявність відповідної підписки цього користувача і вже потім звертатись до TemplateAndMessagingService
, щоб перевірити чи зареєстровані повідомлення (ми ж не хочемо відправляти що заманеться).
Далі, запит перенаправляється до NotificationService
який і відправить повідомлення через різні канали провайдерів (sms, viber message, push notification) та зберігати статус відправки до HistoryService
. Приблизно так можна запланувати розділення на мікросервіси, але це доволі поверхово.
Приклади успішних застосувань
Netflix — перехід до мікросервісів.
Netflix, великий провайдер потокового відео, успішно модернізував свій легасі-код, перейшовши до мікросервісної архітектури. Це дозволило їм забезпечити вищу доступність, швидшу розробку та масштабування.
Twitter — використання фреймворку Scala.
Twitter використовує фреймворк Scala для модернізації свого легасі-коду. Scala надає можливості функціонального та об’єктноорієнтованого програмування, що полегшує розробку та збільшує продуктивність розробників.
Amazon — використання контейнеризації та Kubernetes.
Amazon використовує контейнеризацію (зокрема Docker) та оркестрацію контейнерів (Kubernetes) для модернізації своїх додатків. Це дозволяє ефективно керувати й масштабувати додатки в хмарному середовищі.
Висновок
Модернізація легасі-коду не є простим завданням. Деякі з викликів охоплюють обмежений бюджет, відсутність або неправильну документацію, і велику кількість залежностей між різними частинами системи.
Використання рефакторингу, оновлення версій Java, впровадження тестування та інші стратегії можуть значно полегшити цей процес. Приклади успішних ініціатив таких компаній, як Netflix, Twitter та Amazon, свідчать про ефективність правильно обраної стратегії модернізації.
Однак, незважаючи на ці труднощі, модернізація легасі-коду є необхідним етапом для забезпечення стабільності ІТ бізнес-продуктів.
45 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів