一文了解文件上传全过程(干货)

前言

平常在写业务的时候,对于POST中常用的表单提交、JSON提交大家都会觉得比较容易,而对【文件上传】这个步骤可能会有些许害怕。因为大家对它的细节并不是怎么熟悉,而浏览器Network对它也没有详细记录。我们老是无法确定,关于文件上传到底是前端写得有问题呢,还是后端有问题,然后花费了大量的时间在不断的修改和尝试上。那么我们如何避免这种情况呢?要对上传这一块足够熟悉,才能不以猜的方式去写代码。希望你阅读完这篇文章你将收获自信,面对【上传】的时候可以了然于胸。

本文采用自顶向下的方式,所有示例会先展现出你熟悉的方式,再一层层往下,先从请求端是怎么发送文件的,再到接收端是怎么解析文件的。

前置知识

什么是 multipart/form-data?

multipart/form-data 最初由 [1]《RFC 1867: Form-based File Upload in HTML》文档提出。
由于文件上传功能将使许多应用程序受益,因此建议对HTML进行扩展,以允许信息提供者统一表达文件上传请求,并提供文件上传响应的MIME兼容表示。
就是原先的规范不满足啦,扩充规范了。

文件上传为什么要用 multipart/form-data?

1867文档中也写了为什么要新增一个类型,而不使用旧有的application/x-www-form-urlencoded:因为此类型不适合用于传输大型二进制数据或者包含非ASCII字符的数据。平常我们使用这个类型都是把表单数据使用url编码后传送给后端,二进制文件当然没办法一起编码进去了。所以multipart/form-data就诞生了,专门用于有效的传输文件。

也许你有疑问?那可以用 application/json吗?
其实无论你用什么都可以传,只不过要综合考虑一些因素的话,multipart/form-data更好。例如我们知道了文件是以二进制的形式存在,application/json 是以文本形式进行传输,那么某种意义上我们确实可以将文件转成例如文本形式的 Base64 形式。但是呢,转成这种形式后,后端也需要按照这种传输的形式,做特殊的解析。并且文本在传输过程中是相比二进制效率低的,那么对于我们动辄几十M几百M的文件来说是速度是更慢的。

我还可以举个例子,例如你在中国,想要去美洲,我们的multipart/form-data相当于是选择飞机,而application/json相当于高铁。但是中国和美洲之间没有高铁啊,你执意要坐高铁去的话,必须花昂贵的代价(后端额外解析你的文本)造高铁去美洲。但是你明明有更加廉价的方式坐飞机(使用multipart/form-data)去美洲(去传输文件)。你图啥?

multipart/form-data规范是什么?

摘自 [2]《RFC 1867: Form-based File Upload in HTML》 6.Example

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x--

简单解释一些,首先第一行是请求类型,然后是一个boundary(分隔符),因为可能有多文件多字段,每个字段/文件之间,需要由分隔符来对这个字段/文件是从开始到截止来进行划分。
后面就演示了field这个字段,声明内容的形式是 form-data 类型,字段名以及字段内容;
然后如果是文件的话,除了内容形式、字段名,还得有filename即文件名,还有这个文件的类型(text/plain) 。
后面我们会讲到如果这些没有声明的时候,会发生什么?

好了接下来要进入我们的主题了。面对File、formData、 Blob、Base64、ArrayBuffer,到底该怎么做?还有文件上传不仅仅是前端的事。服务端也可以进行文件上传(例如我们利用某云,把静态资源上传到 OSS 对象存储)。服务端和客户端也有各种类型,Buffer、Stream、Base64....头秃,怎么搞?我将上传文件的一方称为请求端,接受文件一方称为接收方。然后通过请求端各种上传方式,接收端如何解析我们的文件以及杀手锏调试工具-wireshark来进行讲解。

文章大纲

请求端

浏览端

File

我们先写最简单的表单提交方式。

<form action="http://localhost:7787/files" method="POST">
  <input name="file" type="file" id="file">
  <input type="submit" value="提交">
</form>

然而我们选择文件后上传,发现后端返回了文件不存在。

不用着急,熟悉的同学可能立马知道原因是啥了。
我们打开控制台,由于表单提交会进行网页跳转,因此我们勾选preserve log 来进行日志追踪。

可以发现FormDatafile字段显示的是文件名,并没有将真正的内容进行传输。再看请求头。

请求头规范和预期不符,也印证了application/x-www-form-urlencoded无法进行文件上传。
我们加上请求头,再次请求。

<form action="http://localhost:7787/files" enctype="multipart/form-data" method="POST">
  <input name="file" type="file" id="file">
  <input type="submit" value="提交">
</form>

文件上传成功,简单的表单上传就是像以上一样简单。但是你得熟记文件上传的格式以及类型。

FormData

我随便写了以下几种方式:

<input type="file" id="file">
<button id="submit">上传</button>
<script src="https://cdn.bootcss.com/axios/0.22.0/axios.min.js"></script>
<script>
  submit.onclick = () => {
    const file = document.getElementById('file').files[0];
    const formData = new FormData();
    formData.append('file', file);
  
    // 方式1
    axios.post('http://localhost:7787/files', formData).then(res => {
      console.log(res.data);
    })
    // 方式2
    fetch('http://localhost:333/files', {
      method: 'POST',
      body: formData
    }).then(res => res.json()).then(res => { console.log(res) });
    // 方式3
    var xhr = new XMLHttpRequest();
    xhr.open('POST', 'http://localhost:7787/files', true);
    xhr.onload = function () {
      console.log(xhr.responseText);
    };
    xhr.send(formData);
  }
</script>

以上几种方式都是可以的。但是请求库这么多,随便在 npm 上一搜就有几百个请求相关的库。

因此掌握请求库的写法并不是我们的目标,目标只有一个,还是掌握文件上传的请求头和请求内容

Blob

Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是JavaScript原生格式的数据。[3] File 接口基于Blob,继承了 blob 的功能并将其扩展以使其支持用户系统上的文件。

如果我们遇到 Blob 文件上方式不用害怕,可以用以下两种处理:

1.直接使用 blob 上传

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });

const formData = new FormData();
formData.append('file', blob, '1.json');
axios.post('http://localhost:7787/files', formData);

2.使用 File 对象,再进行一次包装(File 兼容性可能会差一些 https://caniuse.com/#search=File

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });

const file = new File([blob], '1.json');
formData.append('file', file);
axios.post('http://localhost:7787/files', formData)

ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。
虽然用得比较少,但它是最贴近文件流的方式了。
在浏览器中它的每个字节以十进制的形式存在。以下我提前准备了一张图片:

const bufferArrary = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,1,3,0,0,0,37,219,86,202,0,0,0,6,80,76,84,69,0,0,255,128,128,128,76,108,191,213,0,0,0,9,112,72,89,115,0,0,14,196,0,0,14,196,1,149,43,14,27,0,0,0,10,73,68,65,84,8,153,99,96,0,0,0,2,0,1,244,113,100,166,0,0,0,0,73,69,78,68,174,66,96,130];
const array = Uint8Array.from(bufferArrary);
const blob = new Blob([array], {type: 'image/png'});
const formData = new FormData();
formData.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', formData)

这里需要注意的是new Blob([typedArray.buffer], {type: 'xxx'}),第一个参数是由一个数组包裹。里面是typedArray类型的 buffer。

Base64

const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEUAAP+AgIBMbL/VAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==';
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
  byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const array = Uint8Array.from(byteNumbers);
const blob = new Blob([array], {type: 'image/png'});
const formData = new FormData();
formData.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', formData);

关于 base64 的转化和原理可以看这两篇 [4]【base64 原理
[5]【原来浏览器原生支持JS Base64编码解码

小结

对于浏览器端的文件上传,可以归结出一个套路,所有的核心思路就是构造出 File 对象。然后观察请求Content-Type,再看请求体是否有信息缺失。以上这些二进制数据类型的转化可以看以下表。

图片来源:[6]【浏览器中的二进制以及相关转换#数据输入】

服务端

服务器端和浏览器不同的是,服务端上传有两个难点:

  1. 服务端没有原生formData,也不会像浏览器一样帮我们转成二进制形式。
  2. 服务端没有可视化的Network调试器。

Buffer

1. Request
首先我们通过最简单的示例来进行演示,然后一步一步深入。相关文档可以查看 ➡️ https://github.com/request/request#multipartform-data-multipart-form-uploads

// request-error.js
const fs = require('fs');
const path = require('path');
const request = require('request');
const stream = fs.readFileSync(path.join(__dirname, '../1.png'));
request.post({
  url: 'http://localhost:7787/files',
  formData: {
    file: stream,
  }
}, (err, res, body) => {
  console.log(body);
})

发现报了一个错误,服务端报错该怎么办?这个时候就拿出我们的利器 -- wireshark

打开 wireshark (如果没有或者不会的可以查看教程 ➡️ 【Wireshark抓取本地Tcp包(任何数据包)】
设置配置 tcp.port == 7787,这个是我们后端的端口。

运行上述文件node request-error.js

来找到我们发送的这条http的请求报文。中间那堆乱七八糟的就是我们的文件内容。

POST /files HTTP/1.1
host: localhost:7787
content-type: multipart/form-data; boundary=--------------------------437240798074408070374415
content-length: 305
Connection: close

----------------------------437240798074408070374415
Content-Disposition: form-data; name="file"
Content-Type: application/octet-stream

.PNG
.
...
IHDR.............%.V.....PLTE......Ll.....  pHYs..........+.....
IDAT..c`.......qd.....IEND.B`.
----------------------------437240798074408070374415--

在上述报文可以看到内容请求头Content-Type: application/octet-stream有错误,我们上传的是图片格式应该是image/png,并且也少了文件名filename="1.png"

我们来思考一下,fs.readFileSync(path.join(__dirname, '../1.png'))这个函数返回的是BufferBuffer是什么样的呢?就是像下面这样,不会包含任何文件相关的信息,只有二进制流。

<Buffer 01 02>

所以需要指定文件名以及文件格式,幸好request也给我们提供了这个选项。

key: {
  value:  fs.createReadStream('/dev/urandom'),
  options: {
    filename: 'topsecret.jpg',
    contentType: 'image/jpeg'
  }
}

可以指定options,因此正确的代码应该如下(省略不重要的代码)

...
request.post({
  url: 'http://localhost:7787/files',
  formData: {
    file: {
      value: stream,
      options: {
        filename: '1.png',
        contentType: 'image/png'
      }
    },
  }
});

我们通过抓包进行分析可以看出文件上传的要点还是【规范】,大部分的问题都可以通过规范模板来进行排查,是否构造出了规范要求的样子。

2. Form-data
我们再深入一些,来看看request的源码是怎么实现Node端的数据传输的。

打开源码很容易就可以找到关于 formData 相关的内容 https://github.com/request/request/blob/3.0/request.js#L21

就是利用form-data,我们先来看看formData的方式。

const path = require('path');
const FormData = require('form-data');
const fs = require('fs');
const http = require('http');
const form = new FormData();
form.append('file', fs.readFileSync(path.join(__dirname, '../1.png')), {
  filename: '1.png',
  contentType: 'image/jpeg',
});
const request = http.request({
  method: 'post',
  host: 'localhost',
  port: '7787',
  path: '/files',
  headers: form.getHeaders()
});
form.pipe(request);
request.on('response', function(res) {
  console.log(res.statusCode);
});

3. 原生 Node
看完formData,感觉这个封装还是太高层了,于是打算对照规范手动来构造multipart/form-data请求方式来进行讲解。我们再来回顾一下规范。

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x--

模拟上方,用原生Node来写一个multipart/form-data请求的方式。

主要分为4个部分

  • 构造请求header
  • 构造内容header
  • 写入内容
  • 写入结束分隔符
const path = require('path');
const fs = require('fs');
const http = require('http');
// 定义一个分隔符,要确保唯一性
const boundaryKey = '-------------------------461591080941622511336662';
const request = http.request({
  method: 'post',
  host: 'localhost',
  port: '7787',
  path: '/files',
  headers: {
    'Content-Type': 'multipart/form-data; boundary=' + boundaryKey, // 在请求头上加上分隔符
    'Connection': 'keep-alive'
  }
});
// 写入内容头部
request.write(`--${boundaryKey}\r\nContent-Disposition: form-data; name="file"; filename="1.png"\r\nContent-Type: image/jpeg\r\n\r\n`
);
// 写入内容
const fileStream = fs.createReadStream(path.join(__dirname, '../1.png'));
fileStream.pipe(request, { end: false });
fileStream.on('end', function () {
  // 写入尾部
  request.end('\r\n--' + boundaryKey + '--' + '\r\n');
});
request.on('response', function(res) {
  console.log(res.statusCode);
});

至此,已经实现服务端上传文件的方式。

Stream、Base64

由于这两块就是和Buffer的转化,比较简单,就不再重复描述了。

// base64 to buffer
const b64string = /* whatever */;
const buf = Buffer.from(b64string, 'base64');
// stream to buffer
function streamToBuffer(stream) {  
  return new Promise((resolve, reject) => {
    const buffers = [];
    stream.on('error', reject);
    stream.on('data', (data) => buffers.push(data))
    stream.on('end', () => resolve(Buffer.concat(buffers))
  });
}

小结

由于服务端没有像浏览器那样formData的原生对象,因此核心思路为构造出文件上传的格式(header、filename等),然后写入buffer。然后别忘了用wireshark进行验证。

接收端

这一部分会针对Node进行讲解,如果你平常用惯了koa-body等,可能不太清楚整个过程发生了什么?一旦ctx.request.files不存在,就会懵逼了,也不清楚它到底做了什么,文件流又是怎么解析的。

还是要说到规范,请求端是按照规范来构造请求,那么接收端自然是按照规范来解析请求了。

Koa-body

const koaBody = require('koa-body');
// multipart 参数表示是否解析 FormData 形式的表单数据,即处理 Content-Type 为 multipart/formdate 的请求,上传文件必须为 true
app.use(koaBody({ multipart: true }));

我们来看看最常用的koa-body,它的使用方式非常简单,短短几行,就能让我们享受到文件上传的简单与快乐,(对其他源码库用一样的思路去寻找问题的本源) 可以带着一个问题去阅读,为什么用了它就能解析出文件?

打开koa-body的源码,只有很少的211行:https://github.com/dlau/koa-body/blob/v4.1.1/index.js#L125 可以发现它用了一个叫做formidable的库来解析files。并且把解析好的files对象赋值到了ctx.req.files。(所以说大家要注意查看文档,因为今天用koa-bodyctx.request.files,明天换个库可能就是ctx.request.body 了)

因此我们得出结论,koa-body的核心方法是formidable

Formidable

那么让我们继续深入,来看看formidable做了什么,我们首先来看它的目录结构。

├── lib
│   ├── file.js
│   ├── incoming_form.js
│   ├── index.js
│   ├── json_parser.js
│   ├── multipart_parser.js
│   ├── octet_parser.js
│   └── querystring_parser.js

根据这个目录,我们大致可以梳理出这样的关系。

index.js
|
incoming_form.js
|
type
?
|
1.json_parser
2.multipart_parser
3.octet_parser
4.querystring_parser

由于源码分析比较枯燥。因此只摘录比较重要的片段。由于是分析文件上传,所以我们只需要关心multipart_parser这个文件。
https://github.com/node-formidable/formidable/blob/v1.2.1/lib/multipart_parser.js#L72

...
MultipartParser.prototype.write = function(buffer) {
  console.log(buffer);
  var self = this,
    i = 0,
    len = buffer.length,
    prevIndex = this.index,
    index = this.index,
    state = this.state,
...

我们将它的buffer打印看看。

<Buffer 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 36 36 ... >
144
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 00 01 00 00 00 01 01 03 00 00 00 25 db 56 ca 00 00 00 06 50 4c 54 45 00 00 ff 80 80 80 4c 6c bf ... >
106
<Buffer 0d 0a 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 ... >

我们来看wireshark抓到的包:

我用红色进行了分割标记,对应的就是formidable所分割的片段 ,所以说这个包主要是将大段的buffer进行分割,然后循环处理。

这里我还要补充一下,可能你对以上表非常陌生。左侧是二进制流,每1个代表1个字节,1字节=8位。上面的 2d 其实就是16进制的表示形式,用二进制表示就是 0010 1101。右侧是 ascii 码用来可视化,但是 assii 分可显和非可显。有部分是无法可视的。比如你所看到文件中有些小点,就是不可见字符。

你可以对照[7]【ascii表对照表】来看。

下图总结了formidable对于文件的处理流程。

原生 Node

我们已经知道了文件处理的流程,那么自己来写一个吧。

const fs = require('fs');
const http = require('http');
const querystring = require('querystring');
const server = http.createServer((req, res) => {
  if (req.url === "/files" && req.method.toLowerCase() === "post") {
    parseFile(req, res)
  }
})
function parseFile(req, res) {
  req.setEncoding("binary");
  let body = "";
  let fileName = "";
  // 边界字符
  let boundary = req.headers['content-type']
    .split('; ')[1]
    .replace("boundary=", "")
  
  req.on("data", function(chunk) {
    body += chunk;
  });
  req.on("end", function() {
    // 按照分解符切分
    const list = body.split(boundary);
    let contentType = '';
    let fileName = '';
    for (let i = 0; i < list.length; i++) {
      if (list[i].includes('Content-Disposition')) {
        const data = list[i].split('\r\n');
        for (let j = 0; j < data.length; j++) {
          // 从头部拆分出名字和类型
          if (data[j].includes('Content-Disposition')) {
            const info = data[j].split(':')[1].split(';');
            fileName = info[info.length - 1].split('=')[1].replace(/"/g, '');
            console.log(fileName);
          }
          if (data[j].includes('Content-Type')) {
            contentType = data[j];
            console.log(data[j].split(':')[1]);
          }
        }
      }
    }
    // 去除前面的请求头
    const start = body.toString().indexOf(contentType) + contentType.length + 4; // 有多\r\n\r\n
    const startBinary = body.toString().substring(start);
    const end = startBinary.indexOf("--" + boundary + "--") - 2; // 前面有多\r\n
     // 去除后面的分隔符
    const binary = startBinary.substring(0, end);
    const bufferData = Buffer.from(binary, "binary");
    fs.writeFile(fileName, bufferData, function(err) {
      res.end("sucess");
    });
    ;
  })
}

server.listen(7787)

总结

相信有了以上的介绍,你对文件上传整个过程都会比较清晰了。

再次回顾下我们的重点:
请求端出问题,浏览器端打开 Network 查看格式是否正确(请求头,请求体)。如果数据不够详细,打开 wireshark,对照我们的规范标准,看下格式(请求头,请求体)。

接收端出问题,情况一就是请求端缺少信息,参考上面请求端出问题的情况,情况二请求体内容错误,如果说请求体内容是请求端自己构造的,那么需要检查请求体是否是正确的二进制流(例如上面的blob构造的时候,我一开始少了一个[],导致内容主体错误)。

其实讲这么多就两个字:[8] 【规范】,所有的生态都是围绕它而展开的。

参考

https://juejin.im/post/5c9f4885f265da308868dad1

https://my.oschina.net/bing309/blog/3132260

https://segmentfault.com/a/1190000020654277

本文分享整理自微信公众号 - 前端迷(love_frontend)

原文出处及转载信息见文内详细说明,如有侵权,请联系我删除。

原始发表时间:2020-04-06

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,264评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,549评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,389评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,616评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,461评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,351评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,776评论 3 387
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,414评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,722评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,760评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,537评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,381评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,787评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,030评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,304评论 1 252
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,734评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,943评论 2 336

推荐阅读更多精彩内容