Java Modules (JPMS). Конспект

До Java 9 единственной настоящей границей инкапсуляции был класс. Если класс объявлен как public, то его можно использовать из любой части приложения.

Пакетный доступ (package-private) не обеспечивал надёжную защиту, потому что любой разработчик мог создать класс в том же пакете и получить доступ к его членам. Например при существовании класса Product:

package com.shop;
class Product {
void test() {}
}

Кто-нибудь мог создать другой класс в том же пакете и получить доступ к test():

package com.shop;
public class Hacker {
public static void main(String[] args) {
new Product().test();
}
}

Модульная система делает инкапсуляцию значительно сильнее. Теперь один пакет может принадлежать только одному модулю, доступ к пакетам контролируется самим модулем и даже public классы могут быть недоступны извне. Теперь, чтобы получить доступ к публичным классам из другого модуля, нужно их «импортировать» (осторожно, речь не про import – как это делать, рассказываю ниже).

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

Модули нужны для работы над крупными проектами с несколькими командами, или для написания библиотек, где было бы неплохо скрыть от пользователей внутренние классы. Для небольших проектов модули не принесут заметной пользы.

Настройка модульного проекта. Директивы exports и requires

Чтобы проект стал модульным, достаточно в корень добавить файл module-info.java

src
└── com.example.library
├── module-info.java
└── com
└── example
└── library
└── Book.java

Внутри указываем имя модуля:

module com.example.library {
}

Без разницы, как этот модуль назвать, но принято использовать обратное доменное имя, чтобы позже не было путаницы в случае, если появится несколько модулей с одним именем library: com.example.library и, например, com.practice.library.

После компиляции этот файл превращается в module-info.class и именно он определяет, как модуль взаимодействует с остальной системой.

Директива exports позволяет сделать пакет доступным другим модулям.

module library {
exports com.example.util;
}

Теперь все public классы пакета com.example.util смогут использовать другие модули. Без директивы exports все классы внутри модуля остаются закрытыми для других модулей. И важно помнить, что экспортируется целый пакет, а не отдельный класс.

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

module com.example.library {
exports com.example.api;
exports com.example.util;
exports com.example.model;
}

Можно также разрешить доступ только определённым модулям:

exports com.example.util to app.one, app.two;

Теперь пакет смогут читать только app.one и app.two. Даже если этих модулей пока не существует, код успешно скомпилируется.

Директива requires используется для объявления зависимости от другого модуля.

module app {
requires library;
}

Зависимость должна существовать как во время компиляции, так и во время выполнения программы. Циклические зависимости запрещены. Если этой директивы нет, мы не можем использовать классы из другого модуля.

После слова requires можно указывать лишь один модуль, поэтому несколько зависимостей потребуют несколько строчек:

module app {
requires library;
requires database;
requires java.sql;
}

Важно помнить, что для работы необходимо и сделать экспорт и указать зависимость. Нельзя выбрать что-то одно: require сработает только для модулей, у которых есть exports хотя бы одного пакета. Написать его можно в любом случае, но пока никакого доступа к пакетам другого модуля он сам по себе не даст.

requires transitive позволяет не описывать транзитивные зависимости, если модуль использует другой модуль внутри себя.

Например, если app использует library, которая использует utils, то без transitive нужно написать:

module library {
requires utils;
}
module app {
requires library;
requires utils;
}

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

module library {
requires transitive utils;
}
module app {
requires library;
}

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

Существует также одна неявная зависимость. Каждый модуль автоматически зависит от java.base. Там находится большинство базовых классов Java. Объявлять эту зависимость не требуется.

Модули и рефлексия. Директива opens

До Java 9 Reflection позволял получать доступ практически к любым полям и методам объекта, включая private. Например:

Field field = User.class.getDeclaredField("password");
field.setAccessible(true);

После появления модульной системы ситуация изменилась: даже если использовать Reflection, доступ к классам другого модуля по умолчанию запрещён. Это сделано для усиления инкапсуляции.

Но директива opens разрешает доступ к пакету через Reflection.

module com.example.library {
opens com.example.model;
}

Теперь классы из пакета com.example.model могут использоваться через Reflection. Например Hibernate, Jackson, Spring или JAXB.

Важно понимать, что opens не разрешает импортировать классы и лишь позволяет получать к ним доступ через рефлексию.

Если требуется открыть весь модуль, то можно поставить open перед его объявлением. Тогда рефлексия будет разрешена для всех пакетов модуля.

open module com.example.library {
}

Как и с exports, пакеты можно открыть только определённым модулям.

module com.example.library {
opens com.example.model
to com.example.hibernate;
}

Но нельзя открыть весь модуль только определённым модулям. И нельзя совмещатьopenи opens. Это приведёт к ошибке компиляции.

Service Loader. Директивы uses и provides

Модульная система содержит встроенный механизм поиска реализаций интерфейсов.Он называется Service Loader.

Например, у нас есть описание сервиса и одна или несколько реализаций одного интерфейса. И есть клиент, который хочет пользоваться сервисом – но мы заранее не знаем, какая ему нужна реализация.

public interface PaymentService {
void pay();
}
public class VisaService implements PaymentService { }
public class MasterCardService implements PaymentService { }
PaymentService service = new VisaService();
service.pay();

ServiceLoader позволяет клиенту не волноваться о конкретной реализации и просто просить какой-нибудь вариант нужного интерфейса:

ServiceLoader<PaymentService> loader = ServiceLoader.load(PaymentService.class)

Идея немного похожа на @Autowired в спринге, когда нам не интересует конкретная реализация и мы не занимаемся созданием объекта.

ServiceLoader существовал и до Java 9, и раньше описывался в папке META-INF:

META-INF/services/com.example.PaymentService

Теперь это можно сделать симпатичнее. Директива provides регистрирует реализацию сервиса.

module payment {
provides PaymentService with StripePaymentService;
}

То есть после provides пишем название интерфейса, а после with – реализацию. Или несколько реализаций через запятую:

module payment {
provides PaymentService with VisaService, MasterCardService;
}

С другой стороны, директива uses говорит, что модуль хочет использовать сервис.

module app {
uses PaymentService;
}

То есть мы указываем интерфейс, а не конкретную реализацию. Она получается через ServiceLoader.load(PaymentService.class).

Если реализаций несколько, то ServiceLoader самостоятельно найдёт все зарегистрированные реализации. В этом есть отличие от концепции Spring, где @Autowired работает лишь с одним вариантом. ServiceLoader<>реализует интерфейс Iterable, поэтому можно пробегаться по реализациям в цикле:

ServiceLoader<PaymentService> loader = ServiceLoader.load(PaymentService.class);
for (PaymentService service : loader) { }

Клиент может работать со всеми реализациями. Например, выбирать, какой платёжной системой пользоваться:

ServiceLoader<PaymentService> loader = ServiceLoader.load(PaymentService.class);
String paymentType = "Visa";
for (PaymentService service : loader) {
switch (service.getName()) {
case "Visa":
if (paymentType.equals("Visa")) {
service.pay();
}
break;
case "MasterCard":
if (paymentType.equals("MasterCard")) {
service.pay();
}
break;
}
}

Взаимодействие модулей

Стоит обсудить типы модулей, потому что некоторые старые библиотеки не содержат файл module-info.class.

В таком случае проект соберётся, но модуль станет unnamed-модуль – модулем без имени. Этот модуль получается только один, и в него «сваливаются» все классы, которые не содержатся в именованных модулях.

Классы из неименованного модуля могут использовать public классы экспортированных пакетов именованных модулей и все publicклассы других таких неименованных модулей (так как модульных границ в неименованных модулях нет). Но именованные модули не могут использовать никакие объекты из неименованного модуля, так как мы не можем добавить такой модуль в список requires.

Теперь стоит обсудить использование сторонних библиотек в виде jar-файлов. Многие старые библиотеки уже добавили module-info, чтобы их можно было использовать в модульных проектах. Но если этого файла нет, то это не значит, что мы не можем воспользоваться библиотекой.

Если положить готовыйjar-файл без файла module-info.java в проект, то Java сделает из него модуль автоматически. JVM автоматически считает, что такой модульэкспортирует все пакеты, открывает все пакеты для рефлекcии и читает все остальные модули. Это называется Automatic Module.

Сравним named, unnamed и automatic (своеобразный переходный тип):

  • module-info есть лишь у named.
  • имя named задаётся в module-info, automatic создаётсяавтоматически по имени модуля (или Automatic-Module-Name из MANIFEST.MF). У безымянного нет имени (логично).
  • named может использовать named, automatic и может использоваться в named (через requires), automatic и unnamed (экспортированные пакеты)
  • automatic может использовать named, automatic, unnamed и может использоваться в named (через requires), automatic, unnamed
  • unnamed может использовать named (экспортированные пакеты), automatic и весь код на classpath (unnamed) и может использоваться в automatic и коде на classpath (unnamed)

Также существует понятие observable modules – обозреваемый модуль. Не каждый модуль автоматически загружается JVM. В граф зависимостей попадают только основной модуль, его зависимости (requires) и модули, явно добавленные через —add-modules. Именно такие модули называются observable modules. Когда они используются?

Может быть ситуация, в которой наш модуль не ссылается явно на модуль, и JVM его не загрузит, но он может быть полезен при выполнении программы.

Например, в наш проект с ServiceLoader.load(PaymentService.class) и двумя реализациями VisaService и MasterCardService мы позже добавим ещё один модуль, который позволит оплачивать через PayPal: PaypalPaymentService. Чтобы ServiceLoader также находил этот вариант, нужно его явно подключить. Для этого нужно добавить новый jar в проект и без новой сборки запустить JVM с аргументом —add-modules payment.paypal.

Classpath vs Module Path

И последний пункт о том, как вообще запускать модульные проекты.

До Java 9 существовал только classpath. Например, раньше запускали приложение:

java -cp app.jar;lib1.jar;lib2.jar Main

И тогда мы просто искали все классы в нужных jar-ах.

После Java 9 появился второй путь – module path

java --module-path mods ...

или сокращённо

java -p mods ...

Теперь ищутся модули и из них строится граф зависимостей на основе наших requires и exports.

Например, рассмотрим проект:

├── app.jar
├── gson.jar
└── log4j.jar

Если запустить его с такой командой, то будет создан один неименнованный модуль, и все классы окажутся в нём:

java -cp "app.jar;gson.jar;log4j.jar" com.example.Main

module-info.class (даже если есть) игнорируется и requires, exports, opens не работают.

Но если запустить проект с модулями, то все эти директивы учтутся:

java -p mods -m com.example.app/com.example.Main

Получим структуру:

Module Path
├── app.jar → Named Module
├── gson.jar → Automatic Module (если нет module-info)
└── log4j.jar → Automatic Module (если нет module-info)

Таким образом, один и тот же JAR можно запустить двумя способами. Если он загружается через classpath, он становится частью Unnamed Module. Если через module path, то JVM рассматривает его как Named Module (при наличии module-info) или Automatic Module (если module-info отсутствует). Поведение кода тоже меняется, особенно в случае с рефлексией.

Напоследок три важных правила:

JAR даже с module-info, помещённый на classpath, перестаёт быть модулем и становится частью Unnamed Module. Иначе говоря, всё на classpath – unnamed.

JAR без module-info, помещённый на module path, автоматически становится Automatic Module.

Named Module никогда не может написать requires для Unnamed Module, потому что у него нет имени.

Оставить комментарий