如何使用 Grails 4 上传文件
了解如何使用 Grails 4 上传文件;将它们传输到某个文件夹,将它们保存为数据库中的 byte[] 或将它们上传到 AWS S3。
作者: Sergio del Amo
Grails 版本 4.0.1
1 Grails 培训
Grails 培训 - 由 Grails 框架的创建者和积极维护者开发并交付!。
2 入门
在本指南中,您将编写三个不同的域类。此外,您还将编写与其关联的控制器、服务和视图。
我们将探索不同的方式来保存上传的文件;数据库中的 byte[]、本地文件夹或远程服务器 (AWS S3)。
我们将使用命令对象来只允许图像(JPG 或 PNG)上传。
2.1 需要准备的内容
要完成本指南,您需要准备以下内容
-
一些时间
-
一个不错的文本编辑器或 IDE
-
已安装 JDK 1.8 或更高版本,并适当地配置了
JAVA_HOME
2.2 如何完成本指南
要开始,请执行以下操作
-
下载并解压源代码
或者
-
克隆Git 存储库
git clone https://github.com/grails-guides/grails-upload-file.git
Grails 指南存储库包含两个文件夹
-
initial
初始项目。通常是带有附加代码以给你一个开始指导的简单 Grails 应用程序。 -
complete
完成的示例。这是完成指南提供的步骤并将这些更改应用于initial
文件夹的结果。
要完成指南,请转到 initial
文件夹
-
cd
进入grails-guides/grails-upload-file/initial
并按照下一部分中的说明进行操作。
如果你 cd 进入 grails-guides/grails-upload-file/complete ,则可以直接转到完成的示例 |
3 编写应用程序
initial
文件夹包含使用 web
配置文件创建的 Grails 4 应用程序。它有几个 Controller、GORM 数据服务、Domian 类和一个用 GSP 构建的简单 CRUD 界面。这是一个用于列出旅游资源的应用程序;酒店、饭店和兴趣点。
在下一部分中,你将为应用程序添加一个功能。每个资源都可以包含一个精选图像。
-
对于饭店,我们将图片字节直接存储在域类中。
-
对于兴趣点,我们将文件传输到一个文件夹中。
-
对于酒店,我们上传文件到 AWS S3。
3.1 精选图像命令对象
我们的每个应用程序实体(酒店、饭店和兴趣点)都将有一个精选图像。
为了封装上传的文件的验证;我们使用了一个 Grails 命令对象
package example.grails
import grails.validation.Validateable
import org.springframework.web.multipart.MultipartFile
class FeaturedImageCommand implements Validateable {
MultipartFile featuredImageFile
Long id
Integer version
static constraints = {
id nullable: false
version nullable: false
featuredImageFile validator: { val, obj ->
if ( val == null ) {
return false
}
if ( val.empty ) {
return false
}
['jpeg', 'jpg', 'png'].any { extension -> (1)
val.originalFilename?.toLowerCase()?.endsWith(extension)
}
}
}
}
1 | 仅允许以 JPG 或 PNG 结尾的文件 |
3.2 增加允许的文件大小
Grails 4 的默认文件大小是 128000(~128KB)。
我们将允许上传 25MB 的文件。
25 * 1024 * 1024 = 26.214.400 字节
grails:
controllers:
upload:
maxFileSize: 26214400
maxRequestSize: 26214400
4 饭店 - 上传并保存为字节数组
本指南的这部分说明如何将文件上传到 Grails 服务器应用程序并将文件的字节存储在数据库中。
4.1 饭店域类
修改 Restaurant
域类。添加两个属性 featuredImageBytes
和 featuredImageContentType
。
package example.grails
class Restaurant {
String name
byte[] featuredImageBytes (1)
String featuredImageContentType (2)
static constraints = {
featuredImageBytes nullable: true
featuredImageContentType nullable: true
}
static mapping = {
featuredImageBytes column: 'featured_image_bytes', sqlType: 'longblob' (3)
}
}
1 | 使用域类属性在数据库中存储图像的字节 |
2 | 图像的内容类型。例如“image/jpeg” |
3 | 图像可能是大文件;我们使用映射闭包来配置 sqlType |
4.2 饭店视图
我们将对视图进行轻微修改以包含启用上传精选图像的功能。
在饭店列表中,仅在表格中显示饭店的名称。检出 Fields Plugin f:table 文档。
<f:table collection="${restaurantList}" properties="['name']"/>
编辑和创建表单不允许用户设置特色图片的内容类型或 byte[]
<f:all bean="restaurant" except="featuredImageBytes,featuredImageContentType"/>
<f:all bean="restaurant" except="featuredImageBytes,featuredImageContentType"/>
在显示餐馆页面中,添加一个按钮,将用户带到特色图片编辑页面。
<g:link class="edit" action="editFeaturedImage" resource="${this.restaurant}"><g:message code="restaurant.featuredImageUrl.edit.label" default="Edit Featured Image" /></g:link>
修改 RestaurantController
。添加 editFeaturedImage
控制器的动作
def editFeaturedImage(Long id) {
Restaurant restaurant = restaurantDataService.get(id)
if (!restaurant) {
notFound()
}
[restaurant: restaurant]
}
editFeaturedImage
GSP 与 edit
GSP 相同,但它使用 g:uploadForm
而不是 g:form
<g:uploadForm name="uploadFeaturedImage" action="uploadFeaturedImage">
<g:hiddenField name="id" value="${this.restaurant?.id}" />
<g:hiddenField name="version" value="${this.restaurant?.version}" />
<input type="file" name="featuredImageFile" />
<fieldset class="buttons">
<input class="save" type="submit" value="${message(code: 'restaurant.featuredImage.upload.label', default: 'Upload')}" />
</fieldset>
</g:uploadForm>
4.3 上传特色图片服务
uploadFeatureImage
控制器的动作使用前述命令对象验证上传表单提交。如果没有发现验证错误,则会调用一个 Gorm 数据服务。
def uploadFeaturedImage(FeaturedImageCommand cmd) {
if (cmd == null) {
notFound()
return
}
if (cmd.hasErrors()) {
respond(cmd.errors, model: [restaurant: cmd], view: 'editFeaturedImage')
return
}
Restaurant restaurant = restaurantDataService.update(cmd.id,
cmd.version,
cmd.featuredImageFile.bytes,
cmd.featuredImageFile.contentType)
if (restaurant == null) {
notFound()
return
}
if (restaurant.hasErrors()) {
respond(restaurant.errors, model: [restaurant: restaurant], view: 'editFeaturedImage')
return
}
Locale locale = request.locale
flash.message = crudMessageService.message(CRUD.UPDATE, domainName(locale), cmd.id, locale)
redirect restaurant
}
完整 GORM 数据服务显示在以下代码片段中
package example.grails
import grails.gorm.services.Service
@Service(Restaurant)
interface RestaurantDataService {
Restaurant get(Long id)
List<Restaurant> list(Map args)
Number count()
void delete(Serializable id)
Restaurant update(Serializable id, Long version, String name)
Restaurant update(Serializable id, Long version, byte[] featuredImageBytes, String featuredImageContentType) (1)
Restaurant save(String name)
}
1 | 第一个参数应该是要更新的对象的 id。如果任何参数与域类上的属性不匹配,则会发生编译错误。 |
4.4 加载 byte 数组图片
在 RestaurantController
中创建一个动作,用以渲染餐馆的特色图片
def featuredImage(Long id) {
Restaurant restaurant = restaurantDataService.get(id)
if (!restaurant || restaurant.featuredImageBytes == null) {
notFound()
return
}
render file: restaurant.featuredImageBytes,
contentType: restaurant.featuredImageContentType
}
使用 g:createLink
GSP 标签在 img src 属性中引用之前的控制器动作。
<h1><f:display bean="restaurant" property="name" /></h1>
<g:if test="${this.restaurant.featuredImageBytes}">
<img src="<g:createLink controller="restaurant" action="featuredImage" id="${this.restaurant.id}"/>" width="400"/>
</g:if>
5 兴趣点 - 上传和传输
在本指南的这一部分中,我们已将 Grails 应用程序从上传的图片中分离出来。我们使用 MAMP 运行一个指向 data 文件夹的 8888 端口上的本地 apache 服务器。
5.1 兴趣点域类
修改 PointOfInterest
域类。添加一个属性 featuredImageUrl
package example.grails
class PointOfInterest {
String name
String featuredImageUrl (1)
static constraints = {
featuredImageUrl nullable: true
}
}
1 | 使用此属性来保存可以检索图片的 URL。 |
5.2 兴趣点视图
我们准备对 GSP 视图进行一些修改,以加入支持上传特色图片的功能。
在兴趣点列表中,仅在表格中显示酒店名称。查阅 Fields Plugin f:table 文档。
<f:table collection="${pointOfInterestList}" properties="['name']"/>
编辑和创建表单不允许用户设置特色图片的 URL
<f:all bean="pointOfInterest" except="featuredImageUrl"/>
<f:all bean="pointOfInterest" except="featuredImageUrl"/>
相反添加一个按钮,将用户带到特色图片编辑页面。
<g:link class="edit" action="editFeaturedImage" resource="${this.pointOfInterest}"><g:message code="pointOfInterest.featuredImageUrl.edit.label" default="Edit Featured Image" /></g:link>
修改 PointOfInterestController
。添加一个名为 editFeaturedImage
的控制器动作
def editFeaturedImage(Long id) {
PointOfInterest pointOfInterest = pointOfInterestDataService.get(id)
if (!pointOfInterest) {
notFound()
return
}
[pointOfInterest: pointOfInterest]
}
editFeaturedImage
GSP 与 edit
GSP 相同,但它使用 g:uploadForm
而不是 g:form
<g:uploadForm name="uploadFeaturedImage" action="uploadFeaturedImage">
<g:hiddenField name="id" value="${this.pointOfInterest?.id}" />
<g:hiddenField name="version" value="${this.pointOfInterest?.version}" />
<input type="file" name="featuredImageFile" />
<fieldset class="buttons">
<input class="save" type="submit" value="${message(code: 'pointOfInterest.featuredImage.upload.label', default: 'Upload')}" />
</fieldset>
</g:uploadForm>
5.3 上传特色图片服务
uploadFeatureImage
控制器操作使用先前描述的命令对象来验证上传表单提交。如果它未找到验证错误,则它调用一项服务。
def uploadFeaturedImage(FeaturedImageCommand cmd) {
if (cmd.hasErrors()) {
respond(cmd, model: [pointOfInterest: cmd], view: 'editFeaturedImage')
return
}
PointOfInterest pointOfInterest = uploadPointOfInterestFeaturedImageService.uploadFeatureImage(cmd)
if (pointOfInterest == null) {
notFound()
return
}
if (pointOfInterest.hasErrors()) {
respond(pointOfInterest, model: [pointOfInterest: pointOfInterest], view: 'editFeaturedImage')
return
}
Locale locale = request.locale
flash.message = crudMessageService.message(CRUD.UPDATE, domainName(locale), pointOfInterest.id, locale)
redirect pointOfInterest
}
我们配置将承载上传图片的本地服务器 url 以及用于保存图片的本地文件夹路径。
grails:
guides:
cdnFolder: /Users/sdelamo/Sites/GrailsGuides/UploadFiles
cdnRootUrl: http://localhost:8888
服务使用 transferTo
方法将文件传输到本地文件路径。如果发生错误,服务将删除它先前传输的文件。
package example.grails
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.transform.CompileStatic
@SuppressWarnings('GrailsStatelessService')
@CompileStatic
class UploadPointOfInterestFeaturedImageService implements GrailsConfigurationAware {
PointOfInterestDataService pointOfInterestDataService
String cdnFolder
String cdnRootUrl
@Override
void setConfiguration(Config co) {
cdnFolder = co.getRequiredProperty('grails.guides.cdnFolder')
cdnRootUrl = co.getRequiredProperty('grails.guides.cdnRootUrl')
}
@SuppressWarnings('JavaIoPackageAccess')
PointOfInterest uploadFeatureImage(FeaturedImageCommand cmd) {
String filename = cmd.featuredImageFile.originalFilename
String folderPath = "${cdnFolder}/pointOfInterest/${cmd.id}"
File folder = new File(folderPath)
if ( !folder.exists() ) {
folder.mkdirs()
}
String path = "${folderPath}/${filename}"
cmd.featuredImageFile.transferTo(new File(path))
String featuredImageUrl = "${cdnRootUrl}//pointOfInterest/${cmd.id}/${filename}"
PointOfInterest poi = pointOfInterestDataService.updateFeaturedImageUrl(cmd.id, cmd.version, featuredImageUrl)
if ( !poi || poi.hasErrors() ) {
File f = new File(path)
f.delete()
}
poi
}
}
服务借助 GORM 数据服务更新 featuredImageUrl
package example.grails
import grails.gorm.services.Service
@SuppressWarnings(['LineLength', 'UnusedVariable', 'SpaceAfterOpeningBrace', 'SpaceBeforeClosingBrace'])
@Service(PointOfInterest)
interface PointOfInterestDataService {
PointOfInterest get(Long id)
List<PointOfInterest> list(Map args)
Number count()
void delete(Serializable id)
PointOfInterest save(String name)
PointOfInterest updateName(Serializable id, Long version, String name)
PointOfInterest updateFeaturedImageUrl(Serializable id, Long version, String featuredImageUrl)
}
6 酒店 - 上传到 AWS S3
对于 Hotel
域名类,我们将文件上传到 Amazon AWS S3。
首先,安装 Grails AWS SDK S3 插件
修改 build.grade
文件
repositories {
...
..
maven { url 'http://dl.bintray.com/agorapulse/libs' }
}
dependencies {
...
..
.
compile "org.grails.plugins:aws-sdk-s3:$awsSdkS3Version"
compile "com.amazonaws:aws-java-sdk-s3:1.11.375"
}
在 gradle.properties
中定义 awsSdkS3Version
grailsVersion=4.0.1
gormVersion=7.0.2.RELEASE
hibernateCoreVersion=5.4.0.Final
gebVersion=3.2
htmlunitDriverVersion=2.47.1
htmlunitVersion=2.35.0
assetPipelineVersion=3.0.11
awsSdkS3Version=2.2.4
我们将文件上传到已存在的存储桶。
grails:
plugins:
awssdk:
s3:
region: eu-west-1
bucket: grails-guides
6.1 酒店域名类
修改 Hotel
域名类。添加两个属性 featuredImageKey
和 featuredImageUrl
package example.grails
class Hotel {
String name
String featuredImageUrl (1)
String featuredImageKey (2)
static constraints = {
featuredImageUrl nullable: true
featuredImageKey nullable: true
}
}
1 | 我们存储特色图片的 AWS S3 url |
2 | 我们存储文件路径。如果需要,可以删除文件。 |
6.2 酒店视图
我们将对视图进行轻微修改以包含启用上传精选图像的功能。
在酒店列表中,仅在表格中显示酒店名称。查看 字段插件 f:table 文档。
<f:table collection="${hotelList}" properties="['name']"/>
编辑和创建表单不允许用户设置特色图片的 URL
<f:all bean="hotel" except="featuredImageUrl,featuredImageKey"/>
<f:all bean="hotel" except="featuredImageUrl,featuredImageKey"/>
相反添加一个按钮,将用户带到特色图片编辑页面。
<g:link class="edit" action="edit" resource="${this.hotel}"><g:message code="default.button.edit.label" default="Edit" /></g:link>
修改 HotelController
。添加一个名为 editFeaturedImage
的控制器操作
def editFeaturedImage(Long id) {
Hotel hotel = hotelDataService.get(id)
if (!hotel) {
notFound()
return
}
[hotel: hotel]
}
editFeaturedImage
GSP 与 edit
GSP 相同,但它使用 g:uploadForm
而不是 g:form
<g:uploadForm name="uploadFeaturedImage" action="uploadFeaturedImage">
<g:hiddenField name="id" value="${this.hotel?.id}" />
<g:hiddenField name="version" value="${this.hotel?.version}" />
<input type="file" name="featuredImageFile" />
<fieldset class="buttons">
<input class="save" type="submit" value="${message(code: 'hotel.featuredImage.upload.label', default: 'Upload')}" />
</fieldset>
</g:uploadForm>
6.3 将图片上传到 S3
uploadFeatureImage
控制器操作使用先前描述的命令对象来验证上传表单提交。如果它未找到验证错误,则它调用一项服务。
def uploadFeaturedImage(FeaturedImageCommand cmd) {
if (cmd.hasErrors()) {
respond(cmd.errors, model: [hotel: cmd], view: 'editFeaturedImage')
return
}
def hotel = uploadHotelFeaturedImageService.uploadFeatureImage(cmd)
if (hotel == null) {
notFound()
return
}
if (hotel.hasErrors()) {
respond(hotel.errors, model: [hotel: hotel], view: 'editFeaturedImage')
return
}
Locale locale = request.locale
flash.message = crudMessageService.message(CRUD.UPDATE, domainName(locale), hotel.id, locale)
redirect hotel
}
服务使用该插件提供的 amazonS3Service
将文件上传到 AWS S3。如果发生错误,服务将删除它先前上传的文件。
package example.grails
import grails.gorm.transactions.Transactional
import grails.plugin.awssdk.s3.AmazonS3Service
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@CompileStatic
@Slf4j
@Transactional
class UploadHotelFeaturedImageService {
AmazonS3Service amazonS3Service
HotelDataService hotelDataService
Hotel uploadFeatureImage(FeaturedImageCommand cmd) {
String path = "hotel/${cmd.id}/${cmd.featuredImageFile.originalFilename}"
String s3FileUrl = amazonS3Service.storeMultipartFile(path, cmd.featuredImageFile)
Hotel hotel = hotelDataService.update(cmd.id, cmd.version, path, s3FileUrl)
if ( !hotel || hotel.hasErrors() ) {
deleteFileByPath(path)
}
hotel
}
boolean deleteFileByPath(String path) {
boolean result = amazonS3Service.deleteFile(path)
if (!result) {
log.warn 'could not remove file {}', path
}
result
}
}
在 GORM 数据服务的帮助下更新域名类实例
package example.grails
import grails.gorm.services.Service
@Service(Hotel)
interface HotelDataService {
Hotel get(Long id)
List<Hotel> list(Map args)
Number count()
void delete(Serializable id)
Hotel save(String name)
Hotel update(Serializable id, Long version, String name)
Hotel update(Serializable id, Long version, String featuredImageKey, String featuredImageUrl)
}
6.4 使用 AWS 访问和密钥运行该应用程序
如果您不提供证书,它将使用凭证提供者链按如下顺序搜索凭证
环境变量 - AWS_ACCESS_KEY_ID 和 AWS_SECRET_KEY
Java 系统属性 - aws.accessKeyId 和 aws.secretKey`
通过 Amazon EC2 元数据服务(IAM 角色)传递的实例配置文件凭证
因此,我们可以使用以下命令运行该应用程序
./gradlew bootRun -Daws.accessKeyId=XXXXX -Daws.secretKey=XXXXX