Intervjuer iz Ctripa: Kako postići instant-upload (miaochuan) pri upload-u velikih fajlova?
~
Upload fajlova je tema o kojoj se uvek puno priča. Kada je fajl relativno mali, može se direktno pretvoriti u tok bajtova i otpremiti na server. Ali kada je fajl velik, to nije dobar pristup — malo je korisnika koji mogu da podnesu takvo iskustvo, posebno kada se upload prekine na pola, a nastavak znači ponovno otpremanje od početka, što korisničko iskustvo čini posebno neprijatnim.
Postoji li bolji način za upload? Naravno, a to su sledeći načini koje ćemo predstaviti.
Instant-upload (miaochuan)
1. Šta je instant-upload
Jednostavno rečeno: kada pokušate da otpremete ono što želite, server će prvo uraditi MD5 proveru; ako na serveru već postoji ista stvar, on će vam samo dodeliti novu adresu. Zapravo, sve što preuzimate je jedan te isti fajl na serveru. Da biste izbegli instant-upload, zapravo je dovoljno da promenite MD5, odnosno da malo izmenite sam fajl (promena imena ne pomaže). Na primer, tekstualni fajl — dodajte mu nekoliko karaktera, MD5 će se promeniti i neće biti instant-upload-a.
2. Suština logike instant-upload-a u ovom tekstu
a. Pomoću Redis set metode čuva se status upload-a fajla, gde je ključ md5 fajla koji se otprema, a vrednost flag koji označava da li je upload završen.
b. Kada je flag true, to znači da je upload već završen; u tom slučaju, ako se šalje isti fajl, ulazi se u logiku instant-upload-a. Ako je flag false, upload još nije završen; tada treba ponovo pozvati set metodu kako bi se sačuvao putanja zapisa o broju blokova, pri čemu je ključ md5 fajla koji se otprema + fiksni prefiks, a vrednost je putanja zapisa o broju blokova.
Upload po delovima (chunk/shard upload)
1. Šta je upload po delovima
Upload po delovima znači da se fajl koji se otprema, prema određenoj veličini, podeli u više blokova podataka (koje nazivamo Part) radi otpremanja. Nakon što se svi delovi otpreme, server vrši agregaciju i integraciju svih poslatih fajlova kako bi dobio originalni fajl.
2. Scenariji za upload po delovima
Upload velikih fajlova.
Okruženja sa lošom mrežom, gde postoji rizik da bude potrebno ponovno slanje.
Nastavak upload-a sa prekida (resume upload)
1. Šta je nastavak upload-a sa prekida
Nastavak upload-a sa prekida znači da se prilikom download-a ili upload-a zadatak preuzimanja ili otpremanja (jedan fajl ili jedan arhivirani paket) veštački podeli u nekoliko delova, i svaki deo obrađuje jedna nit. Ako dođe do mrežnog prekida, može se nastaviti od već preuzetog ili otpremljenog dela — nastaviti preuzimanje ili otpremanje nedovršenog dela, umesto da se kreće ispočetka.
PS: Nastavak upload-a sa prekida u ovom tekstu prvenstveno se odnosi na scenarij nastavka upload-a.
2. Scenariji primene
Nastavak upload-a sa prekida može se posmatrati kao izvedenica upload-a po delovima, pa se u svim scenarijima gde se koristi upload po delovima može koristiti i nastavak upload-a sa prekida.
3. Suština logike nastavka upload-a sa prekida
Tokom upload-a po delovima, ako zbog pada sistema, mrežnog prekida ili drugih izuzetaka dođe do prekida upload-a, klijent mora da zapiše napredak upload-a. Kasnije, kada se omogući ponovni upload, može se nastaviti od mesta gde je upload prekinut prošli put.
Da bi se izbegao problem ponovnog otpremanja od početka zbog brisanja podataka o napretku na klijentu nakon upload-a, server može da pruži odgovarajući interfejs pomoću kojeg klijent može da proveri podatke već otpremljenih delova, čime klijent sazna koje je podatke već poslao i nastavi od sledećeg dela.
4. Koraci procesa implementacije
a. Šema 1, standardni koraci
- Prema određenom pravilu deljenja, fajl koji se otprema podeli u blokove podataka iste veličine;
- Inicijalizuje se zadatak upload-a po delovima, koji vraća jedinstveni identifikator tog upload-a;
- Prema određenoj strategiji (serijskoj ili paralelnoj) šalju se pojedinačni blokovi podataka;
- Nakon slanja, server proverava da li su podaci kompletno otpremljeni; ako jesu, vrši se sinteza blokova podataka čime se dobija originalni fajl.
b. Šema 2, koraci implementacije u ovom tekstu
- Front-end (klijent) prema fiksnoj veličini deli fajl na delove; prilikom slanja zahteva back-endu (serveru) obavezno šalje broj i veličinu dela;
- Server kreira conf fajl kojim se beleži pozicija svakog bloka; dužina conf fajla jednaka je ukupnom broju delova — pošalje se deo, u conf fajl se upisuje jedan 127; pozicije koje nisu poslate ostaju podrazumevane 0, a poslate su Byte.MAX_VALUE 127 (ovaj korak je ključan za realizaciju nastavka upload-a sa prekida i instant-upload-a);
- Server na osnovu broja dela i veličine svakog dela (veličina dela je fiksna i ista) iz zahteva izračuna početnu poziciju, i sa pročitanim fragmentom podataka fajla vrši upis u fajl.
5. Implementacija koda za upload po delovima / nastavak upload-a sa prekida
a. Front-end koristi dodatak webuploader koji je obezbedio Baidu za deljenje na delove. Pošto ovaj tekst prvenstveno predstavlja serversku implementaciju koda, detalje o tome kako webuploader vrši deljenje mogu se pogledati na sledećem linku:
http://fex.baidu.com/webuploader/getting-started.html
b. Back-end koristi dva načina za upis fajla. Jedan je RandomAccessFile; oni koji nisu upoznati sa RandomAccessFile mogu pogledati sledeći link:
https://blog.csdn.net/dimudan2015/article/details/81910690
Drugi način je MappedByteBuffer; oni koji nisu upoznati sa MappedByteBuffer mogu ga upoznati preko sledećeg linka:
https://www.jianshu.com/p/f90866dcbffc
Suštinski kod back-enda za operaciju upisa
1. Način implementacije pomoću RandomAccessFile
@UploadMode(mode = UploadModeEnum.RANDOM_ACCESS)
@Slf4j
public class RandomAccessUploadStrategy extends SliceUploadTemplate {
@Autowired
private FilePathUtil filePathUtil;
@Value("${upload.chunkSize}")
private long defaultChunkSize;
@Override
public boolean upload(FileUploadRequestDTO param) {
RandomAccessFile accessTmpFile = null;
try {
String uploadDirPath = filePathUtil.getPath(param);
File tmpFile = super.createTmpFile(param);
accessTmpFile = new RandomAccessFile(tmpFile, "rw");
// ovo mora biti u skladu sa vrednošću koju je postavio front-end
long chunkSize = Objects.isNull(param.getChunkSize()) ? defaultChunkSize * 1024 * 1024
: param.getChunkSize();
long offset = chunkSize * param.getChunk();
// pozicioniranje na pomeraj tog dela
accessTmpFile.seek(offset);
// upis podataka tog dela
accessTmpFile.write(param.getFile().getBytes());
boolean isOk = super.checkAndSetUploadProgress(param, uploadDirPath);
return isOk;
} catch (IOException e) {
log.error(e.getMessage(), e);
} finally {
FileUtil.close(accessTmpFile);
}
return false;
}
}2. Način implementacije pomoću MappedByteBuffer
@UploadMode(mode = UploadModeEnum.MAPPED_BYTEBUFFER)
@Slf4j
public class MappedByteBufferUploadStrategy extends SliceUploadTemplate {
@Autowired
private FilePathUtil filePathUtil;
@Value("${upload.chunkSize}")
private long defaultChunkSize;
@Override
public boolean upload(FileUploadRequestDTO param) {
RandomAccessFile tempRaf = null;
FileChannel fileChannel = null;
MappedByteBuffer mappedByteBuffer = null;
try {
String uploadDirPath = filePathUtil.getPath(param);
File tmpFile = super.createTmpFile(param);
tempRaf = new RandomAccessFile(tmpFile, "rw");
fileChannel = tempRaf.getChannel();
long chunkSize = Objects.isNull(param.getChunkSize()) ? defaultChunkSize * 1024 * 1024
: param.getChunkSize();
// upis podataka tog dela
long offset = chunkSize * param.getChunk();
byte[] fileData = param.getFile().getBytes();
mappedByteBuffer = fileChannel
.map(FileChannel.MapMode.READ_WRITE, offset, fileData.length);
mappedByteBuffer.put(fileData);
boolean isOk = super.checkAndSetUploadProgress(param, uploadDirPath);
return isOk;
} catch (IOException e) {
log.error(e.getMessage(), e);
} finally {
FileUtil.freedMappedByteBuffer(mappedByteBuffer);
FileUtil.close(fileChannel);
FileUtil.close(tempRaf);
}
return false;
}
}3. Suštinski kod apstraktne klase za operacije nad fajlovima
@Slf4j
public abstract class SliceUploadTemplate implements SliceUploadStrategy {
public abstract boolean upload(FileUploadRequestDTO param);
protected File createTmpFile(FileUploadRequestDTO param) {
FilePathUtil filePathUtil = SpringContextHolder.getBean(FilePathUtil.class);
param.setPath(FileUtil.withoutHeadAndTailDiagonal(param.getPath()));
String fileName = param.getFile().getOriginalFilename();
String uploadDirPath = filePathUtil.getPath(param);
String tempFileName = fileName + "_tmp";
File tmpDir = new File(uploadDirPath);
File tmpFile = new File(uploadDirPath, tempFileName);
if (!tmpDir.exists()) {
tmpDir.mkdirs();
}
return tmpFile;
}
@Override
public FileUploadDTO sliceUpload(FileUploadRequestDTO param) {
boolean isOk = this.upload(param);
if (isOk) {
File tmpFile = this.createTmpFile(param);
FileUploadDTO fileUploadDTO = this.saveAndFileUploadDTO(param.getFile().getOriginalFilename(), tmpFile);
return fileUploadDTO;
}
String md5 = FileMD5Util.getFileMD5(param.getFile());
Map<Integer, String> map = new HashMap<>();
map.put(param.getChunk(), md5);
return FileUploadDTO.builder().chunkMd5Info(map).build();
}
/**
* Proveri i izmeni napredak upload-a fajla
*/
public boolean checkAndSetUploadProgress(FileUploadRequestDTO param, String uploadDirPath) {
String fileName = param.getFile().getOriginalFilename();
File confFile = new File(uploadDirPath, fileName + ".conf");
byte isComplete = 0;
RandomAccessFile accessConfFile = null;
try {
accessConfFile = new RandomAccessFile(confFile, "rw");
// označi taj segment kao true — završen
System.out.println("set part " + param.getChunk() + " complete");
// kreiraj conf fajl čija je dužina jednaka ukupnom broju delova; pošalje se blok i u conf fajl se upisuje jedan 127; neposlate pozicije su podrazumevano 0, a poslate su Byte.MAX_VALUE 127
accessConfFile.setLength(param.getChunks());
accessConfFile.seek(param.getChunk());
accessConfFile.write(Byte.MAX_VALUE);
// completeList — provera da li je sve završeno, odnosno da li su u nizu sve vrednosti 127 (svi delovi uspešno otpremljeni)
byte[] completeList = FileUtils.readFileToByteArray(confFile);
isComplete = Byte.MAX_VALUE;
for (int i = 0; i < completeList.length && isComplete == Byte.MAX_VALUE; i++) {
// AND operacija; ako neki deo nije završen, isComplete neće biti Byte.MAX_VALUE
isComplete = (byte) (isComplete & completeList[i]);
System.out.println("check part " + i + " complete?:" + completeList[i]);
}
} catch (IOException e) {
log.error(e.getMessage(), e);
} finally {
FileUtil.close(accessConfFile);
}
boolean isOk = setUploadProgress2Redis(param, uploadDirPath, fileName, confFile, isComplete);
return isOk;
}
/**
* Smesti informacije o napretku upload-a u Redis
*/
private boolean setUploadProgress2Redis(FileUploadRequestDTO param, String uploadDirPath,
String fileName, File confFile, byte isComplete) {
RedisUtil redisUtil = SpringContextHolder.getBean(RedisUtil.class);
if (isComplete == Byte.MAX_VALUE) {
redisUtil.hset(FileConstant.FILE_UPLOAD_STATUS, param.getMd5(), "true");
redisUtil.del(FileConstant.FILE_MD5_KEY + param.getMd5());
confFile.delete();
return true;
} else {
if (!redisUtil.hHasKey(FileConstant.FILE_UPLOAD_STATUS, param.getMd5())) {
redisUtil.hset(FileConstant.FILE_UPLOAD_STATUS, param.getMd5(), "false");
redisUtil.set(FileConstant.FILE_MD5_KEY + param.getMd5(),
uploadDirPath + FileConstant.FILE_SEPARATORCHAR + fileName + ".conf");
}
return false;
}
}
/**
* Operacija čuvanja fajla
*/
public FileUploadDTO saveAndFileUploadDTO(String fileName, File tmpFile) {
FileUploadDTO fileUploadDTO = null;
try {
fileUploadDTO = renameFile(tmpFile, fileName);
if (fileUploadDTO.isUploadComplete()) {
System.out
.println("upload complete !!" + fileUploadDTO.isUploadComplete() + " name=" + fileName);
//TODO sačuvaj informacije o fajlu u bazu
}
} catch (Exception e) {
log.error(e.getMessage(), e);
} finally {
}
return fileUploadDTO;
}
/**
* Preimenovanje fajla
*
* @param toBeRenamed fajl kojem će se menjati ime
* @param toFileNewName novo ime
*/
private FileUploadDTO renameFile(File toBeRenamed, String toFileNewName) {
// proveri da li fajl za preimenovanje postoji i da li je u pitanju fajl
FileUploadDTO fileUploadDTO = new FileUploadDTO();
if (!toBeRenamed.exists() || toBeRenamed.isDirectory()) {
log.info("File does not exist: {}", toBeRenamed.getName());
fileUploadDTO.setUploadComplete(false);
return fileUploadDTO;
}
String ext = FileUtil.getExtension(toFileNewName);
String p = toBeRenamed.getParent();
String filePath = p + FileConstant.FILE_SEPARATORCHAR + toFileNewName;
File newFile = new File(filePath);
// izmeni ime fajla
boolean uploadFlag = toBeRenamed.renameTo(newFile);
fileUploadDTO.setMtime(DateUtil.getCurrentTimeStamp());
fileUploadDTO.setUploadComplete(uploadFlag);
fileUploadDTO.setPath(filePath);
fileUploadDTO.setSize(newFile.length());
fileUploadDTO.setFileExt(ext);
fileUploadDTO.setFileId(toFileNewName);
return fileUploadDTO;
}
}Zaključak
Pri implementaciji upload-a po delovima potrebna je saradnja front-enda i back-enda — na primer, veličina fajla za broj blokova mora biti ista na obe strane, inače će doći do problema u upload-u. Pored toga, operacije nad fajlovima obično zahtevaju postavljanje fajl servera, kao što su korišćenje fastdfs, hdfs i sl.
U ovom primeru, na računaru sa 4 jezgra i 8 GB RAM-a, za upload fajla od 24 GB potrebno je preko 30 minuta; glavnina vremena troši se na izračunavanje md5 vrednosti na front-endu, dok je brzina upisa na back-endu i dalje prilično visoka.
Ako projektni tim smatra da je postavljanje sopstvenog fajl servera previše vremenski zahtevno, a potrebe projekta se svode samo na upload i download, preporučuje se korišćenje Alibaba OSS servera; detalje možete pogledati na zvaničnom sajtu:
https://help.aliyun.com/product/31815.html
Alibaba OSS je suštinski object storage server, a ne fajl server, pa ako postoje zahtevi za velikim brojem brisanja ili izmena fajlova, OSS možda nije dobar izbor.
Na kraju, evo linka sa demo primedom upload-a putem OSS formulara — pomoću njega se fajl može direktno otpremiti sa front-enda na OSS server, čime se sav pritisak upload-a prebacuje na OSS server:
