13/03/2020

Великолепная Java (2): сборка проекта — библиотеки, ресурсы

В предыдущей статье (Великолепная Java (1): сборка проекта — компиляция, JAR) мы рассмотрели процесс сборки Java-программ на примере очень просто организованной программы. ООП и принципы архитектуры ПО «SOLID» подразумевают разделение функционала на классы, каждый из которых обеспечивает ограниченный, минимальный с точки зрения архитектуры, функционал. Это подразумевает поставку классов в виде компонентов. Брюс Эккель в своей книге «Философия Java» разделяет разработчиков на «создателей классов» и «программистов-клиентов». Следовательно, в реальной жизни ваши проекты будут организованы несколько (скорее — значительно) сложнее, чем рассмотренный нами ранее пример.
Библиотеки (или компоненты) в Java распространяются в виде тех же .jar-файлов. Да, .jar-файл может содержать не только конечное приложение, но и библиотеки, используемые «программистами-клиентами» (и как мы увидим ниже — любые другие файлы). Библиотека отличается от приложения тем, что не содержит главного класса (того, который мы в прошлой статье указывали при помощи ключа -e). Главный класс отличается от любого класса-компонента тем, что у него есть главная функция — main (). Что, в прочем, не запрещает легко использовать функционал класса, имеющего эту функцию другими классами.
В предыдущей статье мы уже рассматривали преимущества компоновки кода в .jar-файлы, как скомпилированные классы собираются в jar и как запускать .jar-файлы как приложения.
Здесь мы рассмотрим как на Java создавать библиотеки и как создавать приложение с использованием библиотек.
Вы можете спросить — «Зачем мне вообще создавать какие-то компоненты? Я могу просто разнести код на несколько .java-файлов и компилировать их вместе или вовсе реализовать всё в одном файле». У запаковки скомпилированных классов в один компонент (.jar-файл) есть три причины:
Первая — коммерческая: дизассемблирование, хоть и выполнимо, но является задачей непростой, и компании, распространяющие компоненты на коммерческой основе, заинтересованные в сокрытии реализации своих продуктов, не поставляют библиотеки в .java- и .class-файлах. Как мы описывали в предыдущей статье, Java на каждый класс создаёт отдельный .class-файл, и чтобы «красиво» поставлять компонент в виде одного файла, удобно запаковать всю библиотеку (состоящую иногда из сотен .class-файлов) в один .jar-файл. В этой статье мы научимся, в том числе и использовать компоненты, попавшие к нам в руки в виде .jar-файла.
Вторая — архитектурная: компонент представляет (должен быть так спроектирован, чтобы представлять) собой какой-то уровень абстракции. И «программистам-клиентам» удобно использовать функционал этого компонента как отдельный .jar-файл.
Третья — производительность: так как скомпилированный код Java является кроссплатформенным, у разработчиков нет необходимости пересобирать все компоненты при сборке проекта. «Программист-клиент» может использовать скомпилированный однажды компонент в своём проекте где угодно и когда угодно (в отличие от других языков программирования), экономя время на сборку. В крупных проектах эта экономия может быть очень существенной — обратите внимание как, даже такой маленький проект как пример из этой статьи собирается по make clean all.

Начнём. Библиотеки в Java называются package. Идея компоновать классы в библиотеки так же помогает в разрешении конфликтов имён. В разных библиотеках могут находиться классы с одинаковыми именами. Обращаться к ним можно через указание имени библиотеки, в которой он реализован. Это описано в примере кода основного приложения (см. ниже).
Рассмотрим файл с кодом одного из классов, который входит в нашу библиотеку (Hello.java):
package HelloPackage;

public class Hello
{
  public void Print ()
  {
      System.out.println (getClass().getName() + ".Print ()");
  }
}
Ниже представлен пример второго класса из нашей библиотеки (Bye.java):
package HelloPackage;

// Приватный класс библиотеки, в пределах библиотеки он будет 
// доступен, а снаружи её к нему доступа не будет:
class ByeBye
{
  public void Print ()
  {
      System.out.println (getClass().getName() + ".Print ()");
  }
}

public class Bye
{
  public static void main (String args[]) throws Exception
  {
    // Демонстрация возможности упаковки исполняемых классов
    // в библиотеку, скомпилировав проект, попробуйте выполнить:
    // $JAVA_HOME/bin/java HelloPackage.Bye
    System.out.println ("Bye.main()");
  }
  public void Print ()
  {
      // Здесь класс ByeBye доступен:
      ByeBye BB = new ByeBye ();
      BB.Print();
      System.out.println (getClass().getName() + ".Print ()");
  }
}
Обратите внимание на то, что в разных файлах используется одно название package. Я мог бы объединить реализацию всех трёх классов в одном файле, но специально разнёс их для демонстрации того, что одна библиотека может быть собрана из многих файлов и, соответственно, содержать в себе много классов.
По сути дела библиотека (и её имя) это всего лишь название каталога, в который будут положены .class-файлы. Причём компиляция этих файлов в один проход необязательна — в Makefile я описал три отдельных правила для трёх классов из двух файлов. Java при компиляции любого из них, при необходимости, создаст каталог с названием библиотеки и положит в него, соответствующий .class-файл. Если в одном .java-файле реализованы несколько классов, все соответствующие им .class-файлы будут положены в каталог библиотеки. При компиляции другого файла, в котором указано такое же имя библиотеки, Java добавит новые .class-файлы в существующий каталог, не затрагивая уже находящиеся там файлы.
В коде примера так же описан приватный класс библиотеки (класс ByeBye в файле Bye.java) — это неэкспортируемый из библиотеки класс. Такие классы используются для сокрытия реализации внутреннего функционала библиотеки.
Процесс создания библиотеки заключается в следующем: компилируются все классы (javac с ключом -d — создание библиотеки), затем каталог с классами библиотеки упаковывается в .jar-файл.
Весь этот механизм описан в Makefile, здесь не буду разъяснять все ключи и параметры этого процесса. После сборки библиотеки в .jar-файл мы можем его распространять другим разработчикам единым файлом, без .java- и .class-файлов. Никаких дополнительных файлов (наподобие .h-файлов) не нужно.
Далее, имея .jar-файл библиотеки (либо только что собранный нами, либо полученный от внешнего поставщика), мы собираем основное приложение. Здесь нам нужно создать т.н. манифест — файл с указаниями параметров для Java. Эти параметры будут использоваться при запуске нашего приложения. Сам манифест представлен в проекте. Здесь ограничимся необходимыми нам параметрами. Их два, и они уже известны нам из предыдущей статьи (там мы указывали их в параметрах командной строки):
Main-Class — имя класса, который будет запускаться и имеет функцию main():
Main-Class: HelloWorld
и в Class-Path мы указываем имя .jar-файла нашей библиотеки — Hello.jar (здесь может быть любое количество библиотек, в том числе и полученных из сторонних источников)
Class-Path: Hello.jar
Код класса основного приложения:
import HelloPackage.*;
import java.io.*;

class HelloWorld
{
  public static void main (String args[]) throws Exception
  {
    // Вот так выглядело бы обращение к библиотечному классу
    // без import'а (тоже работает, но писать больше):
    // HelloPackage.Hello lHello = new HelloPackage.Hello ();
    // А так без import'а не сработает:
    Hello lHello = new Hello ();
    Bye lBye = new Bye ();
    
    // Ошибка: ByeBye является приватным классом библиотеки:
    // ByeBye lByeBye = new ByeBye ();
    
    // Получаем доступ к ресурсу - нашему текстовому файлу:
    InputStream lInputStream = 
            HelloWorld.class.getResourceAsStream("Info.txt"); 
    BufferedReader lBufferedReader = 
            new BufferedReader (new InputStreamReader(lInputStream)); 
    String lString = "";
    // Обращаемся к первому классу из нашей библиотеки:
    lHello.Print();
    // Выводим содержимое текстового файла из ресурсов:
    System.out.println ("Strings read from JAR-resource: ");
    while ((lString = lBufferedReader.readLine()) != null) 
      System.out.println(lString); 
    // Обращаемся к другому классу из нашей библиотеки:
    lBye.Print();
  }
}
Использование библиотеки сводится к простому импорту её содержания. В нашем примере мы импортируем все классы (разумеется публично экспортируемые из библиотеки). Выглядит это так:
import HelloPackage.*;

Как я уже писал, мы можем записать в .jar-файл любые файлы. В Java файлы упакованные в .jar-файл называются ресурсами. В примере выше описан механизм доступа к содержимому текстового файла.

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

Что в этом примере сделано не совсем правильно. В демонстративно-образовательных целях я назвал .jar-файл с библиотекой именем, отличным от имени библиотеки. В целях повышения читаемости и понимания кода, а, следовательно, облегчения его дальнейшей поддержки, так делать не стоит.
Так же в этом примере я разместил код библиотеки и код исполняемого класса в один каталог и свёл всю сборку в один Makefile. С точки зрения архитектуры, это неправильно и так поступать в своей профессиональной деятельности не нужно. Обычно создают отдельный каталог, например lib, в котором лежат все библиотеки. Так как часто «программисты-клиенты» используют библиотеки, разрабатываемые не ими — либо своими коллегами, либо вовсе полученные извне компании, имеет смысл выделить библиотеки в отдельный модуль в системе контроля версий (например, submodule в git).
Если библиотека разрабатывается внутри коллектива, то иногда в принципе можно  отказаться от сборки её в .jar-файл и просто запаковать каталог (в нашем случае, HelloPackage) со всеми скомпилированными классами в .jar-файл с приложением, содержащим основную программу, не забыв указать соответствующий classpath в манифесте приложения.
Теперь по названиям компонентов — в Java принято называть компоненты по шаблону com.companyname.packagename. К слову, com здесь обозначает commercial. В примере я этому не следовал, (не только я) считаю это необязательным. Следовать этому правилу или нет — решать вам.

Чтобы посмотреть весь проект и поиграться скачайте:
git clone https://gitlab.com/daftsoft/javalibstutorial.git