还是那个OneDrive的网盘项目,还是在文件上传这块墨迹。
小文件上传已经实现了,现在就差大文件上传,这里涉及到了文件分片。
搜了一圈基本都是说前端分片后传到后端由Python去处理,那不就是JS的分片么?
那求人不如求己。

需求分析

很简单,就是要将文件分成多个小文件通过requests库传输到OneDrive。
但问题在于调试,没可能我每次都要在OneDrive里下载然后打开看是否出现损坏吧?
那倒不如把接收方的写入文件处理也顺便做了
步骤:

  1. 文件碎片字节范围的分配(OneDrive规定需要是320kb的倍数 )
  2. 文件分割
  3. 将碎片按顺序写入到新文件
  4. 校验是否与原文件相同

文件碎片字节范围的分配

因为OneDrive那边是大概是这样说的:
除最后一片外,其余分片的文件大小必须是320k(327680字节)的倍数,在上传完一片非320k倍数大小的文件后,该上传会话会失效。
那我们先定义好除最后一片外,没个文件碎片是10M吧
刚好320kb * 32 = 10240kb = 10M

为了方便举例,我们改成用字节:
每一个分片大小是10485760字节(10M)
然后我们这边有一个文件大小是58077184字节(55.38M)

首先用【每个分片的字节数】向【文件总字节】取模得出【余数】
58077184 % 10485760 = 5648384
这个余数就作为最后一片来进行上传。

然后来用【文件总字节】减去余数得出可以被10M整除的【合数】
58077184 - 5648384 = 52428800

最后我们将这个【合数】除以10M得出我们将要遍历的【次数】
52428800 / 10485760 = 5

得出结论:
将文件分成5次读取交给后续代码进行写入文件, 每次读取10485760字节。
那就需要定义一个指针,初始值为0

首次读取范围是 指针 ~ 指针加10M
0 ~ 10485760
随后给指针赋值 值为当前指针加上10485760
指针 = 指针 + 10485760

第二次读取范围是 10485760 ~ 20971520

第三次读取范围是 20971520 ~ 31457280

第四次读取范围是 31457280 ~ 41943040

第五次读取范围是 41943040 ~ 52428800

五次遍历完后其实还剩下52428800 ~ 58077184 这一区间没处理
那就额外的读取范围为 指针 ~ 指针 + 余数
52428800 ~ 52428800 + 5648384
52428800 ~ 58077184
并将其交给后续代码处理进行写入。

文件分割(分片读取)

思路有了,接下来是实现环节。
说是分割,但是看上面描述的话就知道其实只是选取了文件的部分区域进行读取,那肯定离不开open()函数。
我们先来看看官方文档是如何介绍open函数的:

open(file, mode='r', buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=None)

打开 file 并返回对应的 file object。如果该文件不能打开,则触发 OSError。

file 是一个 path-like object,表示将要打开的文件的路径(绝对路径或者当前工作目录的相对路径),也可以是要被封装的整数类型文件描述符。(如果是文件描述符,它会随着返回的 I/O 对象关闭而关闭,除非 closefd 被设为 False 。)

mode 是一个可选字符串,用于指定打开文件的模式。默认值是 'r' ,这意味着它以文本模式打开并读取。其他常见模式有:写入 'w' (截断已经存在的文件);排它性创建 'x' ;追加写 'a' (在 一些 Unix 系统上,无论当前的文件指针在什么位置,所有 写入都会追加到文件末尾)。在文本模式,如果 encoding 没有指定,则根据平台来决定使用的编码:使用 locale.getpreferredencoding(False) 来获取本地编码。(要读取和写入原始字节,请使用二进制模式并不要指定 encoding。)可用的模式有:

'r'读取(默认)
'w'写入,并先截断文件
'x'排它性创建,如果文件已存在则失败
'a'写入,如果文件存在则在末尾追加
'b'二进制模式
't'文本模式(默认)
'+'更新磁盘文件(读取并写入)

我们只需要关注前两个参数即可,第一个是要打开的文件路径,这个不用说。
第二个是模式,看起来我们只需要用到 r ,也就是读取的模式就行了?
答案是No的,先看看官方文档的这段话:

Python区分二进制和文本I/O。以二进制模式打开的文件(包括 mode 参数中的 'b' )返回的内容为 bytes`对象,不进行任何解码。
在文本模式下(默认情况下,或者在 *mode* 参数中包含 `'t'` )时,文件内容返回为 str ,首先使用指定的 encoding (如果给定)
或者使用平台默认的的字节编码解码。

因为我们需要进行分片的文件格式是各式各样的,并不单单是文本,所以我们需要使用 rb 模式,加上with as语句管理上下文:

with open('/test.zip', 'rb') as f:
    do something....

文件打开后我们需要规定读取的范围,这时可以查看官方文档的io模块,找到有seek()和read()方法:
seek(offset, whence=SEEK_SET)
offset参数指定文件流的指针在第几个字节开始。
whence决定一些便捷的行为,比如直接指定在头或者尾部,某些情况下需要offset为0。

read(size=-1)
如果size参数是None或者负数的话,就以文本的方式读取文件所有内容。
否则就读取size参数所指定的大小

把代码优化一下,比如改文件大小是5120字节(5KB),而我会以1024字节来分块遍历,使用send()方法通过某种方式传输给接收方。
注:send()是伪代码,可以是发送http请求,也可以是函数调用。

import os

path = '/test.zip'
file_name = path.split('/').pop()
# 5120
file_size = os.path.getsize()
# 每个分片的大小(字节)
each_piece_size = 1024
# 因为举例用的是整数,所以就不做取模 结果是5
times = file_size / each_piece_size 
# 指针
pointer = 0

with open(path, 'rb') as f:
    for t in range(0, times):
        f.seek(pointer)
        pointer += each_piece_size 
        send(f.read(pointer), file_name)   

文件写入

假设通过上一步传输方已经对文件进行分片读取,并按顺序将文件块通过某种方式传输到接收方。
这一步会讲述作为接收方如何使用Python将文件碎片“还原”成一个文件并与原文件一致。

使用到的是file object里面的write()方法。
同样是官方文档的io模块,找到wirte(),其实就是在read()下面:
write(b)
将参数传过来的二进制对象写入文件当中,返回已写入的字节,有可能返回的字节和写入的字节不一致blablabla....
反正就是要传入二进制对象啦。

无论是read()、seek()还是write()都是file object的一个方法,那获取一个file object一般我们需要通过open()方法,
如果传输的第一个路径参数文件不存在则会新建。

def write_file(file, file_name : str):
    # 这里是接收方的目录。 注意模式 ab ,b就很熟悉了。
    # a是从后面追加。只要能确保发送方是严格按照顺序发送过来的话这里就可以安心写入
    # 当然也可以要求发送方带上本次文件块的区间范围(比如 0 ~ 1024),然后接收方进行验证
    with open('/receive/' + file_name, 'ab') as f:
        f.write(file)

当然,如果不是通过调用函数的方式而是通过http等其他形式传输文件过来的话,作为接收方是无法知道文件是否上传完毕,需要约定一些“暗号”,具体实现不细讲了。

文件校验

这个就很简单了,一般的话我们会使用md5校验。
先通过open()打开文件,模式用rb, 返回的file object调用read()方法
使用hashlib库中的md5方法将其加密,在调用返回结果的hexdigest()方法得出16进制字符

import hashlib

with open('/test.zip', 'rb') as f:
    file_md5 = hashlib.md5(f.read()).hexdigest()

可以在文件上传前发送方得出原文件md5码,传输文件时将原文件md5码附在传输的数据中。
当接收方完成所有的文件块写入后对创建的文件进行md5加密,对比两者是否相同。

示例


import os
import hashlib


def send(path: str, receive_path: str):

    # 文件总大小(字节)
    file_size = os.path.getsize(path)
    # 文件名
    file_name = path.split('/').pop()
    # 每个文件块大小(10M)
    each_piece_size = 327680 * 32
    # 余数,作为最后一块写入
    remainder = file_size % each_piece_size
    # 合数,保证能被each_piece_size整除
    composite_num = file_size - remainder
    # 本次需要遍历的次数 (不算最后一块)
    times = int(composite_num / each_piece_size)
    # 指针
    pointer = 0

    # 简单判断下
    if file_size < each_piece_size:
        print('文件太小啦')
        exit()    
    if os.path.exists(receive_path + file_name):
        print('文件已存在')

    with open(path, 'rb') as f:
        # 原文件MD5
        orig_file_md5 = hashlib.md5(f.read()).hexdigest()
        for t in range(0, times):
            f.seek(pointer)
            pointer += each_piece_size
            receive(f.read(each_piece_size), receive_path, file_name)

        # 最后一块
        f.seek(pointer)
        new_file_md5 = receive(f.read(remainder), receive_path, file_name, True)
        if orig_file_md5 == new_file_md5:
            print('写入完成,校验成功')
            exit()
        else:
            print('MD5校验失败 原文件MD5: %s  新写入文件MD5: %s' % (orig_file_md5, new_file_md5))


def receive(file, receive_path: str, file_name: str, end=None):
    # 没有目录就创建
    if not os.path.exists(receive_path):
        os.makedirs(receive_path)
    with open(receive_path + file_name, 'ab') as f:
        f.write(file)
    if end:
        with open(receive_path + file_name, 'rb') as f:
            return hashlib.md5(f.read()).hexdigest()
    else:
        return None


if __name__ == '__main__':
    send('C:/Users/Rob/Desktop/upload_file/1.exe', 'C:/Users/Rob/Desktop/upload_file/receive/')


        

标签: none

添加新评论