Mengoptimalkan Generasi Laporan PDF Besar di Spring dengan JasperReports

NDID Engineering Blog
NDID Engineering
Published in
7 min readOct 6, 2023

Author: Andi Pranama

Pendahuluan

Menghasilkan laporan PDF yang besar dapat menjadi tugas yang menantang, terutama dalam aplikasi Spring di mana manajemen memori sangat penting. Jika Anda pernah mengalami pesan kesalahan “Out of Memory” saat mencoba menghasilkan laporan PDF berukuran besar, Anda tidak sendirian. Dalam artikel ini, kita akan menjelajahi solusi praktis untuk masalah ini dengan membagi proses pembuatan laporan menjadi bagian yang lebih kecil dan menggabungkannya pada akhirnya. Kami juga akan menyediakan contoh kode untuk membantu Anda menerapkan solusi ini dalam aplikasi Spring Anda.

Masalah

Saat menghasilkan laporan PDF dengan JasperReports, tidak jarang mengalami masalah memori ketika berurusan dengan laporan yang memiliki ukuran file yang besar, hal ini tentu tergantung pada memori yang tersedia di aplikasi Anda. Ini dapat menyebabkan kesalahan “Out of Memory” yang dapat membuat aplikasi Anda berhenti.

Contoh kasus yang akan saya simulasikan adalah Ketika ingin membuat JasperReport Payslip dengan jumlah ribuan karyawan secara sekaligus. Mari kita buat program untuk simulasi “Out of Memory” pada saat membuat laporan Payslip tersebut.

1. Pertama-tama, buat proyek spring boot dengan dependency Spring Web (wajib), dan Lombok (Opsional) untuk mempersingkat code program kita.

2. Buat model dengan nama Payslip.java:

package com.andipramana.hugejasperreport.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Payslip {
private Long payslipId;
private Long employeeId;
private String employeeName;
private String employeeCode;
private String officeName;
private String position;
private String basicSalary;
private String fixAllowance;
private String variableAllowance;
private String deduction;
private String tax;
private String takeHomePay;
}

3. Buat Service untuk mensimulasilakan “Out of Memory” Ketika membuat laporan dengan nama PayslipService.java

package com.andipramana.hugejasperreport.service;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReport;
import net.sf.jasperreports.engine.data.JRBeanCollectionDataSource;

import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

import com.andipramana.hugejasperreport.model.Payslip;

@Service
public class PayslipService {
public byte[] generateReportAtOnce(Long totalPage) throws IOException, JRException {
// Load the JasperReport template (JRXML file)
InputStream jrxmlInput = new ClassPathResource("/jasper/my_report.jrxml").getInputStream();
JasperReport jasperReport = JasperCompileManager.compileReport(jrxmlInput);

// Create data source (replace YourDataClass and fetchDataForPage with your actual data)
List<Payslip> dataForPage = fetchDataForPage(totalPage);
JRBeanCollectionDataSource dataSource = new JRBeanCollectionDataSource(dataForPage);

// Create JasperPrint by filling the report with data
Map<String, Object> parameters = null; // If you have report parameters, you can set them here
JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, parameters, dataSource);

byte[] reportBytes = JasperExportManager.exportReportToPdf(jasperPrint);
return reportBytes;
}

// Simulate retrieving data for a specific range of pages
private List<Payslip> fetchDataForPage(Long totalPage) {
List<Payslip> data = new ArrayList<>();
// Implement logic to retrieve data for the specified page range
// For this example, we'll create a list of dummy data
for (Long i = 0L; i.compareTo(totalPage) < 0; i++) {
Long count = i+1;
data.add(new Payslip(count, count, "Employee " + count, "EM"+count, "Office 1", "Supervisor", "10.000.000", "200.000", "100.000", "0", "50.000", "10.250.000"));
}

return data;
}
}

4. Buat controller untuk membuat laporan dengan simulasi “Out of Memory”

package com.andipramana.hugejasperreport.controller;

import java.io.IOException;

import javax.servlet.http.HttpServletResponse;

import net.sf.jasperreports.engine.JRException;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import com.andipramana.hugejasperreport.service.PayslipService;

@Controller
public class PayslipController {
@Autowired
private PayslipService payslipService;

@GetMapping("/generate-payslip")
public void generateReport(@RequestParam Long totalPage, HttpServletResponse response) throws JRException, IOException {
byte[] payslipBytes = payslipService.generateReportAtOnce(totalPage);
response.setContentType("application/pdf");
response.setHeader("Content-Disposition", "attachment; filename=output.pdf");
response.getOutputStream().write(payslipBytes);
}
}

5. Agar simulasi “Out of Memory” dapat terjadi, kita akan menjalankan aplikasi spring boot dengan memory yang terbatas.
- Buka folder proyek aplikasi kita di console (Command Prompt, Git Bash, dll.)

- Build aplikasi dengan perintah berikut:
“mvn clean package“ (tanpa tanda kutip)

- Jalanlan perintah berikut untuk menjalankan aplikasi dengan memory terbatas:
“java -Xmx40m -jar ./target/huge-jasper-report-0.0.1-SNAPSHOT.jar” (tanpa tanda kutip, ganti huge-jasper-report-0.0.1-SNAPSHOT dengan nama file jar anda)

- Panggil url berikut pada web browser:
http://localhost:8080/generate-report?totalPage=3000

- Pada console akan terlihat error “Out of Memory” yang disebabkan karena tidak cukupnya memory yang tersedia saat membuat laporan payslip dengan ukuran melebihi kapasitas memory:

Solusi: Membagi dan Menggabungkan

Untuk mengatasi masalah ini, kita dapat membagi proses pembuatan laporan menjadi bagian yang lebih kecil dan menggabungkannya pada akhirnya. Pendekatan ini memungkinkan kita menghasilkan laporan besar tanpa menghabiskan sumber daya memori. Berikut adalah panduan langkah demi langkah untuk menerapkan solusi ini dalam aplikasi Spring Anda:

Langkah 1: Pembagian Pembuatan Laporan

Membagi Laporan: Bagi laporan besar Anda menjadi bagian yang lebih kecil, misalnya 50 halaman setiap bagian. Pembagian ini dapat dilakukan berdasarkan data, bab, atau pengelompokan logis lainnya yang masuk akal untuk laporan Anda.

Menghasilkan Setiap Bagian: Implementasikan mekanisme untuk menghasilkan setiap bagian laporan secara terpisah. Anda dapat menggunakan JasperReports untuk menghasilkan bagian-bagian laporan secara individu. Berikut adalah contoh potongan kode untuk menghasilkan laporan payslip dengan memecah menjadi beberapa file PDF ukuran kecil:

private void generatePdfReport(Long totalPage, Long limitPage) throws IOException, JRException {
// Load the JasperReport template (JRXML file)
InputStream jrxmlInput = new ClassPathResource("/jasper/my_report.jrxml").getInputStream();
JasperReport jasperReport = JasperCompileManager.compileReport(jrxmlInput);

// Create a temporary folder to store PDF files
File tempFolder = new File(tempFolderPath);
if (!tempFolder.exists()) {
tempFolder.mkdirs();
}

// Create data source (replace YourDataClass and fetchDataForPage with your actual data)
Long totalProcess = totalPage/limitPage;
Long dataLeft = totalPage % limitPage;
if(dataLeft != 0) {
totalProcess += 1;
}

for (Long currentPage = 1L; currentPage <= totalProcess; currentPage++) {
if(currentPage == totalProcess && dataLeft != 0) {
limitPage = dataLeft;
}

List<Payslip> dataForPage = fetchDataForPage(currentPage, limitPage);
JRBeanCollectionDataSource dataSource = new JRBeanCollectionDataSource(dataForPage);

// Create JasperPrint by filling the report with data
JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, null, dataSource);

// Generate individual PDF files and store them in the temporary folder
String pdfFileName = tempFolderPath + "/page_" + getFileCount(currentPage) + ".pdf";
JasperExportManager.exportReportToPdfFile(jasperPrint, pdfFileName);
System.out.println(pdfFileName + " save to temp folder");
}
}

private String getFileCount(Long currentPage) {
if(currentPage > 99) {
return currentPage.toString();
} else if(currentPage > 9) {
return "0" + currentPage;
} else {
return "00" + currentPage;
}
}

// Simulate retrieving data for a specific page
private List<Payslip> fetchDataForPage(Long currentPage, Long limitPage) {
List<Payslip> data = new ArrayList<>();
// Implement logic to retrieve data for the specified page
// For this example, we'll create a list of dummy data
for (Long i = 0L; i < limitPage; i++) {
Long count = (currentPage - 1) * limitPage + i + 1;
data.add(new Payslip(count, count, "Employee " + count, "EM" + count, "Office 1", "Supervisor", "10.000.000", "200.000", "100.000", "0", "50.000", "10.250.000"));
}

return data;
}

Langkah 2: Menggabungkan Bagian-Bagian Laporan

Setelah Anda menghasilkan semua bagian laporan, saatnya menggabungkannya menjadi satu laporan PDF. Anda dapat menggunakan perpustakaan PDF seperti iText untuk melakukannya. Berikut adalah contoh potongan kode untuk menggabungkan bagian-bagian yang dihasilkan:

private class PdfCopyWrapper {
private PdfCopy copy;

public PdfCopy getCopy() {
return copy;
}

public void setCopy(PdfCopy copy) {
this.copy = copy;
}
}

// Merge all PDF files in the temporary folder into one PDF
private OutputStream mergePdfFiles() throws IOException, DocumentException {
OutputStream mergedPdfStream = createFile();
Document document = new Document();
final PdfCopyWrapper copyWrapper = new PdfCopyWrapper();

try {
copyWrapper.setCopy(new PdfCopy(document, mergedPdfStream));
document.open();

Path folderPath = Paths.get(tempFolderPath);
Files.list(folderPath)
.filter(Files::isRegularFile)
.filter(path -> path.toString().toLowerCase().endsWith(".pdf"))
.forEach(pdfFile -> {
PdfReader reader = null;
try {
reader = new PdfReader(pdfFile.toString());

int totalPages = reader.getNumberOfPages();

System.out.println("Start merging file " + pdfFile.getFileName().toString() + " " + (reader.getFileLength()/1024) + " kb");
for (int i = 1; i <= totalPages; i++) {
try {
copyWrapper.getCopy().addPage(copyWrapper.getCopy().getImportedPage(reader, i));
} catch (BadPdfFormatException e) {
System.out.println("Error adding page to merged PDF file: " + e.getMessage());
}
}
System.out.println("Finish merging file " + pdfFile.getFileName().toString());
System.out.println();

reader.close();
} catch (OutOfMemoryError e) {
System.out.println("Out of memory error occurred while merging PDF file: " + pdfFile.toString());
if (reader != null) {
reader.close();
}
} catch (IOException e) {
System.out.println("Error merging PDF file: " + e.getMessage());
if (reader != null) {
reader.close();
}
}
});
} finally {
deleteTempFolder();
if (copyWrapper.getCopy() != null) {
copyWrapper.getCopy().close();
}
if (document != null) {
document.close();
}
if (mergedPdfStream != null) {
mergedPdfStream.close();
}

System.out.println("Finish merging all file");
}

return mergedPdfStream;
}

private FileOutputStream createFile() throws IOException {
File directory = new File(outputFolderPath);
if (!directory.exists()) {
directory.mkdirs();
}

return new FileOutputStream(outputFolderPath + File.separator + fileName);
}

// Delete the temporary folder and its contents
private void deleteTempFolder() {
File tempFolder = new File(tempFolderPath);
File[] files = tempFolder.listFiles();

if (files != null) {
for (File file : files) {
file.delete();
}
}

tempFolder.delete();
}

Buat controller yang memanggil fungsi untuk membuat laporan dengan process memecah file PDF menjadi ukuran kecil lalu menggabungkannya:

package com.andipramana.hugejasperreport.controller;

import java.io.File;
import java.io.IOException;
import java.util.Date;

import net.sf.jasperreports.engine.JRException;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import com.andipramana.hugejasperreport.service.PayslipService;
import com.lowagie.text.DocumentException;

@Controller
public class PayslipController {
@Autowired
private PayslipService payslipService;

private final String fileNameAndPath = "src/main/resources/jasper_output/mergedReport.pdf"; // Change this to your actual temporary folder path
private final String fileName = "mergedReport.pdf";

@ResponseBody
@GetMapping("/generate-payslip-merged")
public ResponseEntity<FileSystemResource> generateReportMerged(@RequestParam Long totalPage, @RequestParam Long limitPage) throws JRException, IOException, DocumentException {
payslipService.generateReportWithPageLimit(totalPage, limitPage);

return downloadMergedPayslip();
}

private ResponseEntity<FileSystemResource> downloadMergedPayslip() {
File file = new File(fileNameAndPath);

// Check if the file exists
if (file.exists()) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + fileName);

// Create a FileSystemResource to wrap the file
FileSystemResource resource = new FileSystemResource(file);

System.out.println("Payslip created successfully at " + new Date());

// Return a ResponseEntity with the file data and headers
return ResponseEntity.ok()
.headers(headers)
.contentLength(file.length())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
} else {
return ResponseEntity.notFound().build();
}
}
}

- Buka folder proyek aplikasi kita di console (Command Prompt, Git Bash, dll.)

- Build aplikasi dengan perintah berikut:
“mvn clean package“ (tanpa tanda kutip)

- Jalanlan perintah berikut untuk menjalankan aplikasi dengan memory terbatas:
“java -Xmx40m -jar ./target/huge-jasper-report-0.0.1-SNAPSHOT.jar” (tanpa tanda kutip, ganti huge-jasper-report-0.0.1-SNAPSHOT dengan nama file jar anda)

- Panggil url berikut pada web browser:
http://localhost:8080/generate-report-merged?totalPage=3000&limitPage=100

Disini kita akan membuat laporan payslip dengan jumlah halaman yang sama yaitu 3000 halaman, akan tetapi kita akan memecahnya menjadi 100 halaman terlebih dahulu, dan menyimpannya di penyimpanan local. Setelah semua halaman dibuat, kita akan menggabungkan file-file pdf tersebut menjadi 1 file pdf dengan 3000 halaman.

Kesimpulan

Dengan membagi proses pembuatan laporan PDF besar menjadi bagian yang lebih kecil dan menggabungkannya, Anda dapat mengelola memori dengan efektif dan menghindari kesalahan “Out of Memory” dalam aplikasi Spring Anda. Untuk full code aplikasi simulasi ini, anda bisa kunjungi halaman berikut:
https://gitlab.com/andipramana/huge-jasper-report

--

--