Tantangan Pemrosesan Data Massal & Kualitas Kode
Dalam ekosistem aplikasi institusi pendidikan, alur kerja operasional sering kali bergantung pada proses impor data spreadsheet berskala besar:
- Penerimaan Peserta Didik Baru (PPDB): Mengunggah berkas Excel berisi 2.000 data siswa baru yang dibagi ke dalam 10 ruang kelas pada awal tahun ajaran.
- Bank Soal CBT Guru: Mengunggah spreadsheet ujian terstandarisasi yang berisi 200 butir soal pilihan ganda lengkap beserta kunci jawaban, bobot nilai, dan penjelasan.
Membaca berkas spreadsheet .xlsx berukuran besar secara naif menggunakan pustaka parser berbasis DOM XML konvensional memakan alokasi RAM yang sangat boros (sering mencapai 50MB–100MB per file). Akibatnya, server rentan mengalami Out-Of-Memory (OOM) crash ketika beberapa guru mengunggah berkas secara bersamaan. Selain itu, jika baris data ke-1.850 memiliki format email yang tidak valid, perulangan naif tanpa transaksi database atomik akan meninggalkan 1.849 akun pengguna yatim (orphaned records) di database tanpa profil siswa yang utuh.
Pada proyek mfarim/fastapi-elearning-react, kami merancang:
- Pipeline Streaming Excel Hemat Memori: Menggunakan metode
iter_rows(values_only=True)dariopenpyxl. - Transaksi Database Batch Atomik: Menjamin integritas data berprinsip all-or-nothing.
- Automasi Pengujian Asinkron Komprehensif: Menggunakan
pytest,pytest-asyncio, danhttpx. - Pipeline CI/CD Otomatis di GitHub Actions: Menjalankan pengujian otomatis terhadap container PostgreSQL 16 dan Redis 7 yang terisolasi.
1. Streaming Pembaca Excel Berkecepatan Tinggi dengan openpyxl
Pada berkas backend/app/services/excel_service.py, service pembaca spreadsheet dirancang untuk kecepatan tinggi dan alokasi memori minimal:
PYTHON
# backend/app/services/excel_service.py
import io
import openpyxl
from typing import List, Dict, Any
class ExcelService:
@staticmethod
def parse_student_excel(file_bytes: bytes) -> List[Dict[str, Any]]:
# Muat workbook langsung dari stream memori tanpa menyentuh disk fisik
wb = openpyxl.load_workbook(filename=io.BytesIO(file_bytes), data_only=True)
sheet = wb.active
students = []
# Header baris 1 yang diharapkan: [Nama, Email, NIS, NISN, Gender, Telepon, Alamat]
# iter_rows(values_only=True) melewati objek pembungkus Cell berat secara efisien
for row_idx, row in enumerate(sheet.iter_rows(values_only=True), start=1):
if row_idx == 1:
continue # Lewati baris header judul kolom
if not row or not any(row):
continue # Lewati baris kosong
name = str(row[0]).strip() if len(row) > 0 and row[0] is not None else None
email = str(row[1]).strip() if len(row) > 1 and row[1] is not None else None
nis = str(row[2]).strip() if len(row) > 2 and row[2] is not None else None
nisn = str(row[3]).strip() if len(row) > 3 and row[3] is not None else None
gender = str(row[4]).strip().upper() if len(row) > 4 and row[4] is not None else "L"
phone = str(row[5]).strip() if len(row) > 5 and row[5] is not None else None
address = str(row[6]).strip() if len(row) > 6 and row[6] is not None else None
# Validasi kolom identitas wajib
if name and email and nis:
students.append({
"name": name,
"email": email,
"nis": nis,
"nisn": nisn,
"gender": gender if gender in ["L", "P", "M", "F"] else "L",
"phone": phone,
"address": address,
})
return students
@staticmethod
def parse_questions_excel(file_bytes: bytes) -> List[Dict[str, Any]]:
wb = openpyxl.load_workbook(filename=io.BytesIO(file_bytes), data_only=True)
sheet = wb.active
questions = []
# Header: [Teks Soal, Tipe, Opsi A, Opsi B, Opsi C, Opsi D, Kunci Jawaban, Bobot, Kesulitan]
for row_idx, row in enumerate(sheet.iter_rows(values_only=True), start=1):
if row_idx == 1:
continue
if not row or not any(row):
continue
q_text = str(row[0]).strip() if len(row) > 0 and row[0] is not None else None
q_type = str(row[1]).strip().lower() if len(row) > 1 and row[1] is not None else "multiple_choice"
opt_a = str(row[2]).strip() if len(row) > 2 and row[2] is not None else None
opt_b = str(row[3]).strip() if len(row) > 3 and row[3] is not None else None
opt_c = str(row[4]).strip() if len(row) > 4 and row[4] is not None else None
opt_d = str(row[5]).strip() if len(row) > 5 and row[5] is not None else None
correct = str(row[6]).strip().upper() if len(row) > 6 and row[6] is not None else "A"
points = int(row[7]) if len(row) > 7 and row[7] is not None and str(row[7]).isdigit() else 1
difficulty = str(row[8]).strip().lower() if len(row) > 8 and row[8] is not None else "medium"
if q_text:
options = []
if opt_a: options.append({"key": "A", "text": opt_a})
if opt_b: options.append({"key": "B", "text": opt_b})
if opt_c: options.append({"key": "C", "text": opt_c})
if opt_d: options.append({"key": "D", "text": opt_d})
questions.append({
"question_text": q_text,
"question_type": q_type,
"options": options if options else None,
"correct_answer": correct,
"points": points,
"difficulty": difficulty if difficulty in ["easy", "medium", "hard"] else "medium",
})
return questions
excel_service = ExcelService()
Mengapa Parameter iter_rows(values_only=True) Sangat Berpengaruh?
Pemanggilan sheet['A1'] standar di openpyxl akan membungkus setiap sel dalam objek Python Cell yang mencatat informasi font, border, warna latar, dan pohon formula. Pada lembar kerja dengan 2.000 baris dan 10 kolom (20.000 sel), alokasi objek representasi sel ini menghabiskan memori lebih dari 60MB RAM.
Dengan menyetel iter_rows(values_only=True), parser XML internal langsung mengembalikan nilai primitif tuple Python (string dan integer murni), memangkas penggunaan memori menjadi kurang dari 12MB untuk keseluruhan proses parsing.
2. Transaksi Database Batch Atomik
Pada berkas backend/app/api/v1/students.py, proses impor data siswa menuntut pembuatan akun User (untuk kredensial login) sekaligus data entitas Student (terkait kelas dan NIS). Seluruh operasi ini wajib dieksekusi dalam satu transaksi database atomik:
PYTHON
# backend/app/api/v1/students.py
@router.post("/import-excel", response_model=ApiResponse[Dict[str, int]])
async def import_students(
classroom_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_roles(["ROLE_ADMIN"]))
):
content = await file.read()
records = excel_service.parse_student_excel(content)
if not records:
raise HTTPException(status_code=400, detail="Berkas Excel tidak memuat data yang valid")
# Ambil role siswa standar
role_stmt = select(Role).where(Role.name == "ROLE_STUDENT")
student_role = (await db.execute(role_stmt)).scalars().first()
default_hashed_pwd = get_password_hash("password")
imported_count = 0
# Blok transaksi atomik
try:
for item in records:
# 1. Buat Akun Pengguna Login
user = User(
name=item["name"],
email=item["email"],
password=default_hashed_pwd,
is_active=True,
roles=[student_role]
)
db.add(user)
await db.flush() # Flush ke DB untuk memperoleh user.id tanpa commit permanen
# 2. Buat Profil Siswa dengan Foreign Key user_id
student = Student(
user_id=user.id,
classroom_id=classroom_id,
nis=item["nis"],
nisn=item.get("nisn"),
gender=item["gender"],
phone=item.get("phone"),
address=item.get("address"),
)
db.add(student)
imported_count += 1
# Commit seluruh 2.000 data sekaligus secara serentak
await db.commit()
except Exception as exc:
await db.rollback()
raise HTTPException(
status_code=400,
detail=f"Impor massal dibatalkan karena pelanggaran batasan data: {str(exc)}"
)
return ApiResponse.ok(
data={"imported": imported_count},
message=f"Berhasil mengimpor {imported_count} siswa ke dalam kelas"
)
Dengan mengeksekusi await db.flush() di dalam perulangan, database mengalokasikan primary key (user.id) yang dibutuhkan oleh foreign key tabel profil tanpa memfinalisasi commit. Jika baris ke-1.999 melanggar duplikasi email atau NIS unik, blok except akan mengeksekusi await db.rollback(), menjamin nol data korup atau parsial.
3. Otomasi Pengujian Asinkron dengan Pytest & Pytest-Asyncio
Dalam arsitektur asinkron, test runner sinkron lama (unittest.TestCase) tidak dapat menjalankan coroutine await. Kami menggunakan Pytest yang dikombinasikan dengan pytest-asyncio dan httpx.AsyncClient.
Pengujian Unit In-Memory untuk Parser Excel (test_excel_import.py)
Di backend/tests/test_excel_import.py, kami menguji ketahanan parser tanpa perlu membaca file fisik dari disk dengan cara membuat binary stream sintetis langsung di memori:
PYTHON
# backend/tests/test_excel_import.py
import io
import openpyxl
from app.services.excel_service import excel_service
def test_excel_student_parsing():
# 1. Bangun spreadsheet sintetis di memori
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["Nama", "Email", "NIS", "NISN", "Gender", "Telepon", "Alamat"])
ws.append(["Ahmad Test", "[email protected]", "20249999", "00999999", "L", "0811111111", "Jakarta"])
stream = io.BytesIO()
wb.save(stream)
stream.seek(0)
# 2. Eksekusi parser
students = excel_service.parse_student_excel(stream.getvalue())
# 3. Verifikasi struktur data yang dihasilkan
assert len(students) == 1
assert students[0]["name"] == "Ahmad Test"
assert students[0]["nis"] == "20249999"
assert students[0]["gender"] == "L"
def test_excel_question_parsing():
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["Question Text", "Type", "Option A", "Option B", "Option C", "Option D", "Correct Answer", "Points", "Difficulty"])
ws.append(["Apa ibukota Indonesia?", "multiple_choice", "Bandung", "Jakarta", "Surabaya", "Medan", "B", 10, "easy"])
stream = io.BytesIO()
wb.save(stream)
stream.seek(0)
questions = excel_service.parse_questions_excel(stream.getvalue())
assert len(questions) == 1
assert questions[0]["question_text"] == "Apa ibukota Indonesia?"
assert questions[0]["correct_answer"] == "B"
assert questions[0]["points"] == 10
assert len(questions[0]["options"]) == 4
Pengujian Rute API Asinkron End-to-End (test_auth.py)
Di backend/tests/test_auth.py, rute autentikasi diuji secara asinkron menggunakan httpx.AsyncClient:
PYTHON
# backend/tests/test_auth.py
import pytest
from httpx import AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_login_success():
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.post(
"/api/v1/auth/login",
json={"email": "[email protected]", "password": "password"}
)
assert response.status_code == 200
json_data = response.json()
assert json_data["success"] is True
assert "token" in json_data["data"]
assert json_data["data"]["user"]["email"] == "[email protected]"
@pytest.mark.asyncio
async def test_unauthorized_access():
async with AsyncClient(app=app, base_url="http://test") as client:
# Panggil endpoint terproteksi tanpa menyertakan bearer token
response = await client.get("/api/v1/admin/dashboard")
assert response.status_code == 401
4. Integrasi Berkelanjutan via GitHub Actions (CI/CD)
Pada berkas .github/workflows/backend-ci.yml, setiap aktivitas push dan pull request otomatis memicu suite pengujian di lingkungan container PostgreSQL dan Redis terisolasi:
YAML
# .github/workflows/backend-ci.yml
name: Backend CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: elearning_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Dependencies
working-directory: backend
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-asyncio httpx coverage
- name: Eksekusi Suite Pytest dengan Coverage
working-directory: backend
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/elearning_test
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test_secret_key_ci_cd
run: |
pytest -v --cov=app --cov-report=term-missing
Ringkasan Rekomendasi Arsitektural
- Selalu gunakan parser mode streaming untuk berkas massal: Kombinasi
openpyxl.load_workbook(data_only=True)dansheet.iter_rows(values_only=True)menghemat memori hingga 80% dibanding pembacaan sel standar. - Jangan commit di dalam perulangan batch: Gunakan
await db.flush()untuk mendapatkan ID otomatis dan lakukan satu kaliawait db.commit()di akhir untuk menjamin integritas data secara penuh. - Gunakan
httpx.AsyncClientuntuk pengujian endpoint: Menguji aplikasi FastAPI secara asinkron murni tanpa memblokir coroutine event loop. - Wajibkan container service nyata di pipeline CI: Menjalankan pengujian otomatis terhadap PostgreSQL dan Redis asli di GitHub Actions mengeliminasi anomali perilaku sebelum kode dirilis ke server produksi.
Pada bab penutup, Part 6, kita akan membahas aspek operasional produksi: Single VPS Production Deployment, Multi-Container Docker Compose, Konfigurasi Nginx SSL Reverse Proxy, dan Skrip Monitoring Pemulihan Otomatis (Auto-Recovery).
