由contenttype引发的一次小错误

本文通过一个实际案例说明了在使用不同客户端调用同一服务端接口时,Content-Type设置的不同导致的数据解析差异。强调了正确设置Content-Type对于确保接口能够正确解析请求数据的重要性。

             最近项目依然是在写服务端了,最近有写到服务端提供接口给客户端让其调用。

             正如大家所知,我们一般在和服务器交互时候,所有的接口请求都会定义一个统一的规范,一般都是同一个实体,例如下面的一个类

public class GeneralRequestVo<T> implements Serializable{
    private static final long serialVersionUID = -8448288200887137838L;
    /**
     * 通用请求信息(这也是一个实体,是一些公共参数,例如手机型号,app版本号之类的)
     */
    private CommonRequestMsg comm;
    /**
     * token(标识登录有效期的)
     */
    private String token;
    /**
     * 请求参数(这个其实才是具体的业务参数,泛型指定)
     */
    private T body;
    
    public CommonRequestMsg getComm() {
        return comm;
    }
    public void setComm(CommonRequestMsg comm) {
        this.comm = comm;
    }
    public String getToken() {
        return token;
    }
    public void setToken(String token) {
        this.token = token;
    }
    public T getBody() {
        return body;
    }
    public void setBody(T body) {
        this.body = body;
    }
}

我们客户端所有请求都会把这样子一个实体给服务端。然后看下服务端接口定义如下:

@RequestMapping(value = "/test", method = { RequestMethod.POST, RequestMethod.GET})
    public Map<String, Object> test(@RequestBody GeneralRequestVo<Map> requestVo) {
        //具体的业务逻辑被我删除掉了,这里相当于直接返回了一个空的json串“{}”
        return new HashMap();
    }
然后我们ios的大哥为了简便直接用了postman模拟了网络请求,虽然接口可以请求到,但是@RequestBody出来的GeneralRequestVo对象的属性值都是null。然后我使用的是我自己的客户端请求的,然后这个对象的属性就是有值的。我俩的请求参数基本一致。但是为啥一个可以另外一个就是不可以呢?

         后来发现是我们的请求头有区别了。他在postman里面请求的时候没有指定contenttype了。但是我客户端呢?请看如下

是的客户端用的网络请求框架默认指定了contenttype属性。标识我们是以json格式进行的请求。而用postman默认的是text/plain纯文本格式请求,所以服务端映射出来的对象的各个属性都是null了。以前还真的没有注意到过这个问题。

        谨以此做记录。如有错误,欢迎指正。


(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global.$fetch = factory()); }(typeof self !== 'undefined' ? self : window, function () { 'use strict'; class fetchFactory { constructor() { this.defaultOptions = { timeout: 10000, retries: 3, retryDelay: 1000 }; } /** * 创建AbortController */ createController() { return new AbortController(); } /** * 检测文件类型 */ detectFileType(contentType, url) { if (!contentType && url) { const ext = url.split('.').pop()?.toLowerCase(); if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) { return 'image'; } if (['json'].includes(ext)) { return 'json'; } if (['txt', 'md', 'js', 'css', 'html', 'xml' , 'vue', 'ts', 'jsx', 'scss'].includes(ext)) { return 'text'; } } if (contentType) { if (contentType.startsWith('image/')) return 'image'; if (contentType.includes('json')) return 'json'; if (contentType.startsWith('text/')) return 'text'; } return 'binary'; } /** * 图片专用转换 (FileReader) * @param {Blob} blob * @returns {Promise<string>} Base64字符串 */ toBase64(blob) { let isImage = false; if (!blob.type) return this.txtToBase64(blob) if (blob.type.startsWith('image/')) { console.log(`检测到图片格式: ${blob.type}`); isImage = true; } else { console.log(`其他文件格式: ${blob.type || '未知'}`); return this.txtToBase64(blob) } return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.onerror = (err) => reject(isImage ? '图片转换失败:' + err : err); reader.readAsDataURL(blob); }); } txtToBase64(str = '') { if (!str || typeof str !== 'string') return str // 创建 TextEncoder 实例 const encoder = new TextEncoder(); // 将字符串编码为 Uint8Array const data = encoder.encode(str); // 将字节数组转换为二进制字符串 const binary = Array.from(data, byte => String.fromCharCode(byte)).join(''); // 编码为 Base64 return btoa(binary); } /** * 自动处理响应数据 */ async handleResponse(response, options = {}) { const contentType = response.headers.get('content-type') || ''; const url = response.url; const fileType = this.detectFileType(contentType, url); let data; try { switch (options.readMode || 'auto') { case 'blob': data = await response.blob(); break; case 'text': data = await response.text(); break; case 'json': data = await response.json(); break; case 'arrayBuffer': data = await response.arrayBuffer(); break; case 'auto': default: if (fileType === 'text') { data = await response.text(); } else if (fileType === 'json') { data = await response.json(); } else { data = await response.blob(); } } } catch (error) { // 如果解析失败,返回原始文本 data = await response.text(); } return { data, fileType, contentType, size: response.headers.get('content-length') || 0 }; } /** * 核心请求方法 */ async request(url, options = {}) { const opts = { ...this.defaultOptions, ...options }; const controller = opts.signal || this.createController(); let result = { url, code: null, msg: '', data: null, loaded: 0, total: 0, duration: 0 }; const startTime = Date.now(); let timeoutId = null; try { // 请求前回调 if (opts.before) { await opts.before(url, opts); } if (opts.platform) { result.platform = opts.platform; delete opts.platform; } // 设置超时处理 if (opts.timeout > 0) { timeoutId = setTimeout(() => { controller.abort(); }, opts.timeout); } const response = await fetch(url, { method: 'GET', ...opts, signal: controller.signal }); result.code = response.status; // 处理特殊状态码 switch (response.status) { case 200: case 201: case 204: result.msg = 'ok'; break; case 429: result.msg = '请求频率超限,请稍后重试'; break; case 401: result.msg = '未授权,请检查认证信息'; break; case 403: result.msg = '禁止访问'; break; case 404: result.msg = '资源未找到'; break; case 500: result.msg = '服务器内部错误'; break; default: result.msg = `请求失败: ${response.statusText}`; } // 处理响应数据 const handledData = await this.handleResponse(response, opts); result.data = handledData.data; result.fileType = handledData.fileType; result.contentType = handledData.contentType; result.size = handledData.size; // 流式进度处理 if (opts.progress && response.body && handledData.fileType !== 'text' && handledData.fileType !== 'json') { const reader = response.body.getReader(); const contentLength = response.headers.get('content-length'); result.total = parseInt(contentLength) || 0; const chunks = []; let loaded = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); loaded += value.length; result.loaded = loaded; opts.progress(url, loaded, result.total); } // 重新组合数据 if (opts.readMode === 'blob' || (opts.readMode === 'auto' && handledData.fileType === 'binary')) { result.data = new Blob(chunks, { type: handledData.contentType }); } } } catch (error) { if (error.name === 'AbortError') { result.code = 0; result.msg = '请求已取消'; } else { result.code = error.code || -1; result.msg = error.message || '网络错误'; } } finally { // 在finally块中清除超时定时器,确保无论如何都会清理 if (timeoutId) { clearTimeout(timeoutId); } result.duration = Date.now() - startTime; // 完成后回调 if (opts.after) { await opts.after(url, result); } } return result; } /** * 带代理支持的请求方法 */ async requestWithProxy(url, options = {}) { const { proxies, ...opts } = options; // 如果没有提供代理,使用普通请求 if (!proxies || !Array.isArray(proxies) || proxies.length === 0) { return await this.request(url, opts); } // 尝试使用代理 let lastError = null; for (const proxy of proxies) { try { // 设置3秒超时 const proxyOptions = { timeout: 5000, ...opts, }; // 构建代理URL let proxyUrl; if (typeof proxy === 'string') { proxyUrl = `${proxy}${url}`; } else { if (proxy.perfix) { // 直接将完整URL拼接到代理URL后面 proxyUrl = `${proxy.url}${url}`; } else { // 只将路径部分拼接到代理URL后面 const urlObj = new URL(url); const path = urlObj.pathname + urlObj.search + urlObj.hash; proxyUrl = `${proxy.url}${path}`; } } const result = await this.request(proxyUrl, proxyOptions); // 如果请求成功,返回结果 if (result.code >= 200 && result.code < 300) { return result; } // 如果请求失败,记录错误并尝试下一个代理 lastError = result; } catch (error) { lastError = { code: -1, msg: error.message, url: proxy.url }; } } // 所有代理都失败,返回最后一个错误 return lastError || { code: -1, msg: '所有代理请求失败', url }; } /** * 并发下载 */ async downloadConcurrent(urls, options = {}) { const maxConcurrency = options.maxConcurrency || 3; const controller = new ConcurrencyController(maxConcurrency); const tasks = urls.map(url => () => this.requestWithProxy(url, options) ); const promises = tasks.map(task => controller.add(task)); return await Promise.allSettled(promises); } /** * 并行上传 */ async uploadParallel(file, platforms, options = {}) { const uploadPromises = platforms.map(async (platform) => { try { // 判断上传类型 if (file && typeof file === 'object') { // 根据平台调整请求体格式 const requestBody = { content: file.content, message: file.message, ...(platform.branch && { branch: platform.branch }) }; options.body = JSON.stringify(requestBody); // 确保Content-Type为application/json options.headers = { 'Content-Type': 'application/json', ...platform.headers }; } else { // 文件上传模式 - 使用原始文件对象 options.body = file; } options.method = options.method || 'POST'; const result = await this.request(platform.url, options); return { platform: platform.name, ...result }; } catch (error) { return { platform: platform.name, code: -1, msg: error.message }; } }); return await Promise.allSettled(uploadPromises); } } /** * 并发控制器 */ class ConcurrencyController { constructor(maxConcurrency = 3) { this.maxConcurrency = maxConcurrency; this.running = 0; this.queue = []; } async add(task) { return new Promise((resolve, reject) => { this.queue.push({ task, resolve, reject }); this.process(); }); } async process() { if (this.running >= this.maxConcurrency || this.queue.length === 0) { return; } this.running++; const { task, resolve, reject } = this.queue.shift(); try { const result = await task(); resolve(result); } catch (error) { reject(error); } finally { this.running--; this.process(); } } } // 创建实例 const instance = new fetchFactory(); // 导出便捷方法 const ketFetch = async (url, options) => { return await instance.request(url, options); }; ketFetch.request = (url, options) => instance.request(url, options); ketFetch.proxy = (url, options) => instance.requestWithProxy(url, options); ketFetch.downloadAll = (urls, options) => instance.downloadConcurrent(urls, options); ketFetch.uploads = (file, platforms, options) => instance.uploadParallel(file, platforms, options); ketFetch.Aborter = () => instance.createController(); ketFetch.fileType = (contentType, url) => instance.detectFileType(contentType, url); ketFetch.toBase64 = (file) => instance.toBase64(file); return ketFetch; })); 结合整个组件权衡一下, 尽量不改动现有逻辑, 不影响现有输出
最新发布
11-23
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值