Первая и вторая части статьи.

Практический пример: добавление функциональности плагина в Java-приложение

Плагины  —  это небольшие программы, которые могут быть включены в работающее приложение для расширения его функциональности. Рассмотрим редактор кода Visual Studio Code, который помогает в таких задачах разработки, как отладка, тестирование кода и контроль версий. Кроме того, он поставляется на рынок с тысячами расширений, которые могут загружать пользователи. Расширения варьируются от самых необходимых, таких как редактирование кода для определенного языка программирования, до премиальных (например, Prettier для обеспечения единого стиля кода и Better Comments, которое создает цветные комментарии к коду). Visual Studio Code предоставляет эти расширения по технологии plug-and-play.

Хотя VS code написан не на Java, идея архитектуры плагинов та же  —  необходимость расширения системы или подключения новой функциональности во время выполнения. Функциональность плагинов в Java-приложении реализуется с помощью рефлексии и загрузчиков классов. Создать простой плагин можно в три этапа:

  1. Определение интерфейса плагина.
  2. Определение загрузчика классов плагина.
  3. Определение протокола для установки и запуска плагинов в системе.

На 1-м этапе должен быть определен единый интерфейс, через который приложение может запускать любой плагин.

public interface Plugin {

// возвращает имя плагина
public String getPluginName();

// запускает функциональность плагина
public void run();

// используется для настройки плагина, если это необходимо
// возвращает true/false при успешной/неудавшейся настройке
public boolean configure(Object configuration);
}

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

На 2-м этапе для загрузки классов плагина определяется специальный загрузчик классов. Это требуется по многим причинам.

  • Класс однозначно идентифицируется по его имени и загрузчику классов. Загрузка плагина с помощью специального загрузчика классов создает уникальное пространство имен для классов плагина. Это предотвращает возникновение конфликтов между классами приложения и классами плагина. Например, если и приложение, и плагин определили класс с именем Foo, то JVM будет знать, как их различать, поскольку класс Foo приложения был загружен Application ClassLoader, а класс Foo плагина  —  специализированным загрузчиком классов, и каждый из них будет иметь свой объект Class.
  • Не следует полностью доверять безопасность приложения коду внешнего плагина. Он может содержать непроверенный, ошибочный или вредоносный код. Наличие отдельного загрузчика классов, загружающего классы плагинов, позволяет наложить ограничения по безопасности на классы плагинов (подробнее об этом здесь).

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

public class PluginClassLoader extends ClassLoader{

// путь к папке, содержащей классы плагина
String path;

public PluginClassLoader(String path){
/* вызов конструктора суперкласса и установка родителя PluginClassLoader
в загрузчик классов Bootstrapcall */
super(findBootstrapClassOrNull(name));
this.path=path;
}

public synchronized Class findClass(String name) throws ClassNotFoundException
{
// 1. Получение данных о классе
byte [] bytes = getClassData(name);
if ( bytes != null ){
//2. Использование defineClass для построения объекта Class
Class c = defineClass( null , bytes, 0, bytes.length );
return c;
}
// данные класса не найдены, выброс исключения
throw new ClassNotFoundException();
}

// Это вспомогательный метод, используемый для получения данных класса, представляющих собой массив байт-кода
public byte [] getClassData(String name){
try {
String classFile = classPath + name.replace('.', '/') + ".class";
int classSize = Long.valueOf((new File(classFile)).length()).intValue();
byte[] buf = new byte[classSize];
FileInputStream fileInput = new FileInputStream(classFile);
classSize = fileInput.read(buf);
fileInput.close();
return buf;
} catch(IOException e){
return null;
}
}

}

PluginClassLoader переопределяет функцию findClass(), которая загружает классы из пути плагина, заданного в переменной path. Обратите внимание, что PluginClassLoader не переопределяет loadClass(), поскольку загрузчики классов плагинов не должны вмешиваться в модель делегирования.

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

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

Внутри приложения можно определить класс PluginManager, который будет управлять установкой и запуском плагинов (функции этого класса связаны с командами или кнопками установки/запуска плагинов). Он может хранить все установленные в данный момент плагины в хэш-таблице, содержащей пары (имя плагина, объект Plugin), и определять функции loadPlugin() и runPlugin() для загрузки и запуска плагинов соответственно. Ниже приведен пример PluginManager:

public class PluginManager {

/* в этой map хранятся все установленные в данный момент плагины приложения */
private Map<String, Plugin > plugins;
/* в этой переменной хранится путь, по которому можно найти весь код плагина */
String pluginsDir;

/* эта функция загружает плагин, опираясь на имя папки; эта папка
должна существовать в пути плагина и содержать объединенные классы плагина */
protected void loadPlugin(String foldername){

//инициализация загрузчика классов
PluginClassLoader cl = new PluginClassLoader();

File dir = new File("[plugin-directory]");
String[] files = dir.list();

//перебор всех классов в каталоге плагина
for (int i=0; i<files.length; i++) {
try {

// загрузка класса в текущий файл
Class c = cl.loadClass(files[i].substring(0, files[i].indexOf(".")));
// получение реализованных интерфейсов
Class[] intf = c.getInterfaces();

/* перебор интерфейсов для определения того, реализует ли данный класс интерфейс
Plugin */
for (int j=0; j<intf.length; j++) {

/* если класс реализует интерфейс Plugin */
if (intf[j].getName().equals("Plugin")) {
/* рефлексивное создание экземпляра данного класса */
Plugin plugin = (Plugin) c.newInstance();
/* сохранение плагина в хэш-таблице плагинов*/
String pluginName = plugin.getPluginName();
plugins.put(pluginName,plugin);
continue;
}
}

}catch (Exception ex) {
/*обработка исключений загрузки плагина*/
}
}
}

/* Эта функция запускает указанный плагин */
protected void runPlugin(String name) {
Plugin plugin = plugins.get(name);
plugin.run();
}
}

Как уже было сказано, каждый корректный плагин должен иметь класс, реализующий интерфейс Plugin. Метод loadPlugin() создает экземпляр PluginClassLoader и использует его для загрузки всех классов плагина из указанной папки плагина. С помощью рефлексии он проверяет классы плагина, находит класс, реализующий интерфейс Plugin, и создает его экземпляр. Этот экземпляр затем хранится в хэш-таблице plugins для запуска плагина в случае необходимости. Метод runPlugin() запускает конкретный плагин, получая его объект Plugin из хэш-таблицы plugins и вызывая метод run().

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


Практический пример: модификация классов во время выполнения

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

Динамическая модификация классов  —  сложная проблема, которая до сих пор остается предметом исследований в Java-разработке. Существует несколько подходов к ее решению: одни модифицируют компилятор, другие  —  JVM. Однако пользователи Java могут добиться эффекта модификации классов, тщательно составляя программы с помощью загрузчиков классов и рефлексии. Загрузчики классов не могут напрямую модифицировать уже загруженные классы, но они могут перезагрузить файл обновленного класса. Для достижения эффекта модификации класса новый загруженный класс должен заменить старый, что можно сделать с помощью паттернов проектирования.

Активный класс  —  это загруженный класс, имеющий экземпляры в JVM. Замена активного класса представляет собой сложную задачу, поскольку включает в себя несколько этапов:

  1. Загрузка новой обновленной версии класса.
  2. Перенаправление зависимых частей приложения на инстанцирование объектов нового класса вместо старого.
  3. Если в системе в этот момент выполнения существуют экземпляры старого класса, то их необходимо заменить эквивалентными экземплярами нового класса.

На 1-м этапе замены класса необходимо загрузить в JVM обновленный файл класса. Здесь есть один нюанс, который заключается в том, что класс ClassLoader по умолчанию отслеживает все ранее загруженные классы и не позволяет загружать один и тот же класс дважды (см. реализацию loadClass() выше). Как уже упоминалось, класс в JVM идентифицируется как по его полному имени (которое содержит имя класса и пакет, из которого он был загружен, например org.example.Foo), так и по загрузчику класса, который его загрузил. Класс Foo, загруженный дважды двумя разными загрузчиками классов, будет восприниматься в JVM как два разных класса (будет иметь два разных объекта Class). Вот два возможных способа перезагрузки класса Foo после его изменения:

  1. Изменяйте полное имя Foo, либо изменив имя класса (например, FooV1, FooV2 и т. д.), либо изменив пакет (в пределах classpath), в котором находится его файл класса, когда он изменяется, чтобы заставить Application ClassLoader загрузить его снова.
  2. Создайте пользовательский загрузчик классов и используйте его для многократной загрузки Foo. Это включает переопределение функции loadClass() и программирование ее так, чтобы она всегда загружала определенные классы, даже если они были загружены ранее. Ниже показано, как это сделать:
public class CustomClassLoader extends ClassLoader{

String classPath ;
public CustomClassLoader(String path){
super(CustomClassLoader.class.getClassLoader());
this.classPath=path;
}

public synchronized Class findClass(String name) throws ClassNotFoundException{
// 1. Получение данных класса
byte [] bytes = getClassData(name);
if ( bytes != null ){
//2. Использование defineClass для построения объекта Class
Class c = defineClass( null , bytes, 0, bytes.length );
return c;
}
// данные класса не найдены, выброс исключения
throw new ClassNotFoundException();
}

// Этот метод загружает байт-код из файла, используя classPath
public byte [] getClassData(String name){ ... }

@Override
public Class loadClass(String name) throws ClassNotFoundException {
// Для определенного класса или группы классов всегда загружать
if(name.contains("Foo")) {
return this.findClass(name);
}
// для всех остальных классов использовать реализацию загрузки классов по умолчанию
return super.loadClass(name);
}
}

CustomClassLoader переопределяет loadClass() и всегда загружает модифицируемый класс Foo при запросе на его загрузку (он делает это путем вызова метода findClass(), который считывает последний обновленный файл класса и возвращает новый объект Class). Это позволит перезагружать класс Foo по требованию. Обратите внимание, что перезагрузка Foo не оказывает прямого влияния на работу приложения. Вот как выглядит система после перезагрузки класса Foo и обновления его файла:

Изображение автора

Объект Class Foo версии 2 приложение не “видит”, а Foo версии 1 все еще используется и имеет активные экземпляры. Прямого способа заменить объект Class Foo версии 1 на объект Class Foo версии 2 не существует. Следующим шагом является перенаправление приложения на использование Foo версии 2. Это делается с использованием шаблона “фабрика” и рефлексии.

Шаблон “фабрика”

Шаблон “фабрика”  —  это порождающий шаблон, который в основном используется, когда необходимо абстрагировать логику создания объекта. Делается это путем создания класса Factory, который отвечает за создание объектов определенного типа. Внутри такой Factory можно настраивать конкретные типы, создаваемые по заданным правилам. Согласно учебнику “Design Patterns: Elements of Reusable Object-Oriented Software” (“Шаблоны проектирования: элементы многоразового объектно-ориентированного программного обеспечения”):

“Ключевая потребность в шаблоне “фабрика” заключается в том, что один объект должен создавать другие объекты, не зная заранее, какой конкретный объект надо создать”.

Перефразируя это правило для нашего варианта использования, можно сказать: потребность в шаблоне “фабрика” возникнет, когда один объект должен создавать другие объекты, не зная заранее, какую версию объекта следует создать. С целью перезагрузки классов модифицируемый класс должен иметь объект Factory. Этот объект отслеживает последнюю обновленную версию класса, сохраняя свой объект Class. Он также отвечает за создание объектов Foo и делает это рефлексивно.

Первым шагом в использовании шаблона “фабрика” является создание интерфейса для изменяемого класса. Это позволит всему зависимому коду одинаково относиться ко всем версиям Foo.

public interface Foo {
public void doSomething();
}

Изменяемый класс (например, ConcreteFoo) реализует интерфейс Foo. FooFactory создается следующим образом:

public class FooFactory {
// В этой переменной хранится текущий реализуемый объект класса Foo
static private Class implClass;

// Этот метод возвращает экземпляр текущего реализуемого класса
public static Foo newInstance() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Foo foo = (Foo) implClass.getDeclaredConstructor().newInstance();
return foo;

}
//Этот метод перезагружает класс Foo
public static void reload(String dir) throws ClassNotFoundException {
ClassLoader cl = new CustomClassLoader(dir);
implClass = cl.loadClass( "[ConcreteFoo]" );
}

}

FooFactory отслеживает текущую реализацию Foo, используя переменную implClass, и определяет метод newInstance(), который является методом фабрики, поскольку “производит” объект Foo. Этот метод использует рефлексию для инстанцирования объекта Foo с использованием текущей реализации класса. Метод reload(), вызываемый всякий раз, когда класс обновляется, использует CustomClassLoader для загрузки нового объекта Class, а затем обновляет переменную implClass. Это приводит к тому, что все последующие вызовы newInstance() возвращают экземпляры обновленной версии класса.

Создание объектов Foo во всем приложении должно происходить исключительно через класс FooFactory, чтобы обеспечить согласованность в поведении типа Foo. Таким образом FooFactory гарантирует, что при всех будущих применениях Foo будет использоваться последняя версия обновленного класса. Следующий код перезагружает изменяемый класс, а затем создает его новый экземпляр с помощью FooFactory:

// перезагрузка изменяемого класса типа Foo после обновления
FooFactory.reload("[directory]");
// создание нового экземпляра Foo
Foo foo = FooFactory.newInstance();
foo.doSomething();

Вот как выглядит система после выполнения приведенного выше кода:

Изображение автора

Хотя это определенно шаг в правильном направлении, поскольку при последующем применении Foo будут использоваться Foo версии 2 (обновленный класс), старые экземпляры Foo версии 1 все же необходимо заменить эквивалентными экземплярами Foo версии 2 для завершения замены.

Объект существует в Java потому, что на него ссылается некая переменная. Как только объект больше не используется переменной, он собирается в мусор (удаляется из кучи). Если объекты Foo существуют в какой-то момент во время выполнения, значит, переменные, принадлежащие определенным объектам, ссылаются на них. Например, класс Bar может использовать объекты Foo в своих внутренних методах, и, следовательно, его ссылки в идеале должны быть обновлены, чтобы использовать последнюю версию Foo для полного эффекта замены в приложении.

Отслеживание всех программных объектов, имеющих внутренние ссылки на объекты Foo, и обновление их ссылок стало бы кошмаром для специалистов по обслуживанию. Особенно если объекты Foo активно используются во всей программе или применяются другими модифицируемыми классами. Для устранения этой проблемы шаблон “прокси” предлагает простое решение. Для начала интерфейс Foo обновляется следующим образом:

public interface Foo {

public void doSomething();

public Foo evolve(Foo foo);
}

Добавленный метод evolve() указывает, что каждый класс, реализующий интерфейс Foo, должен определять логику для создания своего экземпляра, который может выступать в качестве замены другого объекта Foo. Этот метод будет использоваться для сопоставления объектов старой реализации Foo с объектами новой реализации.

Шаблон “прокси” используется для замены старых экземпляров класса.

Шаблон “прокси”

Прокси (Proxy) в Java  —  это объект, который действует как замена другого объекта. Внешне он выглядит и действует как другой объект, реализуя его интерфейсы, но внутренне перенаправляет вызовы своих методов реальному экземпляру объекта, который он имитирует (целевой объект  —  target ). При этом он может выполнить дополнительную обработку перед пересылкой вызовов методов целевому объекту. Ниже приведена схема последовательности вызовов метода прокси:

Изображение автора

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

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

  • Вместо создания обычных объектов Foo с помощью метода “фабрика”, FooFactory возвращает прокси Foo, целью которого является экземпляр текущего реализуемого класса.
  • FooFactory сохраняет списком ссылки на все создаваемые прокси, так что при перезагрузке класса Foo перебирает свой список прокси и обновляет целевой объект каждого прокси до экземпляра обновленного класса Foo. Для этого используется метод evolve() нового класса.

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

Шаблон “фабрика” можно использован для реализации заменяемых классов, а шаблон “прокси”  —  для реализации заменяемых объектов.

Перед погружением в реализацию усвоим два важных термина:

Прокси-класс  —  это класс, который реализует интерфейс или список интерфейсов, а прокси-экземпляр  —  это экземпляр прокси-класса.

Для использования шаблона “прокси” с типом Foo, как правило, необходимо определить два класса:

  • легитимный класс, реализующий интерфейс Foo;
  • прокси-класс, который также реализует интерфейс Foo, но имеет внутренний целевой экземпляр Foo, которому он перенаправляет вызовы своего метода.

Утилиты рефлексии Java (пакет java.lang.reflect) определяют API Proxy, который автоматизирует описанные выше действия. Proxy Java может динамически создавать прокси-класс с помощью списка интерфейсов и возвращать его прокси-экземпляры. Ниже приведено частичное определение Proxy:

public class Proxy implements java.io.Serializable {

/* Этот метод получает прокси-класс типа или набора типов,
а если его не существует, то он создается динамически */
public static Class getProxyClass( ClassLoader loader,
Class[] interfaces )
throws IllegalArgumentException {...}

/* Данный метод возвращает прокси-объект указанного массива типов интерфейса */
public static Object newProxyInstance( ClassLoader loader,
Class[] interfaces,
InvocationHandler h )
throws IllegalArgumentException {...}

/* Этот метод возвращается, если определенный объект Class является прокси */
public static boolean isProxyClass( Class cl ) {...}

/* Этот метод возвращает обработчик вызова прокси-объекта*/
public static InvocationHandler getInvocationHandler( Object proxy )
throws IllegalArgumentException {...}
}

Не вдаваясь в подробности класса, можно сказать, что метод newProxyInstance() в основном используется при создании прокси. Он возвращает прокси-объект с помощью списка интерфейсов. Сначала он проверяет, есть ли во входном массиве интерфейсов реализующий их прокси-класс. Если такового нет, то он создается для них динамически, а затем возвращается его экземпляр.

Данный метод принимает 3 аргумента:

  • ClassLoader, используемый для загрузки прокси-класса;
  • массив объектов Class, содержащий список интерфейсов, реализуемых прокси-классом;
  • объект InvocationHandler, отвечающий за обработку запросов, полученных прокси-экземпляром (подробнее об этом ниже)

Метод newProxyInstance() просто создает класс, реализующий набор интерфейсов для работы в качестве прокси-класса, и возвращает экземпляр этого класса. Программисту необходимо определить, что именно должен делать прокси-объект при получении вызова метода. Это делается с помощью обработчиков вызовов. Обработчик вызова  —  это объект, ответственный за обработку всех вызовов методов, полученных прокси-объектом. Другими словами, у каждого прокси есть связанный с ним объект InvocationHandler, которому он передает запросы.

Ниже приведено определение интерфейса InvocationHandler:

public interface InvocationHandler {

public Object invoke( Object proxy, Method method, Object[] args ) throws Throwable;

}

Прокси-объект передает вызовы методов своему InvocationHandler с помощью метода invoke(). Его аргументами являются:

  • самостоятельная ссылка на прокси-объект;
  • объект Method вызываемого метода в прокси-объекте;
  • массив объектов, которые являются аргументами, полученными прокси-объектами для вызова метода.

Эти входные данные  —  все, что нужно обработчику вызова для вызова конкретного метода. Каждый разработчик создает пользовательский InvocationHandler для собственного варианта использования. Ниже приведен обработчик вызова, определенный для Foo:

public class FooIH implements InvocationHandler {

Foo target;

public FooIH(){
target= new ConcreteFoo();
}
public FooIH(Foo foo){
target = foo;
}
public void setTarget( Foo foo ){
target = foo;
}
public Foo getTarget(){
return target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

Object result = null;
result = method.invoke( target, args );
return result;
}

}

Класс FooIH имеет переменную Foo target, в которой хранится его целевой объект. Этот объект является экземпляром последней обновленной версии модифицируемого класса Foo. Его метод invoke() не выполняет никакой предварительной обработки, а просто рефлексивно вызывает метод ввода, используя свой объект target.

На следующей диаграмме показаны все объекты, задействованные при вызове метода прокси-объекта:

Изображение автора

Прокси-объект перенаправляет вызов метода своему InvocationHandler, который, в свою очередь, применяет рефлексию для вызова метода в отношении своего целевого объекта, используя объект Method вызываемого метода.

Соберем все вместе

Ниже показан обновленный класс FooFactory после добавления логики прокси:

public class FooFactory {

static private Class implClass;

static ArrayList<WeakReference> proxies;

public static Foo newInstance() throws NoSuchMethodException, InvocationTargetException {
// создание экземпляра Foo с помощью текущего implClass
Foo foo = (Foo) implClass.getDeclaredConstructor().newInstance();

// создание прокси Foo путем передачи объекта foo в качестве цели
Foo fooProxy =(Foo) Proxy.newProxyInstance(
ClassLoader.getSystemClassLoader(),
new Class [] {Foo.class},
new FooIH(foo));

// добавление ссылки на новый прокси в список массивов proxies
proxies.add(new WeakReference(fooProxy));
// возврат объекта прокси
return fooProxy;
}

public static void reload(String dir) throws ClassNotFoundException, NoSuchMethodException{
// загрузка нового объекта Class с помощью CustomClassLoader
ClassLoader cl = new CustomClassLoader(dir);
implClass = cl.loadClass( "ConcreteFoo" );

// получение метода "evolve" из нового класса
Method evolve = implClass.getDeclaredMethod( "evolve", new Class[]{Object.class} );

ArrayList<WeakReference> updatedProxies = new ArrayList<>();

// перебор списка массивов proxies
for ( int i = 0; i < proxies.size(); i++ ) {
// получение текущего прокси
Proxy x =(Proxy)((WeakReference)proxies.get(i)).get();
if ( x != null ) {
// получение обработчика вызовов прокси
FooIH fih = (FooIH)Proxy.getInvocationHandler(x);

// получение цели обработчика вызовов (старый объект)
Foo oldObject = fih.getTarget();

// получение эквивалентного нового объекта Foo с помощью метода evolve
Foo replacement = (Foo) evolve.invoke( null, new Object[]{oldObject} );

// обновление цели обработчика вызовов для нового объекта замены
fih.setTarget( replacement );

// добавление в список ссылок
updatedProxies.add( new WeakReference( x ) );
}
}
proxies=updatedProxies;

}
}

Ниже приведены основные обновления в FooFactory:

  • Добавленная статическая переменная ArrayList proxies хранит список ссылок на все прокси-объекты, созданные фабрикой.
  • Метод newInstance() теперь возвращает прокси-экземпляр Foo вместо обычного экземпляра Foo. В качестве обработчика вызовов прокси передается экземпляр FooIH (объект FooIH внутренне сохраняет целевой объект Foo и перенаправляет к нему вызовы методов). Метод также теперь сохраняет ссылку на созданный прокси в списке массивов proxies.
  • У метода reload() появилась дополнительная задача  —  перебрать список массивов proxies и обновить целевые объекты до объекта Foo из обновленного класса. Для каждого прокси он получает целевой объект Foo, создает эквивалентный объект нового класса, используя метод evolve() нового класса, и устанавливает его в качестве нового целевого объекта.

Обратите внимание на использование оберток объектов WeakReference в списке proxies вместо хранения обычного ArrayList прокси-объектов.

static ArrayList<WeakReference> proxies;

Причина этого заключается в том, что при непосредственном хранении ссылок на прокси Foo они никогда не собираются в мусор, даже если больше не используются приложением. Тип WeakReference позволяет ссылаться на объект, но не предотвращает его сборку в мусор, если у него нет хотя бы одной сильной ссылки. Когда объект собирается в мусор, его WeakReference возвращает null.

Теперь FooFactory может полностью заменить класс Foo и все его экземпляры в системе. При вызове reload() активный модифицируемый класс Foo будет преобразован следующим образом:

Изображение автора

Это действительно полная замена класса! Обратите внимание, что через некоторое время JVM выгрузит Foo версии 1, поскольку он больше не используется.


Заключение

Мы исследовали возможности рефлексии и загрузчиков классов. Рефлексия позволяет динамически типизировать и вызывать классы, в то время как загрузчики классов могут динамически загружать классы во время выполнения программы. Их совместное использование обеспечивает создание гибких, настраиваемых и расширяемых систем. Однако, какими бы полезными они ни были в некоторых случаях, следует учитывать несколько моментов.

  • Рефлексия повышает накладные расходы на производительность. Динамическая типизация требует больше шагов во время выполнения, чем статическая. Более того, рефлексия сопряжена с рисками для безопасности, поскольку многие логические ошибки, обычно обнаруживаемые во время компиляции, переносятся на время выполнения. Это чревато неожиданным поведением программы.
  • Загрузчики классов и рефлексия считаются антишаблонами, поскольку вступают в противоречие с более инкапсулированными системами, которые пытается внедрить платформа Java. Однако программирование на уровне интерфейсов и тщательное использование шаблонов проектирования позволяют преодолеть разрыв между ними, создавая программы, которые при необходимости используют рефлексию, следуя при этом принципам ООП.
  • Не стоит переусердствовать с рефлексией. Когда система чрезмерно гибкая и настраиваемая, ее не всегда легко обслуживать. В конце концов, это территория ООП. Поощряются модульность и инкапсуляция, поскольку они создают надежные системы, доступные для обслуживания. Рефлексия  —  это скорее чит-код, используемый по необходимости, а не способ разработки приложений на Java.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Habiba Gamil: A Deep Dive into ClassLoader and Reflection — Dynamic Typing and Runtime Modifiable Classes in Java

Предыдущая статья10 рекомендаций, которые повысят производительность разработки на Flutter в 2023 году
Следующая статьяПродвинутые техники PHP: от шаблонов проектирования до тестирования. Часть 1