Il arrive que sur une application, l’upload de fichier atteigne les limites en taille, ce qui oblige de changer certains paramétrages côté serveur. Pour éviter ceci, il est préférable de découper le fichier en plusieurs morceaux qu’on envoie séparément au serveur. Le serveur doit alors les récupérer et les fusionner pour reconstituer le fichier d’origine.
Nous allons voir dans cet article comment implémenter ceci sur un front Angular et sur une API gérée en Function App
Ressources utilisées pour l’implémentation
Pour implémenter cette fonctionnalité, nous allons utiliser :
- un compte de stockage Azure sur lequel nous sauvegarderons les fichiers
- une file d’attente sur un service bus Azure afin de lancer de manière asynchrone la fusion des morceaux de fichier
- une Azure Function app qui servira d’API et sur laquelle nous implémenterons la logique côté backend
- un front Angular sur lequel nous implémenterons le découpage de fichier côté client
Upload des chunks sur l’API
Nous allons commencer par implémenter la réception des fichiers sur l’API.
Lors de la réception, nous devons identifier chaque fichier pour gérer les multiples uploads simultanés. Pour cela, nous considérons que le client génère un identifiant qui sera envoyé avec chaque morceau de fichier.
Le client doit également fournir le numéro du morceau afin de pouvoir reconstituer le fichier en prenant chaque morceaux dans l’ordre.
Voici le point d’entrée de l’API dans une Function App
[FunctionName("FileChunk")] public async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Function, "post", Route = "files/chunk")] HttpRequest req, ILogger log, CancellationToken ct) { log.LogInformation("C# HTTP trigger function processed a request."); IFormCollection formData = await req.ReadFormAsync(ct); if (formData == null) { return new BadRequestObjectResult(new { IsSuccess = false, Message = "No form found" }); } if (!formData.Files.Any()) { return new BadRequestObjectResult(new { IsSuccess = false, Message = "No file found" }); } try { Guid fileId = new Guid(req.Query["fileId"].ToString()); int index = int.Parse(req.Query["index"].ToString()); var result = await _fileChunkService.SaveChunkAsync(fileId, index, formData.Files[0], ct); return new OkObjectResult(new { IsSuccess = true, Value = result }); } catch (Exception e) { log.LogError(e, "Erreur dans la fonction FileChunk"); return new OkObjectResult(new { IsSuccess = false }); } }
Dans cette API, nous avons un paramètre « fileId » qui contient l’identifiant du fichier et un paramètre « index » qui contient le numéro du morceau.
Nous allons maintenant implémenter l’enregistrement des fichiers dans la méthode SaveChunkAsync. Cette méthode fait juste un enregistrement de blob sur le compte de stockage. L’important est le nom de ce blob pour pouvoir s’y retrouver dans tous les uploads qu’il pourrait y avoir simultanément.
Voici l’implémentation de la méthode SaveChunckAsync :
public async Task<Result<bool>> SaveChunkAsync(Guid id, int index, IFormFile file, CancellationToken ct) { _logger.LogInformation($"FileChunkService.SaveChunk - index {index} pour le fichier {id}"); if (index < 0) { string message = $"FileChunkService.SaveChunk - index {index} erroné pour le fichier {id}"; _logger.LogError(message); return new Result<bool>() { IsSuccess = false, Message = message }; } if (file == null) { string message = $"FileChunkService.SaveChunk - fichier vide pour le fichier {id}"; _logger.LogError(message); return new Result<bool>() { IsSuccess = false, Message = message }; } try { string blobName = $"{id}_{index}"; await using Stream fileStream = file.OpenReadStream(); await _cloudStorageService.UploadBlobAsync(fileStream, CHUNK_CONTAINER_NAME, blobName, ct); return new Result<bool>() { IsSuccess = true }; } catch (Exception e) { string message = $"FileChunkService.SaveChunk - Erreur sur le fichier {id}, index {index} : {e.Message}"; _logger.LogError(e, message); return new Result<bool>() { IsSuccess = false, Message = message }; } }
On peut voir que le nom du blob est composé de l’identifiant du fichier puis de son index. Ainsi, on pourra récupérer tous les morceaux d’un même fichier en fonction de son nom.
Finalisation de l’upload
Lorsque le client a fini d’uploader tous les morceaux de fichier, il devra appeler une route de l’API pour finaliser le traitement. Ainsi, la fusion des fichiers pourra être lancée pour reconstituer le fichier d’origine.
Cette méthode de finalisation ne fera qu’un appel à une file d’attente pour que cette fusion se fasse de manière asynchrone :
[FunctionName("FileFinalize")] public async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Function, "post", Route = "files/finalize")] HttpRequest req, ILogger log, CancellationToken ct) { log.LogInformation("C# HTTP trigger function processed a request."); try { Guid fileId = new Guid(req.Query["fileId"].ToString()); string fileName = req.Query["filename"].ToString(); string jsonMessage = JsonConvert.SerializeObject(new FileMessage() { FileId = fileId, FileName = fileName }); ServiceBusClient client = new ServiceBusClient(_options.Value.ServicebusConnectionString); ServiceBusSender sender = client.CreateSender(Constants.QueueName); await sender.SendMessageAsync(new ServiceBusMessage(jsonMessage), ct); return new OkObjectResult(new { IsSuccess = true }); } catch (Exception e) { log.LogError(e, "Erreur dans la fonction FileChunk"); return new OkObjectResult(new { IsSuccess = false }); } }
Cette route reçoit en paramètre l’identifiant du fichier et le nom final de ce fichier. Ces informations sont envoyées sur une file d’attente au format JSON. Nous allons maintenant traiter la réception de ce message.
Fusion des fichiers
Lorsqu’un message est reçu sur notre file d’attente, le traitement de fusion des morceaux se lance. En fonction de la taille du fichier d’origine et du nombre de morceaux, ce traitement peut prendre du temps. C’est la raison pour laquelle nous le faisons de manière asynchrone.
Afin d’éviter de charger tous les morceaux de fichier en mémoire, nous utilisons les Stream pour lire et écrire au fur et à mesure. Sinon, nous aurions du charger tous les morceaux de fichier en mémoire pour les fusionner, ce qui pourrait être couteux sachant que nous sommes sur une Function App serverless, nous payons ce que nous consommons.
Voici tout d’abord, l’implémentation de la réception du message sur la file d’attente :
[FunctionName("MergeFile")] public async Task Run( [ServiceBusTrigger(Constants.QueueName, Connection = "ServicebusConnectionString")]string queueItem, ILogger log, CancellationToken ct) { log.LogInformation("C# HTTP trigger function processed a request."); if (string.IsNullOrWhiteSpace(queueItem)) { log.LogError("MergeFile : Le message est vide"); throw new ArgumentException("Le message est vide"); } try { FileMessage message = JsonConvert.DeserializeObject<FileMessage>(queueItem); await _fileChunkService.MergeChunkAsync(message, ct); } catch (Exception e) { log.LogError(e, "Erreur dans la fonction MergeFile"); } }
Et voici l’implémentation de la méthode MergeChunkAsync qui lance la fusion de fichier :
public async Task<Result<Uri>> MergeChunkAsync(FileMessage message, CancellationToken ct, string containerDestination = null) { if (message == null) { throw new ArgumentException("Le message désérialisé est vide"); } if (string.IsNullOrWhiteSpace(message.FileName)) { throw new ArgumentException("Le message ne contient pas le nom du fichier"); } if (!message.FileId.HasValue) { throw new ArgumentException("Le message ne contient pas l'id du fichier"); } try { var enumerable = _cloudStorageService.GetBlobList(message.FileId.ToString(), CHUNK_CONTAINER_NAME); if (enumerable == null) { string errorMessage = $"Fichiers non trouvés pour l'id {message.FileId}"; _logger.LogError(errorMessage); return new Result<Uri>() { IsSuccess = false, Message = errorMessage }; } var list = enumerable.ToList(); if (!list.Any()) { string errorMessage = $"Fichiers non trouvés pour l'id {message.FileId}"; _logger.LogError(errorMessage); return new Result<Uri>() { IsSuccess = false, Message = errorMessage }; } if (string.IsNullOrWhiteSpace(containerDestination)) { containerDestination = CHUNK_CONTAINER_NAME; } await using Stream streamWriter = await _cloudStorageService.CreateBlobAsync(message.FileName, containerDestination, ct); foreach (string file in list.OrderBy(f => { string[] split = f.Split('_'); int.TryParse(split[1], out int index); return index; })) { await using Stream reader = await _cloudStorageService.GetBlobStreamAsync(file, CHUNK_CONTAINER_NAME, ct); reader.CopyTo(streamWriter); _logger.LogInformation($"MergeChunkAsync - Merge du chunk {file}"); } //Suppression des chunks après le merge foreach (string file in list) { await _cloudStorageService.DeleteBlobAsync(file, CHUNK_CONTAINER_NAME, ct); } return new Result<Uri>() { IsSuccess = true, Value = _cloudStorageService.GetBlobUri(message.FileName, containerDestination) }; } catch (Exception e) { string errorMessage = $"FileChunkService.MergeChunkAsync - Erreur sur le fichier {message.FileId} : {e.Message}"; _logger.LogError(e, errorMessage); return new Result<Uri>() { IsSuccess = false, Message = errorMessage }; } }
Dans cette méthode, voici la logique de traitement :
- On récupère la liste des blobs pour le fichier concerné grâce à son identifiant.
- On ouvre un Stream d’écriture sur le blob de destination qui correspondra à la fusion de tous les morceaux
- On parcourt la liste des blobs récupérée en les prenant dans l’ordre grâce à l’index contenu dans le nom de chacun de ces blobs
- On écrit le contenu de chaque blob sur la destination
- On supprime tous les morceaux de fichiers qui ne servent plus à rien.
Front : découpage du fichier à envoyer
Maintenant que notre backend est prêt, nous pouvons implémenter l’envoi de fichier côté client.
Nous commençons par une classe Service sur laquelle nous allons lancer les appels à l’API :
private chunkSize = 5242880;//5Mo private getChunkRequests(id: Guid, file: File) { console.log('getChunkRequests'); const requestArray = new Array<Observable<Result>>(); const url = `${this.baseUrl}/files/chunk`; // Number of parts (exclusive last part!) var chunks = this.getTotalChunks(file.size); // Iterate the parts for (var i = 0; i < chunks; i++) { var start = i * this.chunkSize; var end = Math.min(start + this.chunkSize, file.size); const formData = new FormData(); formData.append('file', file.slice(start, end)); const params = new HttpParams({ fromString: `fileid=${id.toString()}&index=${i.toString()}` }); const request = this.httpClient .post<Result>(url, formData, { params }).pipe(tap( (result)=>{ return result; }, (error) => { console.log(error); return error; }), retryWhen(errors => errors .pipe( concatMap((error, count) => { if (count < 5 && (error.status == 400 || error.status == 0)) { return of(error.status); } console.log('retry : ' + error); return throwError(() =>error); }), delay(1000) ))); requestArray.push(request); } }
Dans cette méthode, le paramètre file contient le fichier complet à envoyer et nous le découpons en morceau d’une taille qui se trouve dans la variable « this.chunkSize« . Nous avons choisi arbitrairement une taille de 5Mo.
Puis nous enregistrons tous les appels qui doivent être fait dans la variable requestArray que nous renvoyons en retour.
A noter que lors des appels API, nous utilisons ‘retryWhen’ pour relancer les appels en erreur. Si une erreur 400, ou 0 se produit, on attend 1 seconde et on retente dans une limite de 5 tentatives maximum.
Maintenant nous pouvons lancer tous les appels API que nous venons de générer :
uploadFile(id: Guid, file: File){ const observables = this.getChunkRequests(id, file); return from(observables) .pipe(mergeAll(5)); }
Dans cette méthode, nous utilisons « mergAll » pour lancer tous les appels à l’API en limitant le nombre de requête simultanée. Ici, nous limitons à 5 requêtes simultanées pour éviter des erreurs du navigateur qui n’arriverait pas à gérer des centaines, voire des milliers de requêtes simultanées.
Enfin, nous implémentons l’appel à la méthode finalize de l’API :
finalize(id: Guid, resultList: Result[], fileName: string){ let hasFailedChunkFiles: Result | undefined = resultList.find((res: Result) => res.isSuccess === false); if (!hasFailedChunkFiles) { const url = `${this.baseUrl}/files/finalize?fileid=${id.toString()}&fileName=${fileName}`; const params = new HttpParams({}); return this.httpClient .post<Result>(url, { params }); } return throwError(() => 'Certains chunks ont échoué'); }
Front : implémentation du composant graphique
Sur la partie UI, nous utilisons le composant ngx-mat-file-input pour sélectionner un fichier et le composant mat-progress-bar pour afficher la progression de l’upload. Puis nous ajoutons les boutons d’action :
<p>Select a file</p> <div class="row video-field" [formGroup]="form"> <mat-form-field> <ngx-mat-file-input formControlName="video" accept=".mp3, .mp4" #fileInput placeholder="Choisir un fichier"> </ngx-mat-file-input> <button mat-icon-button matSuffix> <mat-icon>folder</mat-icon> </button> <button mat-icon-button matSuffix *ngIf="!fileInput.empty" (click)="fileInput.clear($event)"> <mat-icon>clear</mat-icon> </button> </mat-form-field> </div> <div class="spinnerContainer" *ngIf="isLoading; else button"> <mat-progress-bar color="primary" mode="determinate" [value]="uploadProgress"></mat-progress-bar> <p>{{ uploadProgress }} %</p> <div class="upload-actions"> <button mat-raised-button *ngIf="!finishUploadVideo" color="primary" (click)="cancelUpload()"> Annuler </button> </div> </div> <ng-template #button> <div> <button mat-raised-button cdkFocusInitial color="primary" (click)="uploadVideo()"> Ajouter </button> <button mat-button>Annuler</button> </div> </ng-template>
Dans la partie javascript de notre composant, nous récupérons le fichier sélectionné dans la variable this.uploadFile :
uploadFile: File; constructor(private formBuilder: FormBuilder, private videoService: VideoService,) { } ngOnInit(): void { this.form = this.formBuilder.group({ video: [undefined, Validators.required] }) this.form.controls['video'].valueChanges.subscribe((fileInput: FileInput) => { if (fileInput !== null && fileInput.files.length > 0){ this.uploadFile = fileInput.files[0]; } else { this.uploadFile = undefined; }}); }
Et nous faisons l’appel à la méthode UploadFile de la classe Service pour déclencher l’Upload lorsqu’on clique sur le bouton ajouter :
uploadVideo() { if (this.uploadFile === undefined) { return; } this.isLoading = true; const id = Guid.create();//Creation de l'identifiant du fichier let nbUploadeChunck = 1;//Nombre de chunck qui ont été envoyé let total = this.videoService.getTotalChunks(this.uploadFile!.size);//Nombre total de chunks à envoyer let resultList: Result[] = [];//On enregistre tous les résultats d'upload pour savoir s'il y a eu des erreurs this.videoService.uploadFile(id, this.uploadFile!) .subscribe({ next: (result) =>{ this.uploadProgress = Math.round((nbUploadeChunck / total) * 100); resultList.push(result); //S'il y a une erreur, on arrete l'envoie if (result != null && result.isSuccess === false) { this.cancelUpload(); if (result.message) { console.log(result.message); } } nbUploadeChunck++; if (nbUploadeChunck > total) { this.finishUploadVideo = true; console.log('La vidéo a été correctement ajouté'); this.videoService.finalize(id, resultList, this.uploadFile!.name).subscribe({ next: (result) => { console.log('Finalize terminé'); this.isLoading = false; }, error: (e) => console.log(e.message) }) } }, error: (e) => console.log(e.message) }); }
Nous voyons ici, que nous gérons la progression de l’upload en incrémentant un compteur à chaque fois qu’un appel API est terminé en succès.
Puis lorsque tous les appels sont terminés, on appelle, la méthode finalize.
Voyons ce que ça donne en exécutant l’application sur un fichier de 5.81Mo :

Nous voyons que le fichier a été découpé en 6 morceaux. Il y a 6 appels à la méthode « files/chunk » de l’API puis un appel à la méthode « finalize« . Et sur le compte de stockage, nous retrouvons bien les 6 fichiers avant que la fusion ne soit lancée :

Puis une fois que la fusion s’est faite, nous n’avons plus qu’un seul fichier de 5.81Mo :

Nous venons d’implémenter une fonctionnalité d’envoi de fichier volumineux qui peut être repris dans une application. Il est possible d’aller plus loin en fonction de votre besoin en ajoutant une méthode d’initialisation sur l’API avant d’envoyer les chunks ou d’ajouter de la logique sur la méthode finalize.
A savoir qu’il existe une librairie javascript pour envoyer des fichiers sur un compte de stockage directement depuis le front. Nous avons fait le choix ici de passer par une API pour éviter d’exposer côté client les identifiants de connexion au compte de stockage.
Retrouvez le code source complet sur notre github : https://github.com/dcube/Upload_Chunk
0 commentaires