github.com/qiniu/go-sdk/v7@v7.27.0/storage/resume_uploader.go (about)

     1  package storage
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"hash/crc32"
     8  	"io"
     9  	"os"
    10  	"path/filepath"
    11  	"sort"
    12  	"sync"
    13  
    14  	"github.com/qiniu/go-sdk/v7/client"
    15  	"github.com/qiniu/go-sdk/v7/internal/clientv2"
    16  	"github.com/qiniu/go-sdk/v7/storagev2/apis"
    17  	"github.com/qiniu/go-sdk/v7/storagev2/http_client"
    18  	"github.com/qiniu/go-sdk/v7/storagev2/region"
    19  	"github.com/qiniu/go-sdk/v7/storagev2/uplog"
    20  )
    21  
    22  // ResumeUploader 表示一个分片上传的对象
    23  type ResumeUploader struct {
    24  	Client  *client.Client
    25  	Cfg     *Config
    26  	storage *apis.Storage
    27  }
    28  
    29  // NewResumeUploader 表示构建一个新的分片上传的对象
    30  func NewResumeUploader(cfg *Config) *ResumeUploader {
    31  	return NewResumeUploaderEx(cfg, nil)
    32  }
    33  
    34  // NewResumeUploaderEx 表示构建一个新的分片上传的对象
    35  func NewResumeUploaderEx(cfg *Config, clt *client.Client) *ResumeUploader {
    36  	if cfg == nil {
    37  		cfg = NewConfig()
    38  	}
    39  
    40  	if clt == nil {
    41  		clt = &client.DefaultClient
    42  	}
    43  
    44  	bucketQuery, _ := region.NewBucketRegionsQuery(getUcEndpoint(cfg.UseHTTPS, nil), &region.BucketRegionsQueryOptions{
    45  		UseInsecureProtocol: !cfg.UseHTTPS,
    46  		Client:              clt.Client,
    47  	})
    48  
    49  	opts := http_client.Options{
    50  		BasicHTTPClient:     clt.Client,
    51  		UseInsecureProtocol: !cfg.UseHTTPS,
    52  		BucketQuery:         bucketQuery,
    53  		AccelerateUploading: cfg.AccelerateUploading,
    54  		HostRetryConfig:     &clientv2.RetryConfig{},
    55  	}
    56  	if region := cfg.GetRegion(); region != nil {
    57  		opts.Regions = region
    58  	}
    59  
    60  	return &ResumeUploader{
    61  		Client:  clt,
    62  		Cfg:     cfg,
    63  		storage: apis.NewStorage(&opts),
    64  	}
    65  }
    66  
    67  // Put 方法用来上传一个文件,支持断点续传和分块上传。
    68  //
    69  // ctx     是请求的上下文。
    70  // ret     是上传成功后返回的数据。如果 upToken 中没有设置 CallbackUrl 或 ReturnBody,那么返回的数据结构是 PutRet 结构。
    71  // upToken 是由业务服务器颁发的上传凭证。
    72  // key     是要上传的文件访问路径。比如:"foo/bar.jpg"。注意我们建议 key 不要以 '/' 开头。另外,key 为空字符串是合法的。
    73  // f       是文件内容的访问接口。考虑到需要支持分块上传和断点续传,要的是 io.ReaderAt 接口,而不是 io.Reader。
    74  // fsize   是要上传的文件大小。
    75  // extra   是上传的一些可选项。详细见 RputExtra 结构的描述。
    76  func (p *ResumeUploader) Put(ctx context.Context, ret interface{}, upToken string, key string, f io.ReaderAt, fsize int64, extra *RputExtra) error {
    77  	return p.rput(ctx, ret, upToken, key, true, f, fsize, nil, extra)
    78  }
    79  
    80  func (p *ResumeUploader) PutWithoutSize(ctx context.Context, ret interface{}, upToken, key string, r io.Reader, extra *RputExtra) error {
    81  	return p.rputWithoutSize(ctx, ret, upToken, key, true, r, extra)
    82  }
    83  
    84  // PutWithoutKey 方法用来上传一个文件,支持断点续传和分块上传。文件命名方式首先看看
    85  // upToken 中是否设置了 saveKey,如果设置了 saveKey,那么按 saveKey 要求的规则生成 key,否则自动以文件的 hash 做 key。
    86  //
    87  // ctx     是请求的上下文。
    88  // ret     是上传成功后返回的数据。如果 upToken 中没有设置 CallbackUrl 或 ReturnBody,那么返回的数据结构是 PutRet 结构。
    89  // upToken 是由业务服务器颁发的上传凭证。
    90  // f       是文件内容的访问接口。考虑到需要支持分块上传和断点续传,要的是 io.ReaderAt 接口,而不是 io.Reader。
    91  // fsize   是要上传的文件大小。
    92  // extra   是上传的一些可选项。详细见 RputExtra 结构的描述。
    93  func (p *ResumeUploader) PutWithoutKey(ctx context.Context, ret interface{}, upToken string, f io.ReaderAt, fsize int64, extra *RputExtra) error {
    94  	return p.rput(ctx, ret, upToken, "", false, f, fsize, nil, extra)
    95  }
    96  
    97  // PutWithoutKeyAndSize 方法用来上传一个文件,支持断点续传和分块上传。文件命名方式首先看看
    98  // upToken 中是否设置了 saveKey,如果设置了 saveKey,那么按 saveKey 要求的规则生成 key,否则自动以文件的 hash 做 key。
    99  //
   100  // ctx     是请求的上下文。
   101  // ret     是上传成功后返回的数据。如果 upToken 中没有设置 CallbackUrl 或 ReturnBody,那么返回的数据结构是 PutRet 结构。
   102  // upToken 是由业务服务器颁发的上传凭证。
   103  // f       是文件内容的访问接口。
   104  // extra   是上传的一些可选项。详细见 RputExtra 结构的描述。
   105  func (p *ResumeUploader) PutWithoutKeyAndSize(ctx context.Context, ret interface{}, upToken string, f io.Reader, extra *RputExtra) error {
   106  	return p.rputWithoutSize(ctx, ret, upToken, "", false, f, extra)
   107  }
   108  
   109  // PutFile 用来上传一个文件,支持断点续传和分块上传。
   110  // 和 Put 不同的只是一个通过提供文件路径来访问文件内容,一个通过 io.ReaderAt 来访问。
   111  //
   112  // ctx       是请求的上下文。
   113  // ret       是上传成功后返回的数据。如果 upToken 中没有设置 CallbackUrl 或 ReturnBody,那么返回的数据结构是 PutRet 结构。
   114  // upToken   是由业务服务器颁发的上传凭证。
   115  // key       是要上传的文件访问路径。比如:"foo/bar.jpg"。注意我们建议 key 不要以 '/' 开头。另外,key 为空字符串是合法的。
   116  // localFile 是要上传的文件的本地路径。
   117  // extra     是上传的一些可选项。详细见 RputExtra 结构的描述。
   118  func (p *ResumeUploader) PutFile(ctx context.Context, ret interface{}, upToken, key, localFile string, extra *RputExtra) error {
   119  	return p.rputFile(ctx, ret, upToken, key, true, localFile, extra)
   120  }
   121  
   122  // PutFileWithoutKey 上传一个文件,支持断点续传和分块上传。文件命名方式首先看看
   123  // upToken 中是否设置了 saveKey,如果设置了 saveKey,那么按 saveKey 要求的规则生成 key,否则自动以文件的 hash 做 key。
   124  // 和 PutWithoutKey 不同的只是一个通过提供文件路径来访问文件内容,一个通过 io.ReaderAt 来访问。
   125  //
   126  // ctx       是请求的上下文。
   127  // ret       是上传成功后返回的数据。如果 upToken 中没有设置 CallbackUrl 或 ReturnBody,那么返回的数据结构是 PutRet 结构。
   128  // upToken   是由业务服务器颁发的上传凭证。
   129  // localFile 是要上传的文件的本地路径。
   130  // extra     是上传的一些可选项。详细见 RputExtra 结构的描述。
   131  func (p *ResumeUploader) PutFileWithoutKey(ctx context.Context, ret interface{}, upToken, localFile string, extra *RputExtra) error {
   132  	return p.rputFile(ctx, ret, upToken, "", false, localFile, extra)
   133  }
   134  
   135  type fileDetailsInfo struct {
   136  	fileFullPath string
   137  	fileInfo     os.FileInfo
   138  }
   139  
   140  func (p *ResumeUploader) rput(ctx context.Context, ret interface{}, upToken string, key string, hasKey bool, f io.ReaderAt, fsize int64, fileDetails *fileDetailsInfo, extra *RputExtra) (err error) {
   141  	if extra == nil {
   142  		extra = &RputExtra{}
   143  	}
   144  	extra.init()
   145  
   146  	var (
   147  		bucket      string
   148  		recorderKey string
   149  		fileInfo    os.FileInfo = nil
   150  	)
   151  	if fileDetails != nil {
   152  		fileInfo = fileDetails.fileInfo
   153  	}
   154  
   155  	if _, bucket, err = getAkBucketFromUploadToken(upToken); err != nil {
   156  		return
   157  	}
   158  
   159  	recorderKey = getRecorderKey(extra.Recorder, upToken, key, "v1", blockSize, fileDetails)
   160  
   161  	return uploadByWorkers(
   162  		newResumeUploaderImpl(p, bucket, key, hasKey, upToken, makeEndpointsFromUpHost(extra.UpHost), fileInfo, extra, ret, recorderKey),
   163  		ctx, newSizedChunkReader(f, fsize, blockSize))
   164  }
   165  
   166  func (p *ResumeUploader) rputWithoutSize(ctx context.Context, ret interface{}, upToken string, key string, hasKey bool, r io.Reader, extra *RputExtra) (err error) {
   167  	if extra == nil {
   168  		extra = &RputExtra{}
   169  	}
   170  	extra.init()
   171  
   172  	var bucket string
   173  
   174  	if _, bucket, err = getAkBucketFromUploadToken(upToken); err != nil {
   175  		return
   176  	}
   177  
   178  	return uploadByWorkers(
   179  		newResumeUploaderImpl(p, bucket, key, hasKey, upToken, makeEndpointsFromUpHost(extra.UpHost), nil, extra, ret, ""),
   180  		ctx, newUnsizedChunkReader(r, 1<<blockBits))
   181  }
   182  
   183  func (p *ResumeUploader) rputFile(ctx context.Context, ret interface{}, upToken string, key string, hasKey bool, localFile string, extra *RputExtra) (err error) {
   184  	var (
   185  		file        *os.File
   186  		fileInfo    os.FileInfo
   187  		fileDetails *fileDetailsInfo
   188  	)
   189  
   190  	if file, err = os.Open(localFile); err != nil {
   191  		return
   192  	}
   193  	defer file.Close()
   194  
   195  	if fileInfo, err = file.Stat(); err != nil {
   196  		return
   197  	}
   198  
   199  	if fullPath, absErr := filepath.Abs(file.Name()); absErr == nil {
   200  		fileDetails = &fileDetailsInfo{fileFullPath: fullPath, fileInfo: fileInfo}
   201  	}
   202  
   203  	return p.rput(ctx, ret, upToken, key, hasKey, file, fileInfo.Size(), fileDetails, extra)
   204  }
   205  
   206  // 创建块请求
   207  func (p *ResumeUploader) Mkblk(ctx context.Context, upToken string, upHost string, ret *BlkputRet, blockSize int, body io.Reader, size int) error {
   208  	return p.resumeUploaderAPIs().mkBlk(ctx, upToken, makeEndpointsFromUpHost(upHost), ret, int64(blockSize), body, int64(size))
   209  }
   210  
   211  // 发送bput请求
   212  func (p *ResumeUploader) Bput(ctx context.Context, upToken string, ret *BlkputRet, body io.Reader, size int) error {
   213  	return p.resumeUploaderAPIs().bput(ctx, upToken, makeEndpointsFromUpHost(ret.Host), ret, body, int64(size))
   214  }
   215  
   216  // 创建文件请求
   217  func (p *ResumeUploader) Mkfile(ctx context.Context, upToken string, upHost string, ret interface{}, key string, hasKey bool, fsize int64, extra *RputExtra) (err error) {
   218  	return p.resumeUploaderAPIs().mkfile(ctx, upToken, makeEndpointsFromUpHost(upHost), ret, key, hasKey, fsize, extra)
   219  }
   220  
   221  func (p *ResumeUploader) UpHost(ak, bucket string) (upHost string, err error) {
   222  	return p.resumeUploaderAPIs().upHost(ak, bucket)
   223  }
   224  
   225  func (p *ResumeUploader) resumeUploaderAPIs() *resumeUploaderAPIs {
   226  	return &resumeUploaderAPIs{cfg: p.Cfg, storage: p.storage}
   227  }
   228  
   229  type (
   230  	// 用于实现 resumeUploaderBase 的 V1 分片接口
   231  	resumeUploaderImpl struct {
   232  		cfg         *Config
   233  		storage     *apis.Storage
   234  		bucket      string
   235  		key         string
   236  		hasKey      bool
   237  		upToken     string
   238  		upEndpoints region.EndpointsProvider
   239  		bufPool     *sync.Pool
   240  		extra       *RputExtra
   241  		ret         interface{}
   242  		fileSize    int64
   243  		fileInfo    os.FileInfo
   244  		recorderKey string
   245  		lock        sync.Mutex
   246  	}
   247  
   248  	resumeUploaderRecoveryInfoContext struct {
   249  		Ctx       string `json:"c"`
   250  		Idx       int    `json:"i"`
   251  		ChunkSize int    `json:"s"`
   252  		Offset    int64  `json:"o"`
   253  		ExpiredAt int64  `json:"e"`
   254  	}
   255  
   256  	resumeUploaderRecoveryInfo struct {
   257  		RecorderVersion string                              `json:"v"`
   258  		Region          *Region                             `json:"r"`
   259  		FileSize        int64                               `json:"s"`
   260  		ModTimeStamp    int64                               `json:"m"`
   261  		Contexts        []resumeUploaderRecoveryInfoContext `json:"c"`
   262  	}
   263  )
   264  
   265  func newResumeUploaderImpl(resumeUploader *ResumeUploader, bucket, key string, hasKey bool, upToken string, upEndpoints region.EndpointsProvider, fileInfo os.FileInfo, extra *RputExtra, ret interface{}, recorderKey string) *resumeUploaderImpl {
   266  	bucketQuery, _ := region.NewBucketRegionsQuery(getUcEndpoint(resumeUploader.Cfg.UseHTTPS, nil), &region.BucketRegionsQueryOptions{
   267  		UseInsecureProtocol: !resumeUploader.Cfg.UseHTTPS,
   268  		Client:              resumeUploader.Client.Client,
   269  	})
   270  	opts := http_client.Options{
   271  		BasicHTTPClient:     resumeUploader.Client.Client,
   272  		BucketQuery:         bucketQuery,
   273  		UseInsecureProtocol: !resumeUploader.Cfg.UseHTTPS,
   274  		AccelerateUploading: resumeUploader.Cfg.AccelerateUploading,
   275  		HostRetryConfig:     &clientv2.RetryConfig{},
   276  	}
   277  	if region := resumeUploader.Cfg.GetRegion(); region != nil {
   278  		opts.Regions = region
   279  	}
   280  	if extra != nil {
   281  		if extra.TryTimes > 0 {
   282  			opts.HostRetryConfig.RetryMax = extra.TryTimes
   283  		}
   284  		if extra.HostFreezeDuration > 0 {
   285  			opts.HostFreezeDuration = extra.HostFreezeDuration
   286  		}
   287  	}
   288  	return &resumeUploaderImpl{
   289  		cfg:         resumeUploader.Cfg,
   290  		bucket:      bucket,
   291  		key:         key,
   292  		hasKey:      hasKey,
   293  		upToken:     upToken,
   294  		upEndpoints: upEndpoints,
   295  		extra:       extra,
   296  		ret:         ret,
   297  		fileSize:    0,
   298  		fileInfo:    fileInfo,
   299  		recorderKey: recorderKey,
   300  		storage:     apis.NewStorage(&opts),
   301  		bufPool: &sync.Pool{
   302  			New: func() interface{} {
   303  				return bytes.NewBuffer(make([]byte, 0, extra.ChunkSize))
   304  			},
   305  		},
   306  	}
   307  }
   308  
   309  func (impl *resumeUploaderImpl) initUploader(ctx context.Context) (recovered []int64, recoveredSize int64, err error) {
   310  	if impl.extra.Recorder != nil && len(impl.recorderKey) > 0 {
   311  		if recorderData, err := impl.extra.Recorder.Get(impl.recorderKey); err == nil {
   312  			recovered, recoveredSize = impl.recover(ctx, recorderData)
   313  			if len(recovered) == 0 {
   314  				impl.deleteUploadRecordIfNeed(nil, true)
   315  			}
   316  		}
   317  	}
   318  	return
   319  }
   320  
   321  func (impl *resumeUploaderImpl) uploadChunk(ctx context.Context, c chunk) error {
   322  	type ChunkRange struct {
   323  		From int64
   324  		Size int64
   325  	}
   326  	var (
   327  		chunkSize      = int64(impl.extra.ChunkSize)
   328  		apis           = impl.resumeUploaderAPIs()
   329  		chunkRange     ChunkRange
   330  		blkPutRet      BlkputRet
   331  		err            error
   332  		realChunkSize  int64
   333  		totalChunkSize = int64(0)
   334  		buffer         = impl.bufPool.Get().(*bytes.Buffer)
   335  	)
   336  	defer impl.bufPool.Put(buffer)
   337  
   338  	for chunkOffset := int64(0); chunkOffset < c.size; chunkOffset += chunkRange.Size {
   339  		chunkRange = ChunkRange{From: chunkOffset, Size: c.size - chunkOffset}
   340  		if chunkRange.Size > chunkSize {
   341  			chunkRange.Size = chunkSize
   342  		}
   343  
   344  		hash32 := crc32.NewIEEE()
   345  		buffer.Reset()
   346  		realChunkSize, err = io.Copy(hash32, io.TeeReader(io.NewSectionReader(c.reader, chunkRange.From, chunkRange.Size), buffer))
   347  		if err != nil {
   348  			impl.extra.NotifyErr(int(c.id), int(c.size), err)
   349  			return err
   350  		} else if realChunkSize == 0 {
   351  			break
   352  		} else {
   353  			totalChunkSize += realChunkSize
   354  		}
   355  		crc32Value := hash32.Sum32()
   356  
   357  		seekableData := bytes.NewReader(buffer.Bytes())
   358  		if chunkOffset == 0 {
   359  			if err = apis.mkBlk(ctx, impl.upToken, impl.upEndpoints, &blkPutRet, c.size, seekableData, realChunkSize); err == nil {
   360  				if blkPutRet.Crc32 != crc32Value || int64(blkPutRet.Offset) != chunkOffset+realChunkSize {
   361  					return ErrUnmatchedChecksum
   362  				}
   363  			}
   364  		} else {
   365  			if err = apis.bput(ctx, impl.upToken, impl.upEndpoints, &blkPutRet, seekableData, realChunkSize); err == nil {
   366  				if blkPutRet.Crc32 != crc32Value || int64(blkPutRet.Offset) != chunkOffset+realChunkSize {
   367  					return ErrUnmatchedChecksum
   368  				}
   369  			}
   370  		}
   371  
   372  		if err != nil {
   373  			impl.extra.NotifyErr(int(c.id), int(realChunkSize), err)
   374  			impl.deleteUploadRecordIfNeed(err, false)
   375  			return err
   376  		}
   377  	}
   378  
   379  	blkPutRet.blkIdx = int(c.id)
   380  	blkPutRet.fileOffset = c.offset
   381  	blkPutRet.chunkSize = int(totalChunkSize)
   382  
   383  	func() {
   384  		impl.lock.Lock()
   385  		defer impl.lock.Unlock()
   386  		impl.extra.Progresses = append(impl.extra.Progresses, blkPutRet)
   387  		impl.fileSize += c.size
   388  		impl.save(ctx)
   389  	}()
   390  
   391  	impl.extra.Notify(blkPutRet.blkIdx, int(totalChunkSize), &blkPutRet)
   392  
   393  	select {
   394  	case <-ctx.Done():
   395  		return ctx.Err()
   396  	default:
   397  	}
   398  
   399  	return nil
   400  }
   401  
   402  func (impl *resumeUploaderImpl) final(ctx context.Context) error {
   403  	if impl.extra.Recorder != nil && len(impl.recorderKey) > 0 {
   404  		impl.deleteUploadRecordIfNeed(nil, true)
   405  	}
   406  
   407  	sort.Sort(blkputRets(impl.extra.Progresses))
   408  	err := impl.resumeUploaderAPIs().mkfile(ctx, impl.upToken, impl.upEndpoints, impl.ret, impl.key, impl.hasKey, impl.fileSize, impl.extra)
   409  	impl.deleteUploadRecordIfNeed(err, false)
   410  	return err
   411  }
   412  
   413  func (impl *resumeUploaderImpl) version() uplog.UpApiVersion {
   414  	return uplog.UpApiVersionV1
   415  }
   416  
   417  func (impl *resumeUploaderImpl) getUpToken() string {
   418  	return impl.upToken
   419  }
   420  
   421  func (impl *resumeUploaderImpl) getBucket() string {
   422  	return impl.bucket
   423  }
   424  
   425  func (impl *resumeUploaderImpl) getKey() (string, bool) {
   426  	return impl.key, impl.hasKey
   427  }
   428  
   429  func (impl *resumeUploaderImpl) deleteUploadRecordIfNeed(err error, force bool) {
   430  	// 无效删除之前的记录
   431  	if force || (isContextExpiredError(err) && impl.extra.Recorder != nil && len(impl.recorderKey) > 0) {
   432  		_ = impl.extra.Recorder.Delete(impl.recorderKey)
   433  	}
   434  }
   435  
   436  func (impl *resumeUploaderImpl) recover(ctx context.Context, recoverData []byte) (recovered []int64, recoveredSize int64) {
   437  	var recoveryInfo resumeUploaderRecoveryInfo
   438  	if err := json.Unmarshal(recoverData, &recoveryInfo); err != nil {
   439  		return
   440  	}
   441  	if impl.fileInfo == nil || recoveryInfo.FileSize != impl.fileInfo.Size() ||
   442  		recoveryInfo.ModTimeStamp != impl.fileInfo.ModTime().UnixNano() {
   443  		return
   444  	}
   445  	if recoveryInfo.RecorderVersion != uploadRecordVersion {
   446  		return
   447  	}
   448  
   449  	for _, c := range recoveryInfo.Contexts {
   450  		if isUploadContextExpired(c.ExpiredAt) {
   451  			// 有一个过期,最终其实都会无效,重传最后之前没过期的也可能会过期
   452  			return nil, 0
   453  		}
   454  
   455  		impl.fileSize += int64(c.ChunkSize)
   456  		recoveredSize += int64(c.ChunkSize)
   457  		impl.extra.Progresses = append(impl.extra.Progresses, BlkputRet{
   458  			blkIdx: c.Idx, fileOffset: c.Offset, chunkSize: c.ChunkSize, Ctx: c.Ctx, ExpiredAt: c.ExpiredAt,
   459  		})
   460  		recovered = append(recovered, c.Offset)
   461  	}
   462  
   463  	return recovered, recoveredSize
   464  }
   465  
   466  func (impl *resumeUploaderImpl) save(ctx context.Context) {
   467  	var (
   468  		recoveryInfo  resumeUploaderRecoveryInfo
   469  		recoveredData []byte
   470  		err           error
   471  	)
   472  
   473  	if impl.fileInfo == nil || impl.extra.Recorder == nil || len(impl.recorderKey) == 0 {
   474  		return
   475  	}
   476  
   477  	recoveryInfo.RecorderVersion = uploadRecordVersion
   478  	recoveryInfo.Region = impl.cfg.Region
   479  	recoveryInfo.FileSize = impl.fileInfo.Size()
   480  	recoveryInfo.ModTimeStamp = impl.fileInfo.ModTime().UnixNano()
   481  	recoveryInfo.Contexts = make([]resumeUploaderRecoveryInfoContext, 0, len(impl.extra.Progresses))
   482  
   483  	for _, progress := range impl.extra.Progresses {
   484  		recoveryInfo.Contexts = append(recoveryInfo.Contexts, resumeUploaderRecoveryInfoContext{
   485  			Ctx: progress.Ctx, Idx: progress.blkIdx, Offset: progress.fileOffset, ChunkSize: progress.chunkSize, ExpiredAt: progress.ExpiredAt,
   486  		})
   487  	}
   488  
   489  	if recoveredData, err = json.Marshal(recoveryInfo); err != nil {
   490  		return
   491  	}
   492  
   493  	_ = impl.extra.Recorder.Set(impl.recorderKey, recoveredData)
   494  }
   495  
   496  func (impl *resumeUploaderImpl) resumeUploaderAPIs() *resumeUploaderAPIs {
   497  	return &resumeUploaderAPIs{cfg: impl.cfg, storage: impl.storage}
   498  }