Pull to refresh

10 самых распространенных ошибок Spring Framework

Reading time16 min
Views16K
Привет, Хабр! Представляю вашему вниманию перевод статьи «Top 10 Most Common Spring Framework Mistakes» автора Toni Kukurin.

Spring, вероятно, один из самых популярных Java-фреймворков, а также могучий зверь для укрощения. Хотя его базовые концепции довольно легко понять, для того, чтобы стать сильным Spring разработчиком, нужно время и усилия.

В этой статье мы рассмотрим некоторые из наиболее распространенных ошибок в Spring, особенно касающихся веб-приложений и Spring Boot. Как говорится на сайте Spring Boot, он навязывает представление о том, как должны быть построены промышленные приложения, поэтому в этой статье мы попытаемся продемонстрировать это представление и дать обзор некоторых советов, которые хорошо вольются в стандартный процесс разработки веб-приложений на Spring Boot.
Если вы не очень хорошо знакомы со Spring Boot, но все же хотели бы попробовать некоторые из упомянутых вещей, я создал репозиторий GitHub, сопровождающий эту статью. Если в любом месте статьи вы почувствуете что потерялись, я рекомендую склонировать репозиторий на локальный компьютер и поиграть с кодом.

Распространенная ошибка №1: спускаться на слишком низкий уровень


Мы сталкиваемся с этой распространенной ошибкой, потому что синдром ”изобретено за границей (not invented here)" — довольно распространен в мире разработки программного обеспечения. Симптомы включают в себя регулярное переписывание фрагментов часто используемого кода, и многие разработчики, похоже, страдают этим.

Хотя понимание внутренностей конкретной библиотеки и ее реализации по большей части хорошо и необходимо (и может быть отличным процессом обучения), но постоянно решать одни и те же низкоуровневые детали реализации — вредно для вашего развития как инженера-программиста. Есть причина, по которой существуют абстракции и фреймворки, такие как Spring, строго отделяющие вас от повторяющейся ручной работы и позволяющие сосредоточиться на деталях более высокого уровня — ваших доменных объектах и бизнес-логике.

Поэтому пользуйтесь абстракциями — в следующий раз, когда вы столкнетесь с конкретной проблемой, сначала выполните быстрый поиск и определите, интегрирована ли библиотека, решающая эту проблему, в Spring. В настоящее время вы, скорее всего, найдете существующее подходящее решение. Как пример полезной библиотеки, в примерах остальной части этой статьи я буду использовать аннотации проекта Lombok. Lombok используется в качестве генератора шаблонного кода и ленивый разработчик внутри вас, надеюсь, не должен иметь проблем с представлением об этой библиотеке. В качестве примера, посмотрите, как выглядит «стандартный Java бин» с Ломбоком:

@Getter
@Setter
@NoArgsConstructor
public class Bean implements Serializable {
    int firstBeanProperty;
    String secondBeanProperty;
}

Как вы можете себе представить, приведенный выше код компилируется в:

public class Bean implements Serializable {
    private int firstBeanProperty;
    private String secondBeanProperty;

    public int getFirstBeanProperty() {
        return this.firstBeanProperty;
    }

    public String getSecondBeanProperty() {
        return this.secondBeanProperty;
    }

    public void setFirstBeanProperty(int firstBeanProperty) {
        this.firstBeanProperty = firstBeanProperty;
    }

    public void setSecondBeanProperty(String secondBeanProperty) {
        this.secondBeanProperty = secondBeanProperty;
    }

    public Bean() {
    }
}

Однако обратите внимание, что вам, скорее всего, придется установить плагин, если вы собираетесь использовать Lombok с вашей IDE. Версию плагина для IntelliJ IDEA можно найти здесь.

Распространенная ошибка №2: «утечка» внутреннего содержимого


Раскрытие вашей внутренней структуры — это всегда плохая идея, потому что создает негибкость в дизайне сервиса и, следовательно, способствует плохой практике кодирования. «Утечка» внутреннего содержимого проявляется в том, что структура базы данных доступна с определенных эндпоинтов API. В качестве примера предположим, что следующий POJO (”Plain Old Java Object") представляет таблицу в вашей базе данных:

@Entity
@NoArgsConstructor
@Getter
public class TopTalentEntity {

    @Id
    @GeneratedValue
    private Integer id;

    @Column
    private String name;

    public TopTalentEntity(String name) {
        this.name = name;
    }
}

Предположим, существует эндпоинт, который должен получить доступ к данным TopTalentEntity. Как бы ни было заманчиво возвращать экземпляры TopTalentEntity, более гибким решением было бы создание нового класса для отображения данных TopTalentEntity на эндпоинте API:

@AllArgsConstructor
@NoArgsConstructor
@Getter
public class TopTalentData {
    private String name;
}

Таким образом, внесение изменений в бэкенд базы данных не потребует каких-либо дополнительных изменений в слое сервиса. Подумайте, что произойдет в случае добавления поля «пароль» в TopTalentEntity для хранения хэшей паролей пользователей в базе данных — без соединителя, такого как TopTalentData, если забыть изменить сервис, фронтенд случайно покажет какую-нибудь очень нежелательную секретную информацию!

Распространенная ошибка №3: отсутствие разделения обязанностей


По мере роста вашего приложения организация кода становится все более важным вопросом. По иронии судьбы, большинство хороших принципов разработки программного обеспечения начинают повсеместно нарушаться – особенно в тех случаях, когда мало внимания уделяется проектированию архитектуры приложений. Одна из наиболее распространенных ошибок, с которыми сталкиваются разработчики — это смешивание обязанностей кода, и это очень легко сделать!

Что обычно нарушает принцип разделения обязанностей, так это просто «добавление» новой функциональности в существующие классы. Это, конечно, отличное краткосрочное решение (для начала, оно требует меньше набора текста), но оно неизбежно станет проблемой в будущем, будь то во время тестирования, сопровождения или где-то между ними. Рассмотрим следующий контроллер, который возвращает TopTalentData из своего репозитория:

@RestController
public class TopTalentController {

    private final TopTalentRepository topTalentRepository;

    @RequestMapping("/toptal/get")
    public List<TopTalentData> getTopTalent() {
        return topTalentRepository.findAll()
                .stream()
                .map(this::entityToData)
                .collect(Collectors.toList());
    }

    private TopTalentData entityToData(TopTalentEntity topTalentEntity) {
        return new TopTalentData(topTalentEntity.getName());
    }
}

Сначала не заметно, что с этим фрагментом кода что-то не так. Он предоставляет список TopTalentData, который извлекается из экземпляров TopTalentEntity. Однако, если присмотреться, мы увидим, что на самом деле TopTalentController выполняет здесь несколько вещей. А именно: он маппит запросы на конкретный эндпоинт, извлекает данные из репозитория и преобразует сущности, полученные из TopTalentRepository, в другой формат. «Более чистым» решением было бы разделение этих обязанностей по их собственным классам. Это может выглядеть примерно так:

@RestController
@RequestMapping("/toptal")
@AllArgsConstructor
public class TopTalentController {

    private final TopTalentService topTalentService;

    @RequestMapping("/get")
    public List<TopTalentData> getTopTalent() {
        return topTalentService.getTopTalent();
    }
}

@AllArgsConstructor
@Service
public class TopTalentService {

    private final TopTalentRepository topTalentRepository;
    private final TopTalentEntityConverter topTalentEntityConverter;

    public List<TopTalentData> getTopTalent() {
        return topTalentRepository.findAll()
                .stream()
                .map(topTalentEntityConverter::toResponse)
                .collect(Collectors.toList());
    }
}

@Component
public class TopTalentEntityConverter {
    public TopTalentData toResponse(TopTalentEntity topTalentEntity) {
        return new TopTalentData(topTalentEntity.getName());
    }
}

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

Распространенная ошибка №4: несогласованность (Inconsistency) и плохая обработка ошибок


Тема обеспечения согласованности не обязательно является эксклюзивной для Spring (или Java, если на то пошло), но все же является важным аспектом, который следует учитывать при работе над проектами Spring. В то время как стиль написания кода может быть предметом обсуждения (и обычно это вопрос соглашения в команде или в рамках всей компании), наличие общего стандарта оказывается большим подспорьем в производительности. Это особенно верно для команд из нескольких человек. Согласованность позволяет осуществлять передачу кода без больших затрат ресурсов на сопровождение или предоставление подробных объяснений относительно обязанностей различных классов.

Рассмотрим проект Spring с различными файлами конфигурации, сервисами и контроллерами. Будучи семантически последовательными в их именовании, создается легко доступная для поиска структура, в которой любой новый разработчик может управлять способами работы с кодом: например, добавляется суффикс Config к классам конфигурации, суффикс Service к сервисам и суффикс Controller к контроллерам.

Тесно связанная с темой согласованности, обработка ошибок на стороне сервера заслуживает особого внимания. Если вам когда-либо приходилось обрабатывать ответы-исключения из плохо написанного API, вы, вероятно, знаете, почему может быть болезненно правильно парсить исключения, а еще сложнее определить причину, по которой эти исключения возникли первоначально.

Как разработчик API, вы в идеале хотите охватить все пользовательские эндпоинты и перевести их на общий формат ошибок. Это обычно означает, что у вас есть общий код ошибки и описание, а не просто отмазка в виде: a) возврат сообщения “500 Internal Server Error” или b) просто сброс стэктрейса пользователю (чего следует избегать любой ценой, поскольку он показывает ваши внутренности в дополнение к сложности обработки на стороне клиента).
Примером распространенного формата ответа на ошибку может быть:

@Value
public class ErrorResponse {

    private Integer errorCode;
    private String errorMessage;
}

Нечто подобное обычно встречается в большинстве популярных API и, как правило, хорошо работает, поскольку это можно легко и систематически документировать. Перевод исключений в этот формат можно выполнить, предоставив методу аннотацию @ExceptionHandler (пример аннотации приведен в Распространенной Ошибке №6).

Распространенная ошибка №5: неправильная работа с многопоточностью


Независимо от того, встречается ли она в десктопных или веб-приложениях, в Spring или не в Spring, многопоточность может оказаться непростой для решения задачей. Проблемы, вызванные параллельным выполнением программ, являются до нервозности неуловимыми и зачастую чрезвычайно сложными для отладки — фактически, из-за природы проблемы, как только вы понимаете, что имеете дело с проблемой параллельного выполнения, вам, вероятно, следует полностью отказаться от отладчика и начать проверять свой код «вручную», пока не найдете причину ошибки. К сожалению, для решения таких проблем не существует шаблонного решения. В зависимости от конкретного случая вам придется оценить ситуацию, а затем атаковать проблему под углом, который вы считаете наилучшим.

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

Избегайте глобального состояния


Во-первых, всегда помните о проблеме “глобального состояния”. Если вы создаете многопоточное приложение, абсолютно все, что можно изменить глобально, должно быть тщательно отслежено и, если это возможно, полностью удалено. Если есть причина, по которой глобальная переменная должна оставаться изменяемой, тщательно используйте синхронизацию и отслеживайте производительность вашего приложения, чтобы подтвердить, что оно не замедляется из-за новых периодов ожидания.

Избегайте изменчивости (Mutability)


Это следует прямо из функционального программирования и, в соответствии с ООП, гласит, что следует избегать изменчивости класса и изменения состояния. Вкратце, вышесказанное означает наличие сеттеров и приватных финальных полей во всех классах модели. Их значения меняются только во время конструирования. Таким образом, вы можете быть уверены, что проблем гонки за ресурсы не возникнет и что обращение к свойствам объекта будет всегда обеспечивать правильные значения.

Логгируйте критические данные


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

Переиспользуйте существующие реализации


Всякий раз, когда вам нужно создать свои собственные потоки (например, для выполнения асинхронных запросов к различным службам), переиспользуйте существующие безопасные реализации, а не создавайте свои собственные решения. По большей части это будет означать использование ExecutorServices и CompletableFutures в аккуратном функциональном стиле Java 8 для создания потоков. Spring также позволяет осуществлять асинхронную обработку запросов через класс DeferredResult.

Распространенная ошибка № 6: неиспользование валидации на основе аннотаций


Давайте представим, что нашему сервису TopTalent, упомянутому выше, требуется эндпоинт для добавления новых Супер Талантов. Кроме того, допустим, что по какой-то действительно веской причине каждое новое имя должно быть длиной ровно 10 символов. Один из способов сделать это может быть следующим:

@RequestMapping("/put")
public void addTopTalent(@RequestBody TopTalentData topTalentData) {
    boolean nameNonExistentOrHasInvalidLength =
            Optional.ofNullable(topTalentData)
         .map(TopTalentData::getName)
   .map(name -> name.length() == 10)
   .orElse(true);
    if (nameNonExistentOrInvalidLength) {
        // throw some exception
    }
    topTalentService.addTopTalent(topTalentData);
}

Однако вышеизложенное (в дополнение к тому, что оно плохо сконструировано) на самом деле не является «чистым» решением. Мы проверяем более одного типа валидности (а именно, что TopTalentData не null, и что TopTalentData.name не null, и что TopTalentData.name имеет длину 10 символов), а также бросает исключение, если данные недействительны.

Это можно сделать гораздо более чисто, используя Hibernate validator со Spring. Сначала перепишем метод addTopTalent для поддержки валидации:

@RequestMapping("/put")
public void addTopTalent(@Valid @NotNull @RequestBody TopTalentData topTalentData) {
    topTalentService.addTopTalent(topTalentData);
}

@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleInvalidTopTalentDataException(MethodArgumentNotValidException methodArgumentNotValidException) {
    // handle validation exception
}

Кроме того, мы должны указать, какое свойство мы хотим проверить в классе TopTalentData:

public class TopTalentData {
    @Length(min = 10, max = 10)
    @NotNull
    private String name;
}

Теперь Spring перехватит запрос и проверит его перед вызовом метода – нет необходимости использовать дополнительные ручные тесты.

Другой способ, которым мы могли бы достичь того же — это создание собственных аннотаций. Хотя обычно пользовательские аннотации используются только тогда, когда ваши потребности превышают встроенный набор констрейнов Hibernate, для этого примера давайте представим, что аннотации Length не существует. Вы должны создать валидатор, который проверяет длину строки, создав два дополнительных класса, один для проверки, а другой для аннотирования свойств:

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = { MyAnnotationValidator.class })
public @interface MyAnnotation {

    String message() default "String length does not match expected";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    int value();
}

@Component
public class MyAnnotationValidator implements ConstraintValidator<MyAnnotation, String> {

    private int expectedLength;

    @Override
    public void initialize(MyAnnotation myAnnotation) {
        this.expectedLength = myAnnotation.value();
    }

    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        return s == null || s.length() == this.expectedLength;
    }
}

Обратите внимание, что в этих случаях лучшие практики по разделению обязанностей требуют, чтобы вы отметили свойство как валидное, если оно равно null (s == null в методе isValid), а затем использовали аннотацию NotNull, если это является дополнительным требованием для свойства:

public class TopTalentData {
    @MyAnnotation(value = 10)
    @NotNull
    private String name;
}

Распространенная ошибка № 7: использование (все ещё) XML-конфигурации


Хотя XML был необходим для предыдущих версий Spring, в настоящее время большая часть конфигурации может быть выполнена исключительно с помощью кода Java / аннотаций. Конфигурации XML просто представляют собой дополнительный и ненужный бойлерплейт.
Эта статья (а также сопровождающий ее репозиторий GitHub) использует аннотации для конфигурирования Spring и Spring знает, какие бины он должен подключить, потому что корневой пакет был аннотирован с помощью составной аннотации @SpringBootApplication, например:

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

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

  • @Component (TopTalentConverter, MyAnnotationValidator)
  • @RestController (TopTalentController)
  • @Repository (TopTalentRepository)
  • @Service (TopTalentService)

Если бы у нас были какие-либо дополнительные классы аннотированные @Configuration, они также были бы проверены на наличие Java-конфигурации.

Распространенная ошибка № 8: забывать о профилях


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

Рассмотрим случай, когда вы используете in-memory базу данных для локальной разработки и базу данных MySQL в ПРОМе. По сути, это будет означать, что вы будете использовать разные URL и (надеюсь) разные учетные данные для доступа к каждой из них. Давайте посмотрим, как это можно сделать двумя разными конфигурационными файлами:

ФАЙЛ APPLICATION.YAML


# set default profile to 'dev'
spring.profiles.active: dev

# production database details
spring.datasource.url: 'jdbc:mysql://localhost:3306/toptal'
spring.datasource.username: root
spring.datasource.password:

ФАЙЛ APPLICATION-DEV.YAML


spring.datasource.url: 'jdbc:h2:mem:'
spring.datasource.platform: h2

По-видимому, вы не хотите случайно выполнить какие-либо действия на своей промышленной базе данных пока вы возитесь с кодом, поэтому имеет смысл установить профиль по умолчанию в dev. Затем на сервере можно вручную переопределить профиль конфигурации, указав параметр -Dspring.profiles.active=prod для JVM. Кроме того, вы также можете установить переменную среды ОС в нужный профиль по умолчанию.

Распространенная ошибка №9: неспособность принять инъекцию зависимостей


Правильное использование внедрения зависимостей в Spring означает, что он позволяет связывать все ваши объекты вместе путем сканирования всех требуемых классов конфигурации; это оказывается полезным для расцепления (decoupling) связей, а также значительно облегчает тестирование. Вместо жесткой связи классов, сделав что-то вроде этого:

public class TopTalentController {

    private final TopTalentService topTalentService;

    public TopTalentController() {
        this.topTalentService = new TopTalentService();
    }
}


Мы позволяем Spring сделать для нас связывание:

public class TopTalentController {

    private final TopTalentService topTalentService;

    public TopTalentController(TopTalentService topTalentService) {
        this.topTalentService = topTalentService;
    }
}

Misko Hevery в Google talk подробно объясняет «причины» внедрения зависимости, поэтому давайте вместо этого посмотрим, как это используется на практике. В разделе о разделении обязанностей (Распространенные Ошибки №3) мы создали классы сервиса и контроллера. Допустим, мы хотим протестировать контроллер в предположении, что TopTalentService ведет себя правильно. Мы можем вставить мок-объект вместо фактической реализации сервиса, предоставив отдельный класс конфигурации:

@Configuration
public class SampleUnitTestConfig {
    @Bean
    public TopTalentService topTalentService() {
        TopTalentService topTalentService = Mockito.mock(TopTalentService.class);
        Mockito.when(topTalentService.getTopTalent()).thenReturn(
                Stream.of("Mary", "Joel")
		      .map(TopTalentData::new).collect(Collectors.toList()));
        return topTalentService;
    }
}

Затем мы можем внедрить мок-объект, сказав Spring использовать SampleUnitTestConfig в качестве поставщика конфигурации:

@ContextConfiguration(classes = { SampleUnitTestConfig.class })

Потом это позволит нам использовать конфигурацию контектса для внедрения пользовательского бина в юнит-тест.

Распространенная ошибка № 10: отсутствие тестирования или неправильное тестирование


Несмотря на то, что идея юнит-тестирования с нами уже давно, многие разработчики, похоже, «забывают» сделать это (особенно если это не обязательно), или просто оставляют его на потом. Очевидно, что это нежелательно, поскольку тесты должны не только проверять правильность вашего кода, но и служить документацией о том, как приложение должно вести себя в разных ситуациях.

При тестировании web-сервисов, вы редко делаете исключительно «чистые» юнит-тесты, так как взаимодействие через HTTP, как правило, требует вызвать DispatcherServlet Spring'а и посмотреть, что происходит, когда получен фактический HttpServletRequest (что делает его интеграционным тестом, с использованием валидации, сериализации и т.д.). REST Assured — Java DSL для легкого тестирования REST-сервисов поверх MockMVC оказался очень элегантным решением. Рассмотрим следующий фрагмент кода с инъекцией зависимостей:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {
        Application.class,
        SampleUnitTestConfig.class
})
public class RestAssuredTestDemonstration {

    @Autowired
    private TopTalentController topTalentController;

    @Test
    public void shouldGetMaryAndJoel() throws Exception {
        // given
        MockMvcRequestSpecification givenRestAssuredSpecification = RestAssuredMockMvc.given()
                .standaloneSetup(topTalentController);

        // when
        MockMvcResponse response = givenRestAssuredSpecification.when().get("/toptal/get");

        // then
        response.then().statusCode(200);
        response.then().body("name", hasItems("Mary", "Joel"));
    }
}

SampleUnitTestConfig подключает мок-реализацию TopTalentService в TopTalentController, в то время как все другие классы связываются с использованием стандартной конфигурации, полученной путем сканирования пакетов, имеющих корни в пакете класса Application. RestAssuredMockMvc просто используется для создания легковесной среды и отправки GET запроса в эндпоинт /toptal/get.

Стать мастером Spring


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

Если вы ищете дополнительные ресурсы, Spring In Action — это хорошая практическая книга, охватывающая многие темы core Spring.

ТЕГИ
Java SpringFramework

Комментарии


Timothy Schimandle
На №2 я думаю, что возвращение доменного объекта является предпочтительным в большинстве случаев. Ваш пример пользовательского объекта — один из нескольких классов, в которых есть поля, которые мы хотим скрыть. Но подавляющее большинство объектов, с которыми я работал, не имеют такого ограничения, и добавление класса dto — просто ненужный код.
В целом хорошая статья. Хорошая работа.

SPIRITED to Timothy Schimandle
Я вполне согласен. Похоже, что добавлен ненужный дополнительный слой кода, я думаю, что @JsonIgnore поможет игнорировать поля (хотя и с недостатками стратегий обнаружения репозитория по умолчанию), но в целом это отличный пост в блоге. Горд, что наткнулся…

Arokiadoss Asirvatham
Чувак, Еще одна распространенная ошибка начинающих: 1) Циклическая Зависимость и 2) несоблюдение основных доктрин объявления Класса Singleton, таких как использование переменной экземпляра в бинах с областью видимости singleton.

Hlodowig
Относительно №8, я считаю, что подходы к профилям очень неудовлетворительные. Давайте посмотрим:

  • Безопасность: некоторые люди говорят: если бы ваш репозиторий был общедоступным, открылись бы какие-нибудь секретные ключи / пароли? Скорее всего, так и будет, следуя такому подходу. Если, конечно, вы не добавите файлы config в .gitignore, но это не серьезный вариант.
  • Дублирование: каждый раз, когда у меня разные настройки, мне нужно создать новый файл свойств, что довольно раздражает.
  • Переносимость: я знаю, что это всего лишь один аргумент JVM, но ноль лучше, чем один. Бесконечно меньше подвержено ошибкам.

Я попытался найти способ использовать переменные среды в моих config файлах вместо «жесткого кодирования» значений, но до сих пор мне это не удалось, думаю, мне нужно провести больше исследований.

Отличная статья Тони, продолжай в том же духе!

Перевод выполнен: tele.gg/middle_java
Tags:
Hubs:
+6
Comments6

Articles

Change theme settings