Expo ImagePicker对接Rails API上传图片触发Errno::ENOENT报错
问题现象
通过Expo的ImagePicker组件上传图片时,Rails服务端返回500错误,错误日志如下:
Completed 500 Internal Server Error in 1ms (ActiveRecord: 2.8ms | Allocations: 637) Errno::ENOENT (No such file or directory @ rb_sysopen - file:///data/user/0/host.exp.exponent/cache/ExperienceData/%2540zilasino%252Frestaurant-management/ImagePicker/5f29f45c-4467-4360-bcc8-2651146837b3.jpg):
使用Insomnia调试上传接口可正常完成上传,仅在Android模拟器运行Expo应用发起请求时触发上述报错。请求参数可正常传递到Rails服务端,但服务端无法定位到对应文件。
现有相关代码
CreateMenuItem.js
const addMenuItem = async () => { const menuItem = { name, price, image: selectedImage.uri, is_drink: isDrink ? true : false, notes, }; const resp = await createAccessory("menu_items", menuItem); if(resp.type == 'success'){ fetchMenuItemData(); clearFields() } showMessage({ message: `${resp.msg}`, position: "center", duration: 2500, style: { backgroundColor: resp.type == 'success' ? colors.PRIMARY : colors.ERROR }, titleStyle: { fontSize: 20 }, }); }; const createAccessory = async (accessory, payload) => { return await axios.post(baseUrl + '/' + accessory, payload, {headers}) .then(resp => { if (resp.data){ return { msg: i18n.t('saved'), type: 'success' } } }) .catch(error => { if (error.response) { return { msg: error.response.data.name } } } ) }
ImageUpload.js
const ImageUpload = ({ parentCallback, selectedImage }) => { let openImagePickerAsync = async () => { let permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (permissionResult.granted === false) { alert("Permission to access camera roll is required!"); return; } let pickerResult = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, aspect: [1, 1], quality: 1 }); if (pickerResult.cancelled === true) { return; } parentCallback(pickerResult); }; if (selectedImage != null) { return ( <> <Image source={{ uri: selectedImage.uri }} style={styles.thumbnail} resizeMode="contain" /> <Button title="X" color={colors.PRIMARY} onPress={() => parentCallback(null)} /> </> ); } else { return ( <> <TouchableOpacity onPress={openImagePickerAsync} style={styles.button}> <Text style={styles.buttonText}>{i18n.t("pickAnImage")}</Text> </TouchableOpacity> </> ); } };
controller.rb
class MenuItemsController < ApplicationController ... def create image = Cloudinary::Uploader.upload(params[:image]) menu_item = MenuItem.new( image: image['url'], ) end ... end
根因分析
- 前端错误地将图片的本地缓存URI字符串作为
image参数传给后端,没有上传真实的图片二进制数据 - 服务端Cloudinary上传方法接收到字符串参数时,会默认该字符串是服务器本地的文件路径,直接尝试读取服务器对应路径下的文件。但
file:///开头的路径是Android模拟器内的本地缓存路径,Rails服务器上不存在该路径,因此抛出找不到文件的ENOENT错误 - Insomnia调试正常是因为工具发起请求时使用
multipart/form-data格式上传了真实的文件二进制流,和前端当前传参逻辑完全不同
修复方案
前端修改
不要直接传JSON格式的URI字符串,改用FormData构造 multipart 格式请求,上传真实文件对象:
- 修改
addMenuItem方法,构造FormData对象填充参数,将图片按照文件格式append到请求体中 - 修改axios请求配置,不要手动指定JSON格式的
Content-Type,让客户端自动生成带boundary标识的multipart请求头,否则后端无法正确解析文件
修改后的CreateMenuItem.js核心代码:
const addMenuItem = async () => { const formData = new FormData(); // 填充普通字段 formData.append('name', name); formData.append('price', price); formData.append('is_drink', isDrink ? true : false); formData.append('notes', notes); // 处理图片文件 const uriSegments = selectedImage.uri.split('.'); const fileExt = uriSegments[uriSegments.length - 1]; formData.append('image', { uri: selectedImage.uri, name: `menu_item.${fileExt}`, type: `image/${fileExt}` }); const resp = await createAccessory("menu_items", formData); if(resp.type == 'success'){ fetchMenuItemData(); clearFields() } showMessage({ message: `${resp.msg}`, position: "center", duration: 2500, style: { backgroundColor: resp.type == 'success' ? colors.PRIMARY : colors.ERROR }, titleStyle: { fontSize: 20 }, }); }; const createAccessory = async (accessory, payload) => { return await axios.post(baseUrl + '/' + accessory, payload, { headers: { Accept: 'application/json', // 不要手动设置Content-Type,让axios自动识别FormData生成合法的multipart头 ...headers } }) .then(resp => { if (resp.data){ return { msg: i18n.t('saved'), type: 'success' } } }) .catch(error => { if (error.response) { return { msg: error.response.data.name } } }) }
ImageUpload组件逻辑不需要修改。
后端验证
现有后端逻辑不需要调整,前端修改完成后,params[:image]会自动被Rails解析为ActionDispatch::Http::UploadedFile类型的文件对象,Cloudinary的上传方法原生支持该类型参数,可以直接读取文件流完成上传。
可在controller中加一行日志验证参数类型:
def create # 打印参数类型,若为ActionDispatch::Http::UploadedFile则传参正确 Rails.logger.info params[:image].class image = Cloudinary::Uploader.upload(params[:image]) menu_item = MenuItem.new( image: image['url'], ) # 后续保存逻辑不变 end
注意避坑
- 不要手动给FormData请求设置
Content-Type: multipart/form-data,缺少boundary标识的请求头会导致后端无法解析文件 - Android端返回的图片URI带
file://前缀属于正常情况,不需要手动截断,React Native网络层会自动处理该格式 - 上述FormData逻辑同时兼容iOS和Android双端,不需要写平台判断代码
内容的提问来源于stack exchange,提问作者Zilton
相关产品推荐
相关产品推荐

