Transcription
Привет, ребята, и добро пожаловать на новое видео. В этом видео я покажу вам, как реализовать загрузку и скачивание файлов в вашем бэкенде Spring Boot с использованием Java. Если вы давно знаете мой канал, вы можете спросить: "Хорошо, почему Java? Почему не Kotlin?". Потому что большая часть моего контента на Kotlin. Но поскольку Java по-прежнему является основным языком, используемым для Spring Boot в индустрии, я также хочу немного использовать Java здесь и там. Но если вы пришли из Java и ничего не знаете о Kotlin, я все равно настоятельно рекомендую попробовать его. У меня есть экспресс-курс по Spring Boot на Kotlin, например. Так что в итоге, посмотрев это видео, вы сможете сделать следующее: у вас будет конечная точка для загрузки изображений, но которая также работает с любыми другими файлами, и тогда вы сможете прикрепить определенные файлы здесь, новый файл с локальной машины. Я выберу изображение здесь, нажму "Открыть", и затем, если мы отправим этот запрос, он скажет: "Хорошо", если мы посмотрим на тело, мы получим размер файла, исходное имя, идентификатор, и если мы затем возьмем этот идентификатор и скопируем его в нашу другую конечную точку здесь, вставим его в наш URL, а затем нажмем "Отправить", то, зайдя в тело, мы получим здесь это очень большое красивое изображение. И я могу сразу сказать, что это не будет типичное видео по загрузке/скачиванию файлов Spring Boot, которое вы найдете на YouTube, потому что очень часто люди просто показывают вам: "Эй, это многокомпонентный запрос. Вот как вы загружаете файл. Хорошо, здесь у вас есть какой-то поток ввода. Делайте с ним что хотите. Вот как вы его скачиваете". Я действительно хочу дать вам производственный подход. Я хочу поделиться лучшими практиками, когда дело доходит до хранения файлов. Я поделюсь некоторыми лучшими практиками безопасности. Я покажу вам, как мы можем действительно взять эту логику, эту логику хранения файлов и интегрировать ее в надлежащую архитектуру Spring Boot. Итак, я здесь в чистом проекте Spring Boot на Java. Вы можете видеть, что у меня есть только мое приложение и в нашем файле `build.gradle.kts` или любом другом менеджере зависимостей, который вы используете. У меня действительно есть только зависимость `spring-boot-web` и MongoDB, потому что MongoDB будет использоваться здесь для хранения метаданных для изображений, которые мы загрузили. Это всегда первая лучшая практика, которую я могу рекомендовать придерживаться, потому что я очень часто вижу, как люди делают это: они берут фактическое необработанное изображение, то есть фактические необработанные байты, и, возможно, кодируют их в base64 каким-либо образом, просто чтобы иметь возможность как-то поместить их в документ базы данных или запись базы данных. И это то, что обычно не является умным решением, потому что ячейки базы данных или, в данном случае, документы MongoDB, все они имеют там максимальный предел. Сохранение таких больших данных может замедлить ваши запросы. Так что гораздо лучше, если вы фактически возьмете файлы и сохраните их в какой-либо реальной файловой системе, а затем просто сохраните метаданные, которые действительно ссылаются на эти файлы. Так, с относительным путем, с, возможно, размером файла, MIME-типом, сохраните это в таблице базы данных. Таким образом, вы можете очень быстро перебирать эту таблицу базы данных с помощью своих запросов, а затем, если вы действительно хотите скачать и предоставить такое изображение, вы получаете путь к файлу, ссылающийся на это изображение, из вашей таблицы базы данных. И на этом этапе нам также нужно поговорить о различных вариантах, которые у вас есть при хранении изображений или файлов в целом. И это, с одной стороны, локальное хранилище. Итак, вы размещаете свой бэкенд Spring Boot на каком-то, возможно, сервере Ubuntu. Тогда вы, конечно, можете хранить эти файлы в вашей локальной файловой системе Ubuntu. Это то, что мы будем делать здесь. Но я также хочу подчеркнуть, что очень часто в продакшене вы определенно захотите рассмотреть какой-либо удаленный сервис хранения. Так что-то вроде Amazon S3. Итак, у нас есть какой-то сторонний сервис, который хранит ваши изображения для вас. Это имеет преимущество, что у вас практически неограниченная масштабируемость, потому что вам больше не нужно беспокоиться о вашей файловой системе. Он имеет встроенную избыточность. Гораздо проще добавлять резервные копии, если вы полагаетесь на что-то вроде S3. И также, если вы, возможно, предоставляете свои файлы, свои изображения по всему миру, то это также облегчает географическое распределение. Так, например, пользователи в Европе также предпочтут получать изображения с европейских серверов и так далее. С другой стороны, недостатки использования такого облачного провайдера, конечно, — это задержка, потому что, во-первых, пользователь загружает изображение на ваш бэкенд, а ваш бэкенд снова загружает его такому облачному провайдеру. Это, конечно, связано с более высокими затратами, и у вас, конечно, есть внешняя зависимость, от которой в конечном итоге зависит ваш код, что является тем, что я бы также в целом хотел минимизировать, но это всегда, конечно, компромисс. Это просто как небольшое примечание перед тем, как мы продолжим, потому что я уже вижу комментарии Филиппа. Гораздо лучше хранить эти изображения в S3. Да, если вы посмотрите это видео, то вы также узнаете, как взять эти изображения и сделать с ними что-то еще. В конце концов, у вас будут эти изображения доступны на вашем сервере. И отправляете ли вы их затем в S3 или храните в вашей локальной файловой системе, это полностью зависит от вас. Мы будем делать последнее в этом курсе. В качестве первого шага я хочу перейти в наш корневой пакет и создать что-то вроде пакета `repository`, в котором у нас будет наш репозиторий MongoDB, хранящий метаданные изображений. Итак, здесь, в `repository`, мы можем создать новую запись Java, которую мы назовем `image metadata`. Так это будет представлять наш документ MongoDB. Сделайте это записью. Так что, если вы из Kotlin, это эквивалент класса данных Kotlin. И теперь нам нужно подумать, какие данные мы хотим хранить в нашем документе MongoDB. Прежде всего, нам нужно сообщить этому, что это документ, и мы дадим этой коллекции в MongoDB имя, например, `image metadata`. И затем мы говорим, что я хочу хранить здесь строку для исходного имени. Так, исходное имя файла, однако исходное имя файла, так как файл назывался на клиентском устройстве, обычно не то, что вы хотите использовать для сервера, потому что это, конечно, может привести к конфликтам. У вас могут быть конфликты имен. Так что гораздо лучше иметь что-то вроде сохраненного имени. Мы также хотим сохранить ссылку на MIME-тип. Так с каким типом файла мы на самом деле работаем. Мы хотим, возможно, иметь что-то вроде `owner ID`. Это просто то, что я жестко закодирую в этом видео. Но если, например, у вас есть приложение социальной сети, веб-сайт социальной сети, где пользователи могут загружать изображения, тогда, конечно, имеет смысл также хранить идентификатор пользователя, которому на самом деле принадлежит изображение. Мы можем захотеть иметь `long` для фактического размера файла. Хотим иметь `Instant` для времени создания файла. И, наконец, хотим иметь первичный ключ здесь в MongoDB, который является `ObjectID`, который мы называем `ID`, вот так. И тогда тело класса может остаться пустым. Кроме того, в нашем пакете `repository` нам теперь нужен интерфейс, который действительно реализует этот репозиторий. Но поскольку Spring Boot так хорош, нам не нужно писать никаких запросов, которые нам здесь нужны, самостоятельно. Так что мы действительно можем просто сделать это `ImageMetadataRepository`, интерфейсом, и нам действительно нужно только сказать: "Эй, это способ делать это на Kotlin". Этот расширяет репозиторий, который хранит объекты `ImageMetadata` и имеет значения `ObjectID` в качестве первичных ключей. И тело этого действительно может остаться таким, как есть. Вам нужно будет расширить это, если у вас будут более сложные запросы, которые вы хотели бы отправить в этот репозиторий или в вашу коллекцию `image metadata`. Но поскольку у нас этого нет, мы действительно хотим только запрашивать отдельные изображения по ID. Это то, что уже содержится здесь по умолчанию. Далее, мы хотим работать над двумя сервисами. Так, просто классы, которые несут ответственность за, с одной стороны, действительно сохранение файла в файловой системе. Это будет наш `LocalStorageService`, и у нас будет другой сервис, который мы просто назовем `ImageService` или что-то в этом роде, который будет нести ответственность за то, чтобы действительно связать все вместе, чтобы получить многокомпонентный файл, затем интерпретировать его, сохранить его в файловой системе, используя наш другой сервис, сохранить метаданные в нашей коллекции MongoDB и так далее. Итак, здесь, в нашем корневом пакете, давайте создадим новый пакет под названием `service` или `services`, скажем, единственное число `service`, и в нем мы прежде всего хотим объявить некоторые свойства, которые мы затем сможем использовать в обоих наших сервисах, которые, ну, обоим этим нужны, которые мы можем назвать `ImageStorageProperties`, и это будет запись здесь. Мы хотим аннотировать это с помощью `@ConfigurationProperties`. Так что это действительно просто класс, который содержит свойства, специфичные для Spring Boot. Так что константа, мы можем дать этому префикс, где мы говорим: "Хорошо, `app.image.storage` или что-то в этом роде", а затем мы также аннотируем это с помощью `@Component`, так что Spring Boot может легко внедрять это повсюду. Здесь мы затем захотим сохранить базовый путь. Так, базовый путь, где мы хотим хранить эти изображения, которые мы загружаем в бэкенд. И мы хотим иметь набор строк, которые являются разрешенными MIME-типами, потому что это определенно одна из лучших практик безопасности: если вы храните, например, изображения на своем бэкенде, то вы хотите ограничить MIME-типы, которые люди могут загружать. Иначе они могли бы загрузить практически что угодно. И поскольку это довольно статично, мы можем инициализировать это в нашем конструкторе. Так что публичный `ImageStorageProperties`, в котором мы вызываем это с базовым путем. Базовый путь в данном случае — просто `images`. Так что здесь в нашей локальной иерархии файлов я могу сказать уже, если вы развернете это где-нибудь, то избегайте хранения этих изображений в вашем фактическом рабочем каталоге Spring Boot или в той же директории, где находится бэкенд Spring Boot, но вместо этого поместите это где-нибудь в статический путь в вашей файловой системе, возможно, в `/var` путь вашего бэкенда Linux, что-то в этом роде. Это действительно только для целей тестирования, здесь у нас есть путь к файлу, где у нас определенно есть разрешение на хранение файлов. Для набора мы можем сказать `Set.of` и просто указать наши разрешенные MIME-типы изображений. Так что все это изображения, но мы, например, хотим разрешить JPEG, мы хотим разрешить PNG, мы хотим разрешить WebP и, возможно, GIF или что-то в этом роде. Так что `image/gif`. И уже наши `ImageStorageProperties`, которые вы теперь можете внедрить в наши сервисы, и тогда оба этих сервиса могут ссылаться на одни и те же константы. Итак, давайте перейдем к `service` и создадим новый класс Java под названием `LocalStorageService`. Мы аннотируем его `@Service` здесь, в Spring Boot. И этот класс действительно несет ответственность за сохранение файлов здесь, в нашей файловой системе. Он еще не связывает это с нашими метаданными изображений и так далее. Это ответственность другого сервиса. Так что прежде всего я хочу внедрить наши `ImageStorageProperties` здесь. Так что `ImageStorageProperties properties`. Я хочу иметь приватный финальный корневой путь. Так, корневой путь к файлу, который является `Path`, и затем мы можем сказать `Alt+Enter` здесь, чтобы добавить параметры конструктора. Да, для обоих этих приборов при вызове конструктора здесь нам не нужно передавать корневой путь. Нам это на самом деле не нужно, потому что корневой путь инициализируется с использованием наших свойств, потому что базовый путь хранится там. Так что мы можем сказать `properties.getBasePath()` и мы говорим, что мы получаем наш базовый путь свойств, это путь, который мы хотим использовать для нашего объекта корневого пути, который мы затем используем в нашем сервисе здесь. Итак, давайте начнем с функции, которая сохраняет файл и возвращает строку. Так, фактический путь к файлу впоследствии, который мы можем назвать `storeFile`, и он принимает `InputStream`, который мы передаем извне, и исходное имя файла. Так, `String originalName`. Он может выбрасывать `IOExceptions` в случае, если что-то пойдет не так. И тогда прежде всего нам нужно подумать о фактической иерархии папок, которой мы хотим придерживаться здесь, которая также позже облегчит поиск определенных файлов. И, ну, простая стратегия папок здесь была бы просто по дате. Так что мы бы сказали, что у нас есть `LocalDate now`, или `today`, который равен `LocalDate.now()`. И затем с этой информацией мы можем объявить путь, который мы называем `dateDirectory`, который равен `rootPath`. Так что он, конечно, должен быть относительным к нашему корневому пути. И мы затем вызываем `resolve` с `today.getYear()`. Так что это будет наша первая директория, плюс разделитель файлов. Так, разделитель файлов, независимо от того, какая у нас ОС, плюс строковый формат, мы хотим отформатировать номер месяца сейчас и номер дня `%02d`. Мы передаем `today.getMonthValue()`. Этот `%02d` в конце достигает того, что если у нас есть какой-то месяц, 12 приведет к 12 для декабря, но если месяц, например, май, то это будет правильно дополнено, так что мы получим директорию, которая имеет ноль в качестве ведущей цифры здесь. После этого нам снова нужен разделитель файлов, и, наконец, мы хотим иметь то же самое и для дня. `getMonth` должен быть заменен на `getDayOfMonth`, и это теперь путь к директории, в которой мы хотим хранить. Так что это приведет к чему-то вроде "Хорошо, так что наш корневой путь, где мы говорим "Хорошо", затем у нас есть 2025 год, возможно, июнь, и 15 июня, что-то в этом роде, это будет созданная иерархия файлов здесь, используя эту логику. Затем мы можем сказать `Files.createDirectories`, где мы просто передаем наш `dateDirectory`, так что, если эта иерархия не существует, мы просто создадим ее этой командой. И затем, наконец, мы можем создать путь `Path` здесь для нашего фактического пути к файлу. Так, где мы хотим сохранить этот файл, который равен нашему `dateDirectory.resolve()`. Так, мы просто добавляем наше исходное имя. Мы на самом деле не хотим полагаться на исходное имя для пути к файлу, как я упомянул вначале. Так что мы не хотим просто брать путь к файлу пользователя здесь, с одной стороны, чтобы избежать конфликтов имен. Если у двух человек одинаковые имена файлов, то эти могут быть дублированы здесь. Но также потому, что это довольно рискованно с точки зрения безопасности, если пользователь может передать любое имя файла, потому что кто скажет нам, что они не передадут что-то вроде этого здесь и не попытаются прочитать файл пароля. Если мы передадим это сюда в исходное имя, и это то, что предоставит клиент, то, если мы не проверим это каким-либо другим способом, это может фактически просмотреть наши директории, и если у нашего приложения Spring Boot есть разрешения на чтение оттуда, нам может не повезти, и они могут просто запросить нашу файловую систему. Это не то, чего мы хотим. Так что мы хотим генерировать уникальные имена файлов здесь, для которых я прежде всего создал небольшую вспомогательную функцию для извлечения расширения файла. Так что приватная строка `getFileExtension` для определенного имени файла. И для расширения файла, это, по сути, то, что идет после последней точки. Так что вы уже можете видеть пример кода здесь. Но я хочу реализовать это немного безопаснее. Так, последняя точка будет `fileName.lastIndexOf('.')`. Да, скажем, это точка, и затем мы возвращаем, равно ли эта последняя точка -1. Так, она вернет -1, если нет точки, потому что без расширения тоже не получится. Если это -1, мы говорим, что это пустая строка. Если это не -1, так что у нас есть допустимый индекс. Тогда мы говорим `file.substring(lastDot + 1)`. Так, что идет после точки. Затем здесь, перед путем к файлу, мы можем сказать: `String extension = getFileExtension(originalName)`. Так, мы, конечно, по-прежнему хотим использовать то же расширение файла, которое было для файла, который пользователь загрузил. И из этого мы можем затем вывести сохраненное имя, которое равно просто чему-то вроде UUID. Это всегда что-то простое для генерации чего-то уникального. UUID просто как имя файла. И затем мы говорим: "Хорошо, плюс, является ли расширение пустым". Если оно пустое, то просто ничего. Но если оно не пустое, тогда мы говорим "точка плюс наше расширение". Так, например, PNG, а затем сохраненное имя — это то, что мы можем передать сюда для окончательного пути к файлу. Так что теперь нам просто нужно взять этот путь в блоке `try` и фактически записать реальные байты в него. Так что мы можем сказать: `OutputStream` хочет записать байты в файл. Это равно `Files.newOutputStream` как предлагает ИИ здесь. Передаем наш путь к файлу. Мы хотим сохранить это, и мы говорим `StandardOpenOption.CREATE_NEW`. Так что мы создадим новый файл, если он не существует. И здесь мы можем затем сказать `StreamUtils.copy()`. Так, мы хотим скопировать содержимое из `InputStream`, который мы передали, в наш `OutputStream`, вот так. И поскольку этот `OutputStream` здесь ссылается на путь к файлу, как мы передали его сюда, это действительно запишет или скопирует содержимое из `InputStream`, который позже поступает из многокомпонентного запроса, в наш `OutputStream`. Так, в реальный файл, а затем, наконец, мы можем вернуть `rootPath.relativize(filePath).toString()`. Так, `relativize` в конце концов является противоположностью `resolve`. Если у нас есть какой-то путь `a/b/c/d`, а наш путь к файлу будет `a/b/c/d/e`, и мы передадим это сюда, то результирующий путь будет просто относительным путем. Так, общие части будут отброшены, и вывод будет просто `/e`. Так, вот как мы храним файлы. Другая функция, которая нам здесь нужна, — это, конечно, получить файлы, а затем позже ответить ими из нашего API-эндпоинта. Так что мы можем сказать, что публичный `Resource` или `Resource` здесь из `spring-framework-core-io` — это действительно то, что мы должны использовать здесь, чтобы предоставить ресурс каким-либо образом или какой-либо файл, например, из конечной точки. Мы говорим `getFileResource` что-то вроде этого, мы передаем `storedName` или `storedPath`. Это также выбрасывает `IOExceptions`. Тогда мы можем снова определить наши различные пути. Так, `filePath = rootPath.resolve(storedPath).normalize()`. Так, нормализация означает, что она отбрасывает весь шум. Так, все эти символы иерархии файлов, которые в конце концов не добавляют никакой ценности. Так, например, если у нас есть имя файла, как это, то `./hello.txt`, то эта точка `/` не добавляет никакой ценности, и вы можете просто удалить ее и нормализовать ее, извините. И мы затем хотим преобразовать это в абсолютный путь. И то же самое мы хотим сделать для нормализованного корневого пути. Так, `normalizedRootPath`, который просто `rootPath.normalize()`. И также иметь это как абсолютный путь. Причина, по которой я делаю это, заключается в том, что я теперь хочу иметь проверку `if`. Если путь к файлу не начинается с нашего нормализованного корневого пути, что это означает, кто-то на самом деле пытается просмотреть нашу иерархию файлов. Так, тот же сценарий, который я показал вам раньше, здесь для сохранения файла, если бы мы полагались только на исходное имя, чтобы определить, где и как этот файл называется на стороне сервера, это было бы проблемой безопасности, но просто потому, что клиенты не могут напрямую предоставить это, риск все еще существует, что путь к файлу здесь может быть чем-то вроде вот этого, и именно здесь мы преобразуем этот путь к файлу в абсолютный путь. Так что эти двойные точки здесь фактически удаляются, и реальный абсолютный путь к файлу содержится, возвращается, потому что если этот абсолютный путь к файлу не начинается с нашего корневого пути к файлу, то это означает, что кто-то пытался манипулировать нашим путем к файлу и перейти куда-то, куда им не следует переходить, и это, конечно, также может быть случай, если кто-то, я не знаю, скомпрометирует вашу базу данных с метаданными файла и затем изменит эти метаданные файла на какие-то относительные строки. Есть также что-то, чего вы хотите избежать, и чтобы действительно, действительно просто добавить двойную проверку здесь после всего. В этом случае вы хотите выбросить `SecurityException`. Так что мы говорим "доступ запрещен", потому что мы хотим, чтобы этот код возвращал файлы только внутри нашего базового каталога `images`. Также в случае, если этот файл не существует. Так, если `Files.exists(filePath)` — о нет, тогда мы хотим выбросить `FileNotFoundException`. Это так просто. И, наконец, мы можем вернуть новый `UrlResource`, потому что мы предоставили этот файл из URL, и мы можем сказать `filePath.toUri()` и точка с запятой. И это все. Вот как мы получаем файлы в качестве ресурсов в Spring Boot. Это должно быть все для нашего сервиса, для нашего `LocalStorageService`. Далее, нам нужен `ImageService`, который действительно берет этот сервис и использует реализованные нами функции, а также реализует их вместе с нашими метаданными изображений. Так, метаданные действительно хранятся в нашем экземпляре MongoDB. Итак, в `service` давайте иметь другой `ImageService`, который связывает эти вещи вместе. Мы аннотируем его `@Service`, и здесь у нас есть несколько приватных финальных полей, которые мы хотим внедрить. Так, с одной стороны, наши `ImageStorageProperties` снова. Мы хотим иметь приватный финальный `ImageRepo` — `ImageMetadataRepository`. Теперь нам нужна ссылка на наш другой сервис. Так, что это называется? `Image` — наш `LocalStorageService`, давайте назовем его `storageService`. И тогда мы можем нажать `Alt+Enter` здесь, чтобы добавить параметры конструктора. Сгенерировать конструктор из всех этих трех полей, и тогда все будет связано Spring Boot. Затем мы хотим иметь функцию, и публичную функцию `ImageMetadata`. Так, которая возвращает объект `ImageMetadata`, который в конце концов называется `uploadImage`. И здесь я хочу передать эту ссылку на многокомпонентный файл уже, потому что именно так работают загрузки файлов с REST API. Так, в то время как обычные или типичные запросы просто включают какой-то, возможно, JSON-тело, где запрос состоит из одной части, которая загружается на сервер, существуют определенно запросы, где запрос состоит из нескольких частей. Так, возможно, JSON-тело, возможно, один или несколько файлов, и это работает с этими многокомпонентными запросами, и Spring Boot просто получает их как объект многокомпонентного файла, из которого мы можем получить всю информацию о файле. Так, `uploadImage`, нам нужно знать `ownerId` вместе с этим, на случай, если вы хотите иметь такое поведение. И тогда прежде всего мы хотим иметь некоторую двойную проверку здесь, что мы проверяем это загруженное изображение, что оно действительно соответствует нашим требованиям, что оно имеет правильный MIME-тип и подобные вещи. Так, приватный `validateImage`. Мы передаем этот многокомпонентный файл и прежде всего проверяем, пуст ли этот файл. Так, если в файле нет байтов, тогда мы хотим выбросить новый `IllegalArgumentException`, где мы говорим "файл пуст", это не должно быть разрешено хранить пустые файлы в нашем. Так, кроме того, мы хотим получить MIME-тип из файла, который равен `file.getContentType()`, и если этот MIME-тип равен null. Так, нет MIME-типа, или `properties.getAllowedMimeTypes()` не содержит этот MIME-тип. Так, не в нашей карте или наборе `allowedMimeTypes`, тогда мы также хотим выбросить новый `IllegalArgumentException` "недопустимый MIME-тип". Я просто хочу сказать, что в реальном бэкенде вы, вероятно, также захотите иметь двойную проверку того, что реальный MIME-тип файла также соответствует тому, что говорит клиент, потому что вы можете технически изменить MIME-тип и, возможно, заставить исполняемый файл сказать вам, что это, возможно, файл PNG. Так, это технически возможно, но проверка этого немного сложнее. Мы хотим использовать эту функцию `validateImage` здесь. Передаем наш файл, и если эта функция ничего не выбросила, мы знаем, что работаем с недопустимым изображением. Так, мы можем затем получить `storagePath` здесь как строку. Мы говорим "попробуйте создать `InputStream`". Так, `InputStream`, куда мы затем можем в конце концов прочитать файл, и это просто приходит из `file.getInputStream()`. Так, это уже приходит из объекта многокомпонентного файла Spring Boot. Так, мы можем затем войти в этот блок `try` и сказать: `storagePath = this.storageService.storeFile(inputStream, file.getOriginalFilename())`. Так, это также часть многокомпонентного запроса. Мы получаем некоторые ошибки здесь, потому что это может выбросить `IOException`. Нам нужно добавить это здесь как информацию. Так, `throws IOException`, и тогда эти ошибки исчезнут. И теперь, когда мы действительно сохранили этот файл здесь, следующий шаг — иметь объект `ImageMetadata`. Так, мы также сохраняем метаданные этого файла в нашей базе данных. Так, мы можем затем запросить, какие файлы мы на самом деле храним, где, и если нам нужен один, тогда мы знаем путь к файлу, который нужно искать. Так, `ImageMetadata metadata = new ImageMetadata(...)`. Где мы теперь должны передать исходное имя, которое приходит из многокомпонентного файла. Сохраненное имя — это наш `storagePath`. MIME-тип — это `file.getContentType()`. `ownerId` передается в эту функцию. Размер равен `file.getSize()`, а `Instant createdOn` может быть просто `Instant.now()`. И, наконец, мы можем сказать `ObjectId.get()` для генерации нового случайного объекта ID MongoDB. Так, это теперь наш объект `ImageMetadata`, который мы хотим сохранить в нашей базе данных. И тогда мы можем просто сказать `repository.save(metadata)`. Это действительно так просто. Так, вот как мы можем загружать изображения здесь и связывать эти вещи вместе. То же самое теперь необходимо для их скачивания или, скорее, для получения существующего объекта `ImageMetadata`, получения файла, который к нему относится, и получения его в виде ресурса. Это, кстати, намного проще, чем загрузка, или требует меньше кода, по крайней мере. Так, здесь мы можем сказать, что у нас есть функция, которая возвращает объект `ImageMetadata` из нашей базы данных `getImageMetadataById`, который затем предоставляется как параметр URL, который, возможно, выбрасывает `IOExceptions`. Мы можем затем сконструировать `ObjectId` из нашего строкового ID вот так, `new ObjectId(imageId)`. И тогда здесь мы можем вернуть `repository.findById(objectId)`. Я хочу найти определенный объект `ImageMetadata` по ID, который мы передали здесь, конкретно `ObjectID`, а затем `orElseThrow()`. Так, либо он находит что-то, но если нет, то он выбрасывает что-то, конкретно исключение. Так, `FileNotFoundException`, например, "файл не найден". И теперь с этим объектом `ImageMetadata` мы также можем получить его в виде ресурса. Так, это то, с чем Spring Boot действительно может ответить для URL-эндпоинта. Так, публичный `Resource` снова из `spring-framework-core-io`. `getImageResource`, мы передаем `imageId`. Это также выбрасывает `IOExceptions`. Мы можем сначала получить объект метаданных, сказав `getMetadata(imageId)`, используя нашу функцию, которую мы только что создали, передав наш `imageId`. И тогда мы можем вернуть наш `storageService`. Так, то, что мы реализовали ранее, `getFileResource`, и мы передаем `storedPath`, который приходит из нашего объекта метаданных `metadata.getStoredName()`, вот так. И этот ресурс, который мы затем возвращаем, — это, наконец, то, с чем мы также можем ответить в нашем контроллере, что является действительно последней частью этого бэкенда. Так, нам нужен контроллер, который принимает эти REST-запросы к нашим двум конечным точкам, а затем, с одной стороны, получает файл, сохраняет его в нашем хранилище, вызывая эту функцию `uploadImage`, а другая конечная точка, конечно, вызовет функцию `getImageResource`. Так, новый пакет `controller`, в котором мы создаем новый класс Java под названием `ImageController`. Давайте назовем его так. Это будет `@RestController`. Так, класс, который получает REST-запросы и обрабатывает их. И мы добавим `@RequestMapping` для иметь относительный URL для всех конечных точек здесь, называемый `images`. Нам нужен наш `ImageService`, который передается сюда. Так, приватный финальный `ImageService`. `Alt+Enter` для добавления параметра конструктора. И тогда мы можем создать две функции: с одной стороны, для получения многокомпонентного запроса, а с другой — для ответа данными изображения. Так, начиная с части загрузки, это будет публичный `ResponseEntity<?>`. Так, может вернуть что угодно. `uploadImage`, и это получит здесь параметр запроса, который называется `file`. Так, это ключ, о котором клиент также должен знать, и это будет многокомпонентный файл под названием `file`, и тогда здесь этот файл будет содержать фактическое содержимое файла. Так, если клиент загружает это как многокомпонентный запрос, то это будет автоматически включено здесь. Это также должен быть POST-запрос. Так, мы говорим, что это `@PostMapping`, и он получает относительный URL `/upload`. Так, мы можем затем вызвать `localhost/images/upload`, и тогда эта функция будет активирована. Я хочу просто жестко закодировать `ownerId` здесь. Конечно, в обычном бэкенде вы получите это, например, из JWT-заявления или чего-то подобного. Если у вас есть это в нашем простом бэкенде здесь, у нас нет владельцев или концепции этого. Так, мы можем затем сказать, что у нас есть блок `try-catch` для обработки этих возможных исключений, которые могут произойти здесь. С одной стороны, `IOExceptions`, с другой стороны, `IllegalArgumentExceptions`, с которыми мы разбирались в наших сервисах. Но прежде всего, пытаясь, что все идет по счастливому пути. Так, мы хотим попытаться получить объект `ImageMetadata`, который мы получаем из нашего `ImageService`, `uploadImage`. Так, мы просто вызываем нашу функцию, которую мы реализовали здесь. Передаем наш многокомпонентный файл и `ownerId`. И если это не выбросило исключение, мы можем сказать, что мы возвращаем `ResponseEntity.ok()`. И здесь вы технически можете вернуть какой-то тело, если хотите. Вы можете создать запись для этого, или мы просто создадим карту и здесь мы могли бы иметь запись карты, с одной стороны, о чем мы заботимся, `imageId`, конечно, что мы сохранили на стороне сервера, это необходимо для запроса этого изображения позже. Это будет `metadata.getId().toHexString()`, чтобы просто вернуть ID в виде строки. И мы могли бы иметь две другие записи, одну для фактического исходного имени, например, которое у клиента, вероятно, уже есть. Но мы также можем вернуть это, просто исходное имя, и, наконец, размер файла. Так, здесь `size = metadata.getSize()`, что-то в этом роде. И тогда, что это жалуется? Удалить избыточный вызов. Я думаю, это должно быть `Map.ofEntries()`. Да. Хорошо, `Map.ofEntries()` или вы просто создаете класс, который представляет это. Это полностью зависит от вас. Конечно, поскольку нам это действительно нужно только один раз в этом простом проекте, я решил сделать это картой. Что насчет того, когда что-то идет не так. Так, в случае `IOException`, мы хотим вернуть `ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()`, потому что это, вероятно, проблема сервера, что что-то в файловой системе не могло быть создано. В случае `IllegalArgumentException`, это скорее "плохой запрос". Так, это проблема клиента, что они прикрепили изображение с неправильным MIME-типом или что-то в этом роде. Но это функция загрузки. Далее, функция скачивания. Так, у нас будет публичный `ResponseEntity`, который на этот раз возвращает `Resource`. Так, здесь `Resource` из `spring-framework`. `downloadImage`, который получает переменную пути `imageId`. Так, это из `@GetMapping`, который мы здесь передаем, который должен исходить из `/`, давайте напрямую передадим `imageId` здесь. Так, мы получаем доступ к этому через `/images/{imageId}`, и этот `imageId` будет помещен сюда как параметр, и мы можем работать с ним. Давайте также иметь блок `try-catch` здесь. Здесь могут произойти только `IOExceptions`. Так, у нас есть `IOException e`, в случае которого мы бы сказали `ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()`. И для блока `try`, мы снова получаем наш объект метаданных. Так, здесь мы теперь ищем конкретное изображение в нашей базе данных с ID `imageId`. Так, мы можем сказать, что это равно `imageService.getImageMetadata(imageId)`. Затем мы получаем это как ресурс. Так, у нас есть наш `Resource`, который мы получаем из `imageService.getImageResource(imageId)`. И тогда мы можем вернуть `ResponseEntity.ok()`, передать наш ресурс. И здесь также важно отправить некоторую дополнительную информацию в виде заголовков. Так, один заголовок, ах, нет, неважно, этот ресурс, его нужно передать как тело. Так, тело как ресурс, но тогда мы можем сказать, что между тем у нас есть один заголовок, который прикреплен к ответу, который является `HttpHeaders.CONTENT_DISPOSITION`. Это просто очень типично для таких скачиваемых файлов, которые содержат информацию об этом файле. Так, здесь вы можете видеть, что ИИ уже предлагает здесь, это на самом деле точно так же, как `resource.getFilename()`, который нет, это на самом деле должно быть исходное имя файла. Так, `metadata.getOriginalName()`, но кроме этого мы говорим, что это вложение, имя файла, ключ или свойство здесь должно называться так, у нас есть кавычки, а затем имя файла в кавычках, это просто формат, который мы обычно хотим прикрепить к таким ответам. Мы хотим передать `Content-Type` изображения, которое было возвращено здесь. Так, клиент также может работать с этим. `MediaType.parseMediaType(metadata.getMimeType())`. И, наконец, как `Content-Length`, чтобы также сообщить клиенту больше об этом, это `metadata.getSize()`. Последнее, но не менее важное, что вы обычно хотите также добавить, если вы храните эти изображения здесь напрямую, или, возможно, даже если нет, это установить максимальные лимиты размера загрузки файла. Это то, что мы можем установить в нашем файле `application.properties`, сказав `spring.servlet.multipart.max-request-size`, это установлено в 10 мегабайт по умолчанию. Так, нам нужно различать между размером запроса, который является общим объемом байтов запроса, и максимальным размером файла. Так, максимальный размер одного файла, прикрепленного в многокомпонентном запросе, где у нас также может быть несколько файлов. Так, я хочу установить этот здесь, скажем, на 5 мегабайт, чтобы учесть большинство изображений, но не позволять клиентам действительно загружать гигабайты контента здесь. И тогда, если мы хотим протестировать это, вы, конечно, должны теперь запустить локальный экземпляр MongoDB или подключить какой-либо облачный сервер Atlas. Я просто запущу простой тестовый экземпляр MongoDB здесь локально. Так, я просто пойду в свою корневую папку здесь. Создам новую директорию и скажу `mongodb-temp` или что-то в этом роде. Так, MongoDB может установить свои временные файлы в этой директории, и мы можем удалить ее впоследствии. Опять же, мы можем запустить это с помощью `mongod`. Так, вы, конечно, должны иметь этот инструмент командной строки или MongoDB как службу, установленную в вашей среде. Но тогда вы можете сказать `mongod --dbpath mongodb-temp` и сослаться на директорию `mongodb-temp`. И если вы нажмете Enter здесь, тогда вы увидите что-то вроде этого. Он говорит "завершение работы", что я не уверен, почему. Давайте попробуем еще раз. Да, теперь он фактически поддерживает соединение активным здесь. Если мы посмотрим в директорию, там есть какие-то файлы MongoDB. Нам не нужно об этом беспокоиться. Но теперь тестовая база данных активна. Это также база данных по умолчанию, к которой Spring Boot будет подключаться. И если мы запустим наш бэкенд, тогда вы увидите, что мы видим журналы. Мы не видим сбоев. Мы не видим проблем с подключением, что именно то, что мы хотим. Так, мы можем теперь перейти в Postman и протестировать это. По умолчанию Spring Boot работает на порту 8080. Так, убедитесь, что вы указали здесь URL `http://localhost:8080`. Я выбрал немного другое имя здесь, но прежде всего, конечно, мы должны протестировать функциональность загрузки. Мой другой бэкенд остановлен. Так, мы определенно говорим с новым, который мы построили здесь. URL выглядит правильно для меня. Файл все еще прикреплен. Так, в Postman, если вы хотите прикрепить это, перейдите в "Body", "form-data", затем выберите "file" здесь, или вы можете прикрепить текстовую часть или файловую часть, а затем просто загрузить свой файл. Если мы нажмем "Send", тогда мы получим ответ "OK". Мы получаем исходное имя. Мы получаем ID изображения, и если мы скопируем этот ID изображения в нашу другую конечную точку, мы не назвали ее "download", но мне нравится этот формат на самом деле больше. Так, у нас есть GET-запрос `http://localhost:8080/images/{imageId}`, который мы только что скопировали. И если мы теперь нажмем "Send", да, наше красивое изображение здесь все еще отображается, что означает, что все работает, как ожидалось. И если мы посмотрим в наш бэкенд, в нашу консоль, тогда мы также должны увидеть, не уверен, есть ли там какие-либо журналы. Нет, кажется, он ничего не залогировал в связи с этим. Но что мы можем протестировать, это в нашей папке `images`, которая теперь появляется. У нас есть наша иерархия, похожая на дату. И наше первое изображение находится там. И если мы откроем это, тогда вы можете увидеть мое красивое лицо, которое было успешно загружено здесь. Опять же, это не то место, где вы хотите хранить эти изображения в производственной среде. Вы, конечно, можете также сделать это, возможно, каким-то полем свойств здесь, что такое относительный путь к изображению, в зависимости от вашего профиля Spring Boot. У меня есть отдельное видео о профилях Spring Boot. Но кроме этого, вот как вы храните изображения там, где работает ваш бэкенд Spring Boot. Если вы теперь, конечно, хотите загрузить эти изображения в какой-либо облачный провайдер, как я сказал, например, AWS S3, тогда это все еще работает очень похоже. Так, вы, конечно, получите ваше изображение на стороне сервера здесь как многокомпонентный запрос, вы получите `InputStream` от него, а затем очень часто у вас есть какая-то функция из этого SDK, например, из S3, которая также принимает `InputStream` и затем выполняет загрузку для вас. Но в любом случае, я думаю, что это довольно полезно, если вы реализовали оба этих подхода один раз. Так, вы понимаете различия, понимаете, в каком случае имеет смысл придерживаться локальных изображений, в каком случае может иметь больше смысла придерживаться облачного провайдера. Но, конечно, если вы храните их локально, тогда вам также придется позаботиться о таких вещах, как резервное копирование. В конце концов, у вас просто есть своего рода единая точка отказа на вашем сервере, если вы делаете это, потому что если ваш сервер по какой-то причине выходит из строя, то не только ваш бэкенд исчезнет, но и все изображения, которые были там сохранены. Но опять же, это действительно компромисс, который вам нужно рассмотреть здесь для вашего конкретного бэкенда, который вы строите. Если вы просто строите какой-то хобби-проект или небольшой проект, тогда хранение этих изображений локально также может сэкономить вам немного денег. Большое спасибо за просмотр этого видео. Если вы хотите узнать больше о Spring Boot, то проверьте мой канал и не забудьте подписаться, потому что я регулярно загружаю больше видео по Spring Boot как на Java, так и на Kotlin. Так что, если вы хотите взглянуть на язык, который я лично считаю намного лучше, чем Java, даже для Spring Boot, тогда взгляните на него. Получите представление о том, как Kotlin выглядит для Spring Boot, а также, возможно, о разработке Android или Kotlin Multiplatform, которой полон мой канал. Большое спасибо. Увидимся в следующем видео. Желаю вам потрясающей оставшейся недели. Пока-пока. [Музыка]