細(xì)說分片上傳與極速秒傳(SpringBoot+Vue實(shí)現(xiàn))

預(yù)期目標(biāo)

  • 目標(biāo):需要突破服務(wù)端上傳大小限制事甜,實(shí)現(xiàn)大視頻文件的上傳

  • 預(yù)期:大視頻文件上傳不受上傳大小的限制

評估結(jié)果

要想實(shí)現(xiàn)大文件上傳有兩種方式:

1)調(diào)大服務(wù)端的文件上傳限制:在一定長度上可以緩解上傳限制問題,但并不是最優(yōu)解铸鹰。一方面無限制地調(diào)大上傳大小會加大服務(wù)端的壓力;一方面這個限制值調(diào)成多少是個需要考量的問題堕汞。

2)假設(shè)服務(wù)端的限制是10M玛追,需要上傳的文件是20M披诗,直接上傳顯然是不可以的甲献,那么分兩次呢宰缤?把文件切分到符合限制的大小分批發(fā)送,這樣就可以突破限制晃洒,這也就是分片上傳慨灭。

下面主要就分片上傳的方案做闡述。

分片上傳

前期準(zhǔn)備

首先這里上傳功能用antd的上傳組件來實(shí)現(xiàn)球及,通過自定義上傳動作來完成分片上傳氧骤;并且做文件切片時需要記錄下文件的 md5 信息,以便后續(xù)在服務(wù)端根據(jù)md5值來進(jìn)行文件合并吃引,這里需要用到spark-md5 庫來做文件md5計(jì)算筹陵,同時使用的 axios 來發(fā)起請求刽锤,具體依賴如下:

依賴 版本
vue ^3.0.0
ant-design-vue ^2.2.8
axios ^0.24.0
spark-md5 ^3.0.2

1、前端邏輯

1)上傳組件

首先是上傳組件部分惶翻,使用antd的upload組件姑蓝,添加一個按鈕來操作上傳動作鹅心,順便添加一個進(jìn)度條組件來展示上傳情況吕粗,具體情況見代碼:

<a-upload 
   :file-list="fileList"
   :remove="handleRemove"
   :multiple="false"
   :before-upload="beforeUpload">
  <a-button>
    <upload-outlined></upload-outlined>
    選擇文件
  </a-button>
</a-upload>
<a-button
  type="primary"
  :disabled="fileList.length === 0 || !finishSlice"
  :loading="uploading"
  style="margin-top: 16px"
  @click="handleUpload">
  {{ uploading ? "上傳中" : "開始上傳" }}
</a-button>
<a-progress :percent="Math.round(sliceProgress/sliceCount*100)"
            :status="sliceProgress===sliceCount ? 'success':'active'" v-if="showSliceProgress"/>
<a-progress :percent="Math.round(finishCount/sliceCount*100)"
            :status="finishCount===sliceCount ? 'success':'active'" v-if="showProgress"/>

其中 fileList 代表的是上傳文件列表;handleRemove 是操作刪除文件選擇的方法旭愧;beforeUpload 代表的是上傳文件之前的預(yù)操作方法颅筋,這里可以在這里進(jìn)行文件切片;handleUpload 代表的是開始上傳文件的方法输枯。

2)變量定義

接下來是上傳相關(guān)邏輯的編寫议泵,這里使用的是 typescript,先看一下定義的一些變量:

// 文件列表
const fileList = ref<File[]>([]);
// 上傳狀態(tài)
const uploading = ref<boolean>(false);
// 分片完成情況
const finishSlice = ref<boolean>(false);
// 完成上傳的分片數(shù)量
const finishCount = ref<number>(0);
// 展示上傳進(jìn)度條
const showProgress = ref<boolean>(false);
// 切片數(shù)量
const sliceCount = ref<number>(0);
// 切片進(jìn)度條
const sliceProgress = ref<number>(0);
// 上傳失敗的數(shù)量
const errorCount = ref<number>(0);
// 展示切片進(jìn)度條
const showSliceProgress = ref<boolean>(false);
// 切片列表
let fileChunkList: any = [];
// 發(fā)送的切片數(shù)量
const sendCount = ref<number>(0);
// 文件類型
let filetype = "";
// 文件名
let filename = "";
// 文件hash值
let hash = "";

3)文件切片

接下來是進(jìn)行文件的切片操作桃熄,這里需要使用到 spark-md5先口。

import SparkMD5 from 'spark-md5'

這里是將文件整體讀入計(jì)算md5,好處是md5碰撞的概率大大降低瞳收,缺點(diǎn)是計(jì)算時間會長一些碉京;如果想計(jì)算時間短一些,不追求極致的低碰撞率的話螟深,可以考慮讀入第一個切片和最后一個切片進(jìn)行md5計(jì)算谐宙。這里可以根據(jù)實(shí)際情況酌情考慮。

const beforeUpload = (file: File) => {
  message.info("開始文件切片");
  // 顯示切片進(jìn)度條
  showSliceProgress.value = true;
  // 文件添加到文件列表 這里只展示單文件上傳
  fileList.value = [file];
  // 一些參數(shù)的初始化
  fileChunkList = [];
  finishSlice.value = false;
  finishCount.value = 0;
  sliceProgress.value = 0;
  showProgress.value = false;
  sliceCount.value = 0;
  errorCount.value = 0;
  
  return new Promise((resolve, reject) => {
    // 初始化md5工具對象
    const spark = new SparkMD5.ArrayBuffer();
    // 用于讀取文件計(jì)算md5
    const fileReader = new FileReader();
    // 這里是依據(jù).來對文件和類型進(jìn)行分割
    let fileInfo = file.name.split(".")
    filename = fileInfo[0];
    // 最后一個.之前的內(nèi)容都應(yīng)該認(rèn)定為文件名稱
    if (fileInfo.length > 1) {
      filetype = fileInfo[fileInfo.length - 1];
      for (let i = 1; i < fileInfo.length - 1; i++) {
        filename = filename + "." + fileInfo[i];
      }
    }
    // 這里開始做切片
    // 設(shè)置切片大小 可以根據(jù)實(shí)際情況設(shè)置
    const chunkSize = 1024 * 1024 * 1;
    // 計(jì)算出切片數(shù)量
    sliceCount.value = Math.ceil(file.size / chunkSize);
    let curChunk = 0;
    // 切片操作的實(shí)際方法【定義】
    const sliceNext = () => {
      // 使用slice方法進(jìn)行文件切片
      const chunkFile = file.slice(curChunk, curChunk + chunkSize);
      // 讀取當(dāng)前切片文件流【這里會觸發(fā)onload方法】
      fileReader.readAsArrayBuffer(chunkFile);
      // 加入切片列表
      fileChunkList.push({
        // 切片文件信息
        chunk: chunkFile,
        // 文件名
        filename: filename,
        // 分片索引 這里直接借助sliceProgress來實(shí)現(xiàn)
        seq: sliceProgress.value + 1,
        // 文件類型
        type: filetype,
        // 狀態(tài)信息 用于標(biāo)識是否上傳成功
        status: false
      });
      // 切片完成變量自增
      sliceProgress.value++;
    };
    
    // 進(jìn)入方法需要進(jìn)行首次切片操作
    sliceNext();
    
    // 讀取文件流時會觸發(fā)onload方法
    fileReader.onload = (e: any) => {
      // 將文件流加入計(jì)算md5
      spark.append(e.target.result);
      // 修改切片位移
      curChunk += chunkSize;
      // 說明還沒到達(dá)最后一個切片 繼續(xù)切
      if (sliceProgress.value < sliceCount.value) {
        sliceNext();
      } else {
        // 說明切片完成了
        finishSlice.value = true;
        // 讀取文件hash值
        hash = spark.end();
        message.success("文件分片完成");
        // 將哈希值作為其中一個屬性 寫入到分片列表中
        fileChunkList.forEach((content: any) => {
          content.hash = hash;
        })
      }
    };
  })
};

到這里文件的切片和md5計(jì)算就完成了界弧,一個大文件也變成了多個小文件的列表凡蜻。

4)上傳分片

接下來介紹的是開始分片上傳的邏輯,這里需要注意不能一次性將分片全部上傳垢箕,如果切片數(shù)量太大一次性發(fā)送出去會導(dǎo)致客戶端卡死崩潰划栓,因此采用遞歸調(diào)用的方式來確保同一時間等待的請求在一定數(shù)量,這里限定同時間等待請求數(shù)為10条获。

// 開始執(zhí)行上傳切片邏輯
const startUpload = () => {
  return new Promise((resolve, reject) => {
    const next = () => {
      // 遞歸出口 分片上傳完畢
      if (finishCount.value + errorCount.value >= sliceCount.value) {
        return;
      }
      // 記錄當(dāng)前遍歷位置
      let cur = sendCount.value++;
      // 說明越界了 直接退出
      if (cur >= sliceCount.value) {
        return;
      }
      // 獲取分片信息
      let content = fileChunkList[cur];
      // 已經(jīng)上傳過了 直接跳過【可用于斷點(diǎn)續(xù)傳】
      if (content.status === true) {
        if (finishCount.value + errorCount.value < sliceCount.value) {
          next();
          return;
        }
      }
      // 開始填充上傳數(shù)據(jù) 這里需要使用FormData來存儲信息
      const formData = new FormData();
      formData.append("file", content.chunk);
      formData.append("hash", content.hash);
      formData.append("filename", content.filename);
      formData.append("seq", content.seq);
      formData.append("type", content.type);
      // 開始上傳
      axios.post("http://localhost:8080/upload", formData).then((res) => {
        // 接收回調(diào)信息
        const data = res.data;
        if (data.success) {
          // 成功計(jì)數(shù) 并設(shè)置分片上傳狀態(tài)
          finishCount.value += 1;
          content.status = true;
        } else {
          // 失敗計(jì)數(shù)
          errorCount.value += 1;
        }
        // 說明完成最后一個分片上傳但上傳期間出現(xiàn)錯誤
        if (errorCount.value !== 0 && errorCount.value + finishCount.value === sliceCount.value) {
          message.error("上傳發(fā)生錯誤茅姜,請重傳");
          showProgress.value = false;
          uploading.value = false;
        }
        // 說明還有分片未上傳 需要繼續(xù)遞歸
        if (finishCount.value + errorCount.value < sliceCount.value) {
          next();
        }
        // 說明所有分片上傳成功了 發(fā)起合并操作
        if (finishCount.value === sliceCount.value) {
          merge();
        }
      }).catch(error => {
        // 對于圖中發(fā)生的錯誤需要捕獲并記錄
        errorCount.value += 1;
        if (errorCount.value !== 0 && errorCount.value + finishCount.value === sliceCount.value) {
          message.error("上傳發(fā)生錯誤,請重傳");
          showProgress.value = false;
          uploading.value = false;
        }
        // 當(dāng)前分片上傳失敗不應(yīng)影響下面的分片
        if (finishCount.value + errorCount.value < sliceCount.value) {
          next();
        }
        console.log(error)
      })
    };
    // 只允許同時10個任務(wù)在等待
    while (sendCount.value < 10 && sendCount.value < sliceCount.value) {
      next();
    }
  });
};

5)文件合并

接下來還應(yīng)該實(shí)現(xiàn) merge 方法的邏輯月匣,主要用于向服務(wù)端發(fā)送合并請求钻洒,服務(wù)端接收后進(jìn)行分片合并操作,那么這里就應(yīng)該將需要合并的文件的hash值傳過去锄开,才可以完成文件的定位素标。

const merge = () => {
  message.success('上傳成功,等待服務(wù)器合并文件');
  // 發(fā)起合并請求 傳入文件hash值萍悴、文件類型头遭、文件名 
  axios.post("http://localhost:8080/merge", {
    hash: hash,
    type: filetype,
    filename: filename
  }).then((res) => {
    const data = res.data;
    if (data.success) {
      message.success(data.message);
      // 獲取上傳成功的文件地址
      console.log(data.content);
      // 其他業(yè)務(wù)操作...
    } else {
      message.error(data.message)
    }
    uploading.value = false;
  }).catch(e => {
    message.error('發(fā)生錯誤了');
    uploading.value = false;
  });
};

6)取消文件

最后完成取消選擇文件的邏輯寓免,也就是上面標(biāo)注的 handleRemove 方法:

const handleRemove = (file: File) => {
  const index = fileList.value.indexOf(file);
  const newFileList = fileList.value.slice();
  let hash = "";
  newFileList.splice(index, 1);
  fileList.value = newFileList;
  // 取消之后需要進(jìn)行相關(guān)變量的重新初始化
  fileChunkList = [];
  finishSlice.value = false;
  finishCount.value = 0;
  sliceProgress.value = 0;
  showProgress.value = false;
  sliceCount.value = 0;
  errorCount.value = 0;
};

7)極速秒傳

實(shí)際上到這里我們已經(jīng)實(shí)現(xiàn)了分片上傳與合并的功能了,但出于節(jié)省資源與提升用戶體驗(yàn)的考慮计维,我們還可以加入極速秒傳的邏輯袜香。這一塊實(shí)際上就是服務(wù)端合并文件之后將(hash:file-site)信息存儲起來,存儲到DB或者Cache中鲫惶,接下來前端在每次上傳文件時都會先請求文件檢查接口蜈首,如果文件存在則無需執(zhí)行上傳操作。

const handleUpload = async () => {
  if (!finishSlice.value) {
    alert("文件切片中欠母,請稍等~");
    return;
  }
  // 進(jìn)度條變更
  showSliceProgress.value = false;
  // 先檢查是否已經(jīng)上傳過
  axios.get("http://localhost:8080/check?hash=" + hash).then((res) => {
    const data = res.data;
    if (data.success) {
      message.success(data.message);
      console.log(data.content);
    } else {
      // 開始上傳邏輯 相關(guān)變量狀態(tài)更迭
      uploading.value = true;
      // 這里主要是服務(wù)于斷點(diǎn)續(xù)傳 避免重復(fù)上傳已成功分塊
      sliceCount.value -= finishCount.value;
      errorCount.value = 0;
      finishCount.value = 0;
      sendCount.value = 0;
      showProgress.value = true;
      console.log("開始上傳")
      // 調(diào)用上面寫好的上傳邏輯
      startUpload();
    }
  }).catch(error => {
    alert("發(fā)生異常了")
    console.log(error)
  })
}

到這里我們就完成了分片上傳/極速秒傳的前端邏輯欢策,接下來就應(yīng)該考慮后端的實(shí)現(xiàn)了。

2赏淌、后端邏輯

后端的基本思路是踩寇,接收到分片信息后根據(jù)hash值創(chuàng)建文件夾,之后將接收到的同一個hash值的分片信息都存儲到同一個文件夾下【這里需要注意存儲時要打好序號六水,才可以按序合并】俺孙,待收到合并請求后合并文件,根據(jù)合并文件的hash值與源hash值做比較掷贾,確保文件無損睛榄。

這里后端使用 SpringBoot 實(shí)現(xiàn),依舊是常見的分層模型胯盯,Controller 層負(fù)責(zé)請求接口定義懈费,Service 層負(fù)責(zé)業(yè)務(wù)邏輯的編寫,由于這里不涉及到數(shù)據(jù)庫的交互因而省略DAO層相關(guān)編寫博脑。

先確定下來提供的接口數(shù)憎乙,現(xiàn)在我們需要一個接收分片的接口,一個接受合并請求的接口叉趣,最后還要有一個接受文件檢查的接口用于極速秒傳泞边,具體如下:

接口 接口描述
uploadSlice 接收上傳切片的接口
merge 接收合并切片請求的接口
checkUpload 檢查文件上傳狀態(tài)的接口

1)返回實(shí)體

先來看看定義的全局返回實(shí)體,目的是同一后端返回樣式疗杉,方便前端獲日笱琛:

import java.io.Serializable;
/**
 * @author h0ss
 * @description 用于系統(tǒng)業(yè)務(wù)響應(yīng)數(shù)據(jù)的統(tǒng)一封裝
 */
public class CommonResp<T> implements Serializable {
    private static final Long serialVersionUID = 205112889857456165L;
    /**
     * 業(yè)務(wù)上的成功或失敗
     */
    private boolean success = true;

    /**
     * 返回信息
     */
    private String message;

    /**
     * 返回泛型的消息體數(shù)據(jù)
     */
    private T content;

    // 省略getter/setter/toString方法
}

2)上傳接口

接下來是接口的具體定義與內(nèi)容:

/**
 * 上傳分片的接口
 *
 * @param file     : 文件信息
 * @param hash     : 文件哈希值
 * @param filename : 文件名
 * @param seq      : 分片序號
 * @param type     : 文件類型
 */
@PostMapping("/upload")
public CommonResp<String> uploadSlice(@RequestParam(value = "file") MultipartFile file,
                                      @RequestParam(value = "hash") String hash,
                                      @RequestParam(value = "filename") String filename,
                                      @RequestParam(value = "seq") Integer seq,
                                      @RequestParam(value = "type") String type) {
    try {
        // 返回上傳結(jié)果
        return uploadService.uploadSlice(file.getBytes(), hash, filename, seq, type);
    } catch (IOException e) {
        // ...日志記錄異常信息...
        CommonResp<String> resp = new CommonResp<>();
        resp.setSuccess(false);
        resp.setMessage("上傳失敗");
        return resp;
    }
}

接口的信息很簡單,就是將參數(shù)預(yù)處理后調(diào)用服務(wù)方法將結(jié)果返回烟具,接下來看看服務(wù)方法:

private static String BASE_DIR = "I:\\";

/**
 * 分片上傳
 *
 * @param file     : 文件流
 * @param hash     : 哈希值
 * @param filename : 文件名
 * @param seq      : 分片序號
 * @param type     : 文件類型
*/
public CommonResp<String> uploadSlice(byte[] file, String hash, String filename, Integer seq, String type) {
    CommonResp<String> resp = new CommonResp<>();
    RandomAccessFile raf = null;
    try {
        // 創(chuàng)建目標(biāo)文件夾
        File dir = new File(BASE_DIR + hash);
        if (!dir.exists()) {
            dir.mkdir();
        }
        // 創(chuàng)建空格文件 名稱帶seq用于標(biāo)識分塊信息
        raf = new RandomAccessFile(BASE_DIR + hash + "\\" + filename + "." + type + seq, "rw");
        // 寫入文件流
        raf.write(file.getBytes());
    } catch (IOException e) {
        // 異常處理
        // ...打印異常日志...
        resp.setSuccess(false);
    } finally {
        try {
            if (raf != null) {
                raf.close();
            }
        } catch (IOException e) {
            // ...打印異常日志...
        }
    }
    return resp;
}

這樣我們就實(shí)現(xiàn)了分片信息的寫入梢什。

3)分片合并

接下來就應(yīng)該實(shí)現(xiàn)分塊合并的邏輯了,對于接受的請求信息我們用一個實(shí)體類來包裝朝聋,免得使用 Map 造成指向不明確:

public class MergeInfo implements Serializable {
    private static Long serialVersionUID = 1351063126163421L;
    /* 文件名 */
    private String filename;
    /* 文件類型 */
    private String type;
    /* 文件哈希值 */
    private String hash;
    
    // ...省略setter/getter/toString...
}

接下來就可以寫請求接口的信息了:

@PostMapping("/merge")
public CommonResp<String> merge(@RequestBody MergeInfo mergeInfo) {
    if (mergeInfo!=null) {
        String filename = mergeInfo.getFilename();
        String type = mergeInfo.getType();
        String hash = mergeInfo.getHash();
        return uploadService.uploadMerge(filename, type, hash);
    }
    CommonResp<String> resp = new CommonResp<String>();
    resp.setSuccess(false);
    resp.setMessage("文件合并失敗");
    return resp;
}

接口還是只對請求參數(shù)做預(yù)處理嗡午,具體看合并的業(yè)務(wù)層代碼:

@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 合并文件的業(yè)務(wù)代碼
*
* @param filename : 文件名
* @param hash     : 文件哈希值
* @param type     : 文件類型
*/
public CommonResp<String> uploadMerge(String filename, String type, String hash) {
    CommonResp<String> resp = new CommonResp<>();
    // 判斷hash對應(yīng)文件夾是否存在
    File dir = new File(BASE_DIR + hash);
    if (!dir.exists()) {
        resp.setSuccess(false);
        resp.setMessage("合并失敗,請稍后重試");
        System.out.println(resp);
    }
    // 這里通過FileChannel來實(shí)現(xiàn)信息流復(fù)制
    FileChannel out = null;
    // 獲取目標(biāo)channel
    try (FileChannel in = new RandomAccessFile(BASE_DIR + filename + '.' + type, "rw").getChannel()) {
        // 分片索引遞增
        int index = 1;
        // 開始流位置
        long start = 0;
        while (true) {
            // 分片文件名
            String sliceName = BASE_DIR + hash + '\\' + filename + '.' + type + index;
            // 到達(dá)最后一個分片 退出循環(huán)
            if (!new File(sliceName).exists()) {
                break;
            }
            // 分片輸入流
            out = new RandomAccessFile(sliceName, "r").getChannel();
            // 寫入目標(biāo)channel
            in.transferFrom(out, start, start + out.size());
            // 位移量調(diào)整
            start += out.size();
            out.close();
            out = null;
            // 分片索引調(diào)整
            index++;
        }
        // 文件合并完畢
        in.close();
        // ...執(zhí)行本地存儲服務(wù)/第三方存儲服務(wù)上傳 返回文件地址...
        // 這里假設(shè)是fileSite
        String fileSite = "";
        resp.setContent(fileSite);
        resp.setMessage("上傳成功");
        // 地址存入redis 實(shí)現(xiàn)秒傳
        stringRedisTemplate.opsForValue().set("upload:finish:hash:" + hash, fileSite);
        return resp;
    } catch (IOException e) {
        // ...記錄日志..
    } finally {
        if (out != null) {
            try {
                out.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    resp.setSuccess(false);
    resp.setMessage("上傳失敗冀痕,請稍后重試");
    return resp;
}

這樣我們就實(shí)現(xiàn)了接收分片上傳與分片合并的請求了荔睹。

4)極速秒傳

除此之外還有極速秒傳的檢查接口狸演,邏輯比較簡單,只要判斷 Redis 是否存在該文件 hash 值的 key 即可僻他,具體邏輯如下:

/**
 * 極速秒傳接口
 *
 * @param hash : 文件哈希值
 */
@Override
public CommonResp<String> fastUpload(String hash) {
    return uploadService.fastUpload(hash);
}
/**
 * 極速秒傳業(yè)務(wù)代碼
 *
 * @param hash : 文件哈希值
 */
public CommonResp<String> fastUpload(String hash) {
    CommonResp<String> resp = new CommonResp<>();
    String key = "upload:finish:hash:" + hash;
    String fileSite = stringRedisTemplate.opsForValue().get(key);
    // 文件已存在 直接返回地址
    if (fileSite != null) {
        resp.setSuccess(true);
        resp.setContent(fileSite);
        resp.setMessage("極速秒傳成功");
    } else {
        resp.setSuccess(false);
        resp.setContent("");
        resp.setMessage("極速秒傳失敗");
    }
    return resp;
}

至此宵距,我們就實(shí)現(xiàn)了后端的分片上傳合并以及極速秒傳的邏輯,到這里前后端代碼就可以聯(lián)調(diào)吨拗,開始測試了满哪。

總結(jié)

1)文件切片時需要注意計(jì)算出文件的 hash 值,以便后續(xù)進(jìn)行合并識別丢胚;

2)對于分片需要記錄下分片的索引信息翩瓜,否則組裝時可能會亂序造成文件損壞受扳;

3)文件信息可暫存在 Redis 中携龟,但建議最終還是持久化到 DB。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末勘高,一起剝皮案震驚了整個濱河市峡蟋,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌华望,老刑警劉巖蕊蝗,帶你破解...
    沈念sama閱讀 206,378評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異赖舟,居然都是意外死亡蓬戚,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,356評論 2 382
  • 文/潘曉璐 我一進(jìn)店門宾抓,熙熙樓的掌柜王于貴愁眉苦臉地迎上來子漩,“玉大人,你說我怎么就攤上這事石洗〈逼茫” “怎么了?”我有些...
    開封第一講書人閱讀 152,702評論 0 342
  • 文/不壞的土叔 我叫張陵讲衫,是天一觀的道長缕棵。 經(jīng)常有香客問我,道長涉兽,這世上最難降的妖魔是什么招驴? 我笑而不...
    開封第一講書人閱讀 55,259評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮枷畏,結(jié)果婚禮上别厘,老公的妹妹穿的比我還像新娘。我一直安慰自己矿辽,他們只是感情好丹允,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,263評論 5 371
  • 文/花漫 我一把揭開白布郭厌。 她就那樣靜靜地躺著,像睡著了一般雕蔽。 火紅的嫁衣襯著肌膚如雪折柠。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,036評論 1 285
  • 那天批狐,我揣著相機(jī)與錄音扇售,去河邊找鬼。 笑死嚣艇,一個胖子當(dāng)著我的面吹牛承冰,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播食零,決...
    沈念sama閱讀 38,349評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼困乒,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了贰谣?” 一聲冷哼從身側(cè)響起娜搂,我...
    開封第一講書人閱讀 36,979評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎吱抚,沒想到半個月后百宇,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,469評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡秘豹,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,938評論 2 323
  • 正文 我和宋清朗相戀三年携御,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片既绕。...
    茶點(diǎn)故事閱讀 38,059評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡啄刹,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出岸更,到底是詐尸還是另有隱情鸵膏,我是刑警寧澤,帶...
    沈念sama閱讀 33,703評論 4 323
  • 正文 年R本政府宣布怎炊,位于F島的核電站谭企,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏评肆。R本人自食惡果不足惜债查,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,257評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望瓜挽。 院中可真熱鬧盹廷,春花似錦、人聲如沸久橙。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,262評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至缸榄,卻和暖如春渤弛,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背甚带。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工她肯, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人鹰贵。 一個月前我還...
    沈念sama閱讀 45,501評論 2 354
  • 正文 我出身青樓晴氨,卻偏偏與公主長得像,于是被迫代替她去往敵國和親碉输。 傳聞我的和親對象是個殘疾皇子籽前,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,792評論 2 345

推薦閱讀更多精彩內(nèi)容