Собственное файлохранилище: мечта детства и spring-boot приложение от начала до конца

Наверное, у каждого человека есть какие-то мечты детства, которые не сбылись. Кому-то не подарили игрушечную железную дорогу, кого-то не взяли в гимназию, кому-то не досталось своей комнаты. К великому счастью, и дорога и гимназия и комната у меня были. А вот своей личной файлопомойки не было, и это меня очень угнетало. Сначала, я не умел в программирование, потом — у меня не было денег на аренду сервера, а вскоре — времени всем этим заняться. И вот — свершилось: сейчас мы с вами накодим свой собственный сервис для временного хранения файлов на Spring Boot 2. А заодно, посмотрим, как из джавакода и парочки утилит сделать нормальный такой сервис, который крутится на nix-сервере и не мешает жить

Опять не баш?!

Опять. И даже больше — не nginx, который можно было бы наконфигурировать, чтобы он делал ровно то, что мы сейчас напишем на джаве. А все потому, что завтра нам захочется добавить еще вон ту маленькую фичу на три поинта и чтобы ее органично вкорячить в существующую кодовую базу, придется переписать все заново да еще и на нормальном языке программирования. Ну не должен nginx писать мета-данные файлов в PostgreSQL, это плохо, ужасно и неправильно. Поэтому берем в руки Java, создаем новый maven — проект, добавляем щепотку гита и начинаем понимать

А что пишем?

Считается, что сервис для временного хранения файлов. Ну или файлопомойку, кому как больше нравится. В общем, независимо от названия, пользователь должен иметь возможность взять свой файл со своего SSD и аккуратно влить на сервер, который сожрет этот файлик, положит к себе в папочку и выдаст пользователю ссылку на скачивание, а так же удалит файл из своей папочки через какое-то время. Ни ограничений на размер файла ни запрета на хранение исполняемых файлов у нас не будет потому что не надо. Зато будет полноценное REST api, маленькое, но гордое SPA-приложение в качестве web UI и даже своя база данных для хранения мета-данных файлов.

Зачем нам Spring?

Затем, что мы с вами очень любим свое свободное время. Если точнее — мы любим проводить его с пользой и удовольствием, а не в отчаянных попытках написать очередную имплементацию джава-сервера, который будет принимать http-запрос, процессить его по каким-то своим темным правилам, ходить в БДшку и отдавать результат. Все эти велосипеды уже давно написаны и заботливо сложены в одну удобную коробочку, которая называется Spring Boot. Поэтому, сэкономим несколько часов нашей жизни, добросив в pom файл несколько зависимостей

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.0.0.RELEASE</version>
</parent>

<dependencies>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

</dependencies>

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

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

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

Spring умеет питаться несколькими типами конфигурационных файликов: в основном, пользуются application.properties и application.yml форматами описания. Нас интересует последний: он нагляднее, умеет во вложенность и вообще няшка. Создаем такой файл в main/resources

spring:
  datasource:
    url: "jdbc:postgresql://localhost:5432/DB_NAME"
    username: DB_USER
    password: PASSWORD
    driver-class-name: org.postgresql.Driver

  jpa:
    database_platform: org.hibernate.dialect.PostgreSQLDialect
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        temp.use_jdbc_metadata_defaults: false

  servlet:
    multipart:
      max-file-size: -1
      max-request-size: -1

server:
    port: 6060

Не обращайте внимание на магию в виде «temp.use_jdbc_metadata_defaults», это известный баг интеграции postgres и spring boot 2, вернее — дефолтно запускающегося там hikkari pool. Теперь, запущенный джарник поднимет подключение к БД, откроет коннекцию к базе и прочно оккупирует 6060 порт, ожидая сетевых запросов от пользователя

А что слать будем?

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

В БДшке хранить мы будем именно что сериализованные мета-данные, поэтому начнем кодить наш сервис с описания схемы данных

@Entity
public class FileAsset implements FileStorageEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Access(AccessType.PROPERTY)
    private Long id;
    private String name;
    private String fileName;
    private String hash;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS")
    private LocalDateTime createdDateTime;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS")
    private LocalDateTime expiringDateTime;

    public FileAsset() {
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getFileName() {
        return fileName;
    }

    public void setFileName(String fileName) {
        this.fileName = fileName;
    }

    public String getHash() {
        return hash;
    }

    public void setHash(String hash) {
        this.hash = hash;
    }

    public LocalDateTime getCreatedDateTime() {
        return createdDateTime;
    }

    public void setCreatedDateTime(LocalDateTime createdDateTime) {
        this.createdDateTime = createdDateTime;
    }

    public LocalDateTime getExpiringDateTime() {
        return expiringDateTime;
    }

    public void setExpiringDateTime(LocalDateTime expiringDateTime) {
        this.expiringDateTime = expiringDateTime;
    }

    @Override
    public String toString() {
        return "FileAsset{" +
            "id=" + id +
            ", name='" + name + '\'' +
            ", fileName='" + fileName + '\'' +
            ", hash='" + hash + '\'' +
            ", createdDateTime=" + createdDateTime +
            ", expiringDateTime=" + expiringDateTime +
            '}';
    }
}

Каждая сущность в обязательном для Jpa — контракта и хибернейтовской его реализации, которую Спринг по умолчанию хочет пользовать, имеет аннотацию @Entity, которая намекает на назначение класса, пустой конструктор, который уже можно не описывать явно и сеттеры/геттеры для каждого из полей, которых может быть сколько угодно и которые могут во всякие one-to-many, many-to-one и many-to-many связки. Сейчас они нам правда не очень нужны, потому что табличку в базе мы хотим ровно одну.

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

После того, как мы описали целую кучу сущностей, мы имеем право хотеть описывать механизм доступа к их сериализованной версии в Базе Данных. Если бы мы писали свой велосипед, кодить бы нам здесь полноценный Data-Access-Layer слой и возиться с JDBC-template. Мы же, вместо всего этого, завезем в проект репозиторий, который спринг подхватит на старте и на магической силе reflection api сделает все за нас

public interface FileAssetRepository extends FileStorageRepository<FileAsset> {
    Optional<FileAsset> findByHash(String hash);

    Optional<FileAsset> findById(Long id);

    Optional<FileAsset> findByFileName(String fileName);

    List<FileAsset> findByExpiringDateTimeBefore(LocalDateTime time);
}

И это круто. Мы не пишем никаких реализаций. Мы просто наследуем наш интерфейс от такого же нереализованного, а потому идеального JpaRepository, причем косвенно, через наш собственный интерфейс, а Hibernate под капотом спринга сделает все за нас. Главное — правильно назвать методы.

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

Э-эй, стой! А где файлы? 

Да, забыли чуток. Ничо, щас быстро вспомним, что файлы — это просто массив байт и хранить его можно несколькими способами.

Например, просто положить Blob объект в поле Базы Данных. И, если объект маленький, а СУБД жирная, все будет прекрасно работать. Гораздо хуже получится, если у нас наоборот, потому что БД — это все-таки про структуризированные данные, а не бинарную кашу.

Еще, ничего не мешает нам сериализовать наш файл в Base64 строчку и положить в БДшку уже не кашу из байт, а вполне себе строку с читабельными символами. И снова — для маленьких файлов, типа обложек от книг, это работает прекрасно. Для больших — есть риск положить не только СУБД таким километровым инсертом, но и сам сервер, который выжрет всю оперативку и досрочно скончается от ожирения

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

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

@Component
public class FileStorageProvider {

    private final ConfigurationProvider configurationProvider;

    public FileStorageProvider(ConfigurationProvider configurationProvider) {
        this.configurationProvider = configurationProvider;
    }

}

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

public File uploadFile(File file) {
    if (!Files.exists(getUploadFolder().toPath())) {
        createUploadDir();
    }

    String pathToSave = getUploadFolder() + File.separator + UUID.randomUUID().toString();
    File savedFile = new File(pathToSave);

    try {
        FileUtils.copyFile(file, savedFile);
    } catch (IOException e) {
        e.printStackTrace();
        throw new FileStorageException("Can't save file.");
    }

    deleteFile(file);

    return savedFile;
}

public List<File> getFiles() {
    return Arrays.asList(getUploadFolder().listFiles());
}

public File getFile(String fileName) {
    String filePath = getUploadFolder() + File.separator + fileName;
    return new File(filePath);
}

public void deleteFile(File file) {
    if (file.exists()) {
        file.delete();
    }
}

private File getUploadFolder() {
    return new File(configurationProvider.getFileUploadDirectory());
}

private void createUploadDir() {
    File uploadDir = new File(configurationProvider.getFileUploadDirectory());
    uploadDir.mkdirs();
}

Ну вот и ответ на вопрос об уникальности имен. UUID гарантирует нам 2128 различных комбинаций, поэтому переживать о коллизии имен можно совсем никогда.

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

Кстати, о конфигурации. Если уж мы и так пользуемся спрингом, почему бы не нагрузить его еще и работой с кастомными конфигами?

Spring из коробки умеет расширять свой application.yml всем, что нам нужно и пробрасывать доступ к любому его значению. Давайте добросим в файл конфигурации несколько строк?

config:
    fileUploadDirectory: file-upload
    expirationDays: 7
    maxExpirationDays: 180

Это все, что мы хотели сказать. Теперь, напишем классик, который позволяет получить значение из конфига обычным геттером

Component
@ConfigurationProperties(prefix = "spring.config")
public class ConfigurationProvider {

    private String fileUploadDirectory;

    private Integer pageSize;

    private Integer expirationDays;

    private Integer maxExpirationDays;

    public String getFileUploadDirectory() {
        return fileUploadDirectory;
    }

    public void setFileUploadDirectory(String fileUploadDirectory) {
        this.fileUploadDirectory = fileUploadDirectory;
    }

    public Integer getPageSize() {
        return pageSize;
    }

    public void setPageSize(Integer pageSize) {
        this.pageSize = pageSize;
    }

    public Integer getExpirationDays() {
        return expirationDays;
    }

    public void setExpirationDays(Integer expirationDays) {
        this.expirationDays = expirationDays;
    }

    public Integer getMaxExpirationDays() {
        return maxExpirationDays;
    }

    public void setMaxExpirationDays(Integer maxExpirationDays) {
        this.maxExpirationDays = maxExpirationDays;
    }
}

Еще один бин, на это раз без зависимостей. Важно только не забывать про сеттеры: по умолчанию, для де/сериализации, спринг хочет пользовать Jackson, который отличается от Gson большей деликатностью и вместо того, чтобы с покерфейсом вбивать данные в приватные поля, вежливо пользует методы, которые начинаются с префикса get или set. Или не пользует, если не найдет, причем узнаем мы об этом скорее всего по NPE, невесть откуда взявшейся на продакшне. Поэтому, генерим IDE нужные методы и переходим к самому интересному

Бизнес — логика

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

Сейчас — напишем наш сервисный слой, который должен делать всю грязную работу: сохранять файлы и доставать их обратно

@Service
public class FileAssetService implements FileStorageService<FileAsset> {

    private final FileAssetRepository fileAssetRepository;
    private final FileStorageProvider fileStorageProvider;
    private final ConfigurationProvider configurationProvider;

    public FileAssetService(FileAssetRepository fileAssetRepository,
                            FileStorageProvider fileStorageProvider,
                            ConfigurationProvider configurationProvider) {
        this.fileAssetRepository = fileAssetRepository;
        this.fileStorageProvider = fileStorageProvider;
        this.configurationProvider = configurationProvider;
    }
}

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

public FileAsset createFileAsset(FileAsset fileAsset, File content) {

        return fileAssetRepository.findByHash(getFileHash(content)).orElseGet(() -> {
            validateFileAsset(fileAsset);
            validateFileContent(content);

            File savedContent = fileStorageProvider.uploadFile(content);

            try {
                return persist(fileAsset, savedContent);
            } catch (RuntimeException ex) {
                fileStorageProvider.deleteFile(savedContent);
                throw ex;
            }
        });

}

Поскольку, мы возвращаем из репозитория не сам ассет, а Optional от него, нам становятся доступны всякие там новомодные java 8 штуковины, типа orElseGet. В остальном же, логика простая — мы либо находим файл по его хешу в БД и возвращаем ассет, либо создаем новый ассет и возвращаем уже его. Это избавит нас от всяких там дубликатов и сэкономит кучу места на диске сервера.

Отдельно, надо сказать про метод persist(). Формально — это наш кастомный метод и никакой магии в нем нет

private FileAsset persist(FileAsset entity, File savedFile) {

        entity = setCreatedDateTime(entity);
        entity = setExpirationDateTime(entity);
        entity = setHash(entity, savedFile);
        entity = setFileName(entity, savedFile);
        return fileAssetRepository.save(entity);
}

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

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

public FileAsset getFileAsset(String hash) {
        return fileAssetRepository
            .findByHash(hash)
            .filter(fileAsset -> fileAsset
                .getHash()
                .equalsIgnoreCase(EncryptionUtils.getFileHash(fileStorageProvider.getFile(fileAsset.getFileName()))))
            .orElseThrow(() -> new EntityNotFoundException(FileAsset.class));
}

Не забываем провалидировать физическое наличие файла на диске: обидно будет получить мета-данные от ничего. В остальном — та же магия, что и выше — или файл или исключение. Про методы, которые удаляют файлы и получают всю просрочку из БДшки разом поговорим в другой раз

Где моя апишка?

Мда, неудобненько получилось. У нас есть относительно рабочий сервис, который при этом — вещь в себе и без настоятельного пинка ничего делать не будет. Чтобы иметь возможность попинывать логику из вне — нужен механизм взаимодействия между сервисом и клиентом. Поэтому, начинаем писать эндпоинт

RestController
@RequestMapping("/api/v1/files")
public class FileAssetEndpoint {

    private final FileAssetService fileAssetService;
    private final FileStorageProvider fileStorageProvider;
    private final ObjectMapper objectMapper;

    public FileAssetEndpoint(FileAssetService fileAssetService, FileStorageProvider fileStorageProvider, ObjectMapper objectMapper) {
        this.fileAssetService = fileAssetService;
        this.fileStorageProvider = fileStorageProvider;
        this.objectMapper = objectMapper;
    }
}

Снова один, снова перевязан интерфейсом консистентности ради и снова принимает все свои зависимости в конструкторе. @RequestMapping у нас глобально отвечает за то, на какой url должен быть настроен хендлер, причем, все методы со своим маппингом автомагически начнут наследовать родительский

Кстати, о методах: давайте загружать файлы?

@PostMapping("")
    public ApiResponse<FileAsset> uploadFile(
        @RequestPart(value = "asset", required = false) String assetString,
        @RequestPart(value = "file") MultipartFile file) throws IOException {

        FileAsset asset;

        if (null == assetString || assetString.isEmpty()) {
            asset = new FileAsset();
            asset.setName(file.getOriginalFilename());
        } else {
            asset = objectMapper.readValue(assetString, FileAsset.class);
        }

        File tempFile = Files.createTempFile(UUID.randomUUID().toString(), file.getOriginalFilename()).toFile();
        file.transferTo(tempFile);

        return new ApiResponse<>(fileAssetService.createFileAsset(asset, tempFile));
}

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

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

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

multipart.getName();
multipart.getName();

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

Чтобы качественно и навсегда пресечь эти игрища возьмем и создадим нормальный файл в temp-папке ОС, куда сольем все байтики мультипарта, после чего скормим уже этот неисчезающий файлик сервису — он с ним разберется — и вернем какой-то респонс назад. Останется только вернуть сам файл

@GetMapping("{hash}")
    public HttpEntity<byte[]> downloadFile(@PathVariable String hash) throws IOException {
        FileAsset asset = fileAssetService.getFileAsset(hash);
        File content = fileStorageProvider.getFile(asset.getFileName());

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.set(HttpHeaders.CONTENT_DISPOSITION,
            "attachment; filename=" + asset.getName().replace(" ", "_")
        );

        httpHeaders.setContentLength(content.length());
        return new HttpEntity<>(FileUtils.readFileToByteArray(content), httpHeaders);
}

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

Кстати, в этом методе кроется ответ на вопрос о том, как вернуть пользователю файл с оригинальным именем, если у нас он именован рандомным UUID. Мы просто передаем браузеру имя файла из БДшки, а как там назывался контейнер для бинарщины, в этот момент никого не волнует

@DeleteMapping("{id}")
    public ApiResponse deleteFile(@PathVariable Long id) {
        fileAssetService.getFileAsset(id);
        return new ApiResponse("File has been deleted.");
}

С удалялкой файлов никакой сложности — приняли запрос, удалили файл с ассетом и сказали об этом пользователю. Ну или не нашли, но все равно сказали: пользователю-то какая разница, что там с файлом. Главное, что он больше недоступен

А как же протухшие файлы?

Да, есть такая задачка. У каждого ассета в нашей БДшке есть срок экспайринга, после которого и сам ассет и файл с диска должны куда-нибудь исчезнуть, а лучше — пропасть. При этом, мы совершенно не уверены в том, что пользователь захочет удалять файл самостоятельно — оно ему зачем? Поэтому, сейчас мы тихонько напишем механизм, который будет проходить по всей БДшке и подчищать за собой все протухшие файлы

@Service
public class ExpiredFilesActuator implements MaintenanceAction {

    private final FileAssetService fileAssetService;

    public ExpiredFilesActuator(FileAssetService fileAssetService) {
        this.fileAssetService = fileAssetService;
    }

    @Override
    public void execute() {

        fileAssetService.findExpiredFiles()
            .forEach(fileAsset -> fileAssetService.deleteFileAsset(fileAsset.getId()));
    }
}

К слову сказать, обычная такая реализация интерфейса. Ничего особенного: нашли и удалили. Осталось только выяснить, как запускать это дело по времени, а не по пинку от пользователя

@Service
public class MaintenanceService {

    private final List<MaintenanceAction> actions;

    public MaintenanceService(List<MaintenanceAction> actions) {
        this.actions = actions;
    }

    @Scheduled(fixedRate = 60 * 60 * 1000)
    public void maintenanceConfigure() {
        try {
            actions.forEach(MaintenanceAction::execute);
        } catch (Exception ignored) {

        }
    }
}

Выяснили. Листик actions, в момент создания бина спрингом будет содержать в себе все бины, классы которых имплементят интерфейс. После этого, каждый час мы будем проходиться по этому листику и у каждого класса вызывать execute, который сделает нам все, что требуется. И, наверное, это единственный случай оправданного использования имплисит-конфига: никакой боли он не приносит, а для того, чтобы завезти в проект еще одну джобу достаточно просто докинуть классик и повесить на него контракт из нужного интерфейса

Мигрируем!

Так, сервер готов. Осталось только в БД описать схему данных, чтобы hibernate мог свободно писать и читать.

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

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

<build>
    <plugins>

        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>

        <plugin>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-maven-plugin</artifactId>
            <version>4.2.0</version>
            <configuration>
                <driver>org.postgresql.Driver</driver>
                <url>jdbc:postgresql://grakovne.org:5432/DB_HOST</url>
                <user>DB_USER</user>
                <password>DB_PASSWORD</password>
            </configuration>
        </plugin>
    </plugins>
</build>

После этого, в папку src/main/resources/db/migration положим SQL — скрипт, который начинается с «V1__» и заканчивается на «.sql»: без этой магии, Flyway может не понять, что это вообще за файл и в каком порядке его надо выполнять на СУБД.

Внутри ничего необычного — простой SQL-скрипт

CREATE TABLE file_asset
(
  id                 BIGSERIAL NOT NULL
    CONSTRAINT file_asset_pkey
    PRIMARY KEY,
  created_date_time  TIMESTAMP,
  expiring_date_time TIMESTAMP,
  file_name          VARCHAR(255),
  hash               VARCHAR(255),
  name               VARCHAR(255)
);

Теперь останется только запустить плагин по команде mvn flyway:migrate и получить готовую к употреблению базу данных

Деплой!

Да, пора бы. В самом простом случае, мы просто сделаем mvn clean install и отправим получившиеся 20 метров jar-архива на сервер под Ubuntu, где консолькой напишем java -jar filename.jar и убедимся, что все работает. Проблема в том, что как только мы пристрелим консольку, джарник сам собой упадет, а это уже плохо

Чтобы наш сервер мог работать в фоне, сделаем из него полноценный сервис и завернем его в systemctl. Так мы сможем удобно и интерактивно управлять сервисом, который будет при этом работать в фоне

sudo apt install systemctl -y

Отлично, исполнялку поставили. Теперь идем по адресу /etc/systemd/system и создаем там новый файлик, который оканчивается на .service

[Unit]
Description=Java Service

[Service]
User=nobody
# The configuration file application.properties should be here:
WorkingDirectory=[path to directory with java file]
ExecStart=/usr/bin/java -jar [filename.jar]
SuccessExitStatus=143
TimeoutStopSec=10
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Здесь у нас три раздела: Unit, который описывает назначение, Service, который описывает, как сервис должен себя вести и Install, который в нашем случае говорит, кому сервис нужен. Теперь сохраним наш конфиг и запустим сам сервис

systemctl daemon-reload
systemctl enable [service-name]
systemctl start [service-name]

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

Ичо, так на 6060 и ходить?

Мда, это как-то не очень. По идее, пользователь вообще ничего не должен знать о том, что на каком порте сидит и как все это управляется. Пользователь имеет право знать только домен сервера и этим доменом активно пользоваться. К великому нашему несчастью, самый крутой 80-й порт уже прочно занят самым крутым блогом и освобождаться не собирается.

Поэтому, давайте настроим Apache2 так, чтобы он все запросы на определенный домен с 80го порта разворачивал на 6060-ый, а все ответы от 6060 — обратно?

Для этого, идем в папку /etc/apache2/sites-available и создаем там новый .conf файлик

<VirtualHost *:80>
  ServerName  api.files.grakovne.org
  ServerAlias api.files.grakovne.org
  ServerAdmin grakovne@gmail.com

  ProxyPass         /  http://localhost:6060/
  ProxyPassReverse  /  http://localhost:6060/
  ProxyRequests     Off

  <Proxy http://localhost:9090/*>
    Order deny,allow
    Allow from all
  </Proxy>

</VirtualHost>

Это называется реверс-прокси. И, кстати, не надо спрашивать, почему у меня на боевой тачке Apache вместо сервера, ладно? Исторически сложилось, теперь уже поздно. Зато, не поздно включить наш новый поддомен и перезапустить апач

a2ensite files
service apache2 restart

После этого, можем сколько угодно проверять, что все запросы с api.files.grakovne.org отлично уходят на grakovne.org:6060 и так же отлично возвращаются назад. Крутотень!

Сервис с человеческим лицом

Сам сервис готов и это замечательно. Правда, пользоваться им немного совсем невозможно, потому что сочинять curl запрос или открывать Postman, чтобы поделиться картинкой с миром будет очень и очень не каждый.

Поэтому сейчас мы припишем к этой апишке какой-нибудь простенький и минималистичный web-клиентик, с которым будет как-то полегче, чем с суровыми запросами к апи

Код на JS я пишу как правило плохо и с закрытыми глазами, а понимаю его еще хуже, поэтому начнем со старой-доброй html-разметки

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>GrakovNe's File Storage service</title>
    <link rel="icon" type="image/png" href="icon.png">
    <link rel="stylesheet" href=css/bootstrap.min.css>
    <link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"
          integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
    <link rel="stylesheet" href=css/styles.css>
</head>
<body>

<div id="content-container" class="center-vertical">
    <div class="page-title text-center">
        <h1 class="page-hint">PUT YOUR FILE HERE</h1>
    </div>

    <div class="uploaded-file-link-container input-group input-item col-sm-8 col-sm-offset-2 input-group-lg">
            <span id="uploaded-status" class="input-group-addon">URL:</span>
            <input id="url-container" type="text" class="form-control" placeholder="http://grakovne.org">
            <div id="copy-to-clipboard-button" class="input-group-addon"><i class="custom-button fa fa-clipboard"
                                                                            aria-hidden="true"></i></div>
        </div>

        <div class="uploading-bar-container input-item col-sm-8 col-sm-offset-2 input-group-lg">
            <div id="uploading-bar" class="progress uploading-bar">
                <div id="uploading-bar-child" data-percentage="0%" class="progress-bar progress-bar-info"
                     role="progressbar"
                     aria-valuemin="0" aria-valuemax="100"></div>
            </div>
        </div>

</div>

<script src="js/jquery-3.1.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/clipboard@1/dist/clipboard.min.js"></script>
<script src="/js/application.js"></script>

</body>
</html>

Ну, это то, что мы все любим. Теперь натянем на это добро чуть-чуть стилей

body {
    background-color: #fafafa;
}

.controllers-button {
    margin: 3% 3% 3%;
}

.content-container {
    width: 70%;
}

.input-item {
    margin-bottom: 5%;
}

.page-title {
    margin-bottom: 5%;
    margin-top: -5%;
}

.center-vertical {
    width: 100%;
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
}

#uploaded-status {
    -webkit-transition: all 2s;
    -moz-transition: all 2s;
    -o-transition: all 2s;
    transition: all 2s;
}

.custom-button {
    cursor: pointer;
}

.page-hint {
    display: block;
}

.progress {
    overflow: hidden;
    border-radius: 4px;
    -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);

    height: 20px;
    background-color: #ebeef1;
    background-image: none;
    box-shadow: none;

    background-image: -webkit-gradient(linear, left 0, left 100%, from(#ebebeb), to(#f5f5f5));
    background-image: -webkit-linear-gradient(top, #ebebeb 0, #f5f5f5 100%);
    background-image: -moz-linear-gradient(top, #ebebeb 0, #f5f5f5 100%);
    background-image: linear-gradient(to bottom, #ebebeb 0, #f5f5f5 100%);
    background-repeat: repeat-x;
    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
}

.progress-bar {
    float: left;
    width: 0;
    height: 100%;
    font-size: 12px;
    line-height: 20px;
    color: #fff;
    text-align: center;
    background-color: #0a56ec;
    -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
    box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
}

#uploading-bar-container {
    -webkit-transition: all 2s;
    -moz-transition: all 2s;
    -o-transition: all 2s;
    transition: all 2s;
}

.uploading-bar {
    width: 95%;
    margin: 0 auto;
}

А теперь — самая вкусная часть: нам нужно принимать файл от пользователя, формировать с этим файлом запрос к апишке и полученный результат показывать обратно

var urlContainer = $('#url-container');
var uploadedStatus = $('#uploaded-status');
var clipboardButton = $('#copy-to-clipboard-button');
var resultForm = $('.uploaded-file-link-container');
var pageTitle = $('.page-hint');
var uploadingBarContainer = $('.uploading-bar-container');

var shortenUrlCount = 0;

function startUploadingAction(file) {

    pageTitle.html("UPLOADING YOUR FILE");

    hideResultForm();
    setUploaingProgress(0);
    uploadingBarContainer.show();

    var formData = new FormData;
    formData.append('file', file);

    $.ajax({
        xhr: function () {
            var xhr = new window.XMLHttpRequest();
            xhr.upload.addEventListener("progress", function (evt) {
                if (evt.lengthComputable) {
                    setUploaingProgress(evt.loaded / evt.total);
                }
            }, false);

            return xhr;
        },

        type: "POST",
        url: "http://api.files.grakovne.org/api/v1/files/",
        data: formData,
        processData: false,
        contentType: false,
        success: function (data) {
            shortenUrlCount++;

            setDoneToStatus();
            setUrlToEdit(buildDownloadingUrl(data['body']['hash']));
            showCopyToClipBoardButton();
        }
    });

}

$("*").on({
    'dragover dragenter': function (e) {
        e.preventDefault();
        e.stopPropagation();
    },
    'drop': function (e) {
        var dataTransfer = e.originalEvent.dataTransfer;

        if (dataTransfer && dataTransfer.files.length) {
            e.preventDefault();
            e.stopPropagation();

            console.log(dataTransfer.files);
            startUploadingAction(dataTransfer.files[0])
        }
    }
});

function copyShortenUrlToClipBoard() {
    urlContainer.select();
    document.execCommand("Copy");
}

clipboardButton.click(function (e) {
    copyShortenUrlToClipBoard();
});

function setDoneToStatus() {
    pageTitle.html("TAKE YOUR LINK");

    showResultForm();
    uploadingBarContainer.hide();
    uploadedStatus.css('color', 'white');
    uploadedStatus.addClass("label-success", 1000);
    uploadedStatus.html("<span class=\"glyphicon glyphicon-ok\"></span>");
}

function setUrlToEdit(url) {
    urlContainer.val(url)
}

function buildDownloadingUrl(hash) {
    var urlPrefix = location.protocol + '//' + location.hostname + '/';
    return urlPrefix + hash;
}


function initViews() {
    hideResultForm();
    uploadingBarContainer.hide();
}

function setUploaingProgress(progress) {
    $("#uploading-bar-child").css("width", progress * 100 + "%");
}

function hideResultForm() {
    resultForm.hide();
}

function showResultForm() {
    resultForm.show();
}

initViews();

Писать на джаве мне как-то приятнее, ну да ладно. Теперь давайте забросим это в репозиторий и пойдем хостить на сервер, где нас уже дожидается все тот же Apache2

Опять конфигурить?

На этот раз, совсем немного. Кроме того, что нам нужно описать еще один поддомен, который не только смотрит на папку в /var/www/file-storage, но и умеет превращать ссылки вида http://files.grakovne.org/d72d36f1ec63260e9fbae8e403718505 в http://api.files.grakovne.org/api/v1/files/d72d36f1ec63260e9fbae8e403718505, чтобы не травмировать несчастного пользователя слишком длинной ссылкой

<VirtualHost *:80>
  ServerName  files.grakovne.org
  ServerAlias files.grakovne.org
  ServerAdmin grakovne@gmail.com
  DocumentRoot /var/www/files
  ErrorDocument 404 http://grakovne.org:6060/api/v1/files%{REQUEST_URI}
</VirtualHost>

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

Ну вот! Теперь, после всех этих развлечений, у нас с вами наконец-то появилась долгожданная тысячапервая файлопомойка в мире и исполнилась еще одна заветная мечта детства

Исходники добротного бекенда на джаве можно забрать здесь

Кошмарный код фронтенда — вот тут

Искренне Ваш, заливающий на свой же сервер свои же файлы, GrakovNe

Top