Tạp chí Thợ Code

Xây dựng Web App CheckVar ⭐🐔 bằng ReactJS Vite/ NodeJS/ Python/ MongoDB trên hạ tầng AWS

1. Giới thiệu

Những ngày vừa qua, song hành cùng với tấm lòng cả nước chung tay hướng về miền Bắc thân yêu để khác phục hậu quả cơn bão Yagi, thì cộng đồng mạng cũng sục sôi không kém trước việc kiểm tra sao kê được công bố bởi Mặt trận Tổ quốc Việt Nam.

Yagi là cơn bão mạnh nhất đổ bộ vào Biển Đông trong suốt 30 năm qua. Đây cũng là cơn bão mạnh nhất trên thế giới trong năm 2024 ghi nhận đến thời điểm hiện tại. Cơn bão check var sao kê trên mạng cũng mạnh không kém với nhiều tình huống check var của cộng đồng mạng dành cho các đại Idol lâm vào cảnh “không kịp đào lổ để mà chui”.

Nhận thấy việc tìm kiếm trong PDF dài vài chục nghìn trang với dung lượng tính bằng trăm MB gây rất nhiều khó khăn và bất tiện cho các “thánh soi”, cũng như là nhằm góp một phần nhỏ vào việc tiết kiệm chi phí năng lượng, băng thông Internet quốc gia cũng như áp lực lớn đến hạ tầng Google Drive, chúng tôi đã tranh thủ những ngày mưa gió liên miên không ngớt để xây dựng Web App CheckVar ⭐🐔 bằng ReactJS Vite/ NodeJS/ Python/ MongoDB trên hạ tầng AWS.

Nay xin được chia sẻ cùng quý bạn đọc quá trình xây dựng từ bản vẽ giải pháp cho đến hiện thực hóa những dòng code và các câu chuyên bên lề.

2. Lựa chọn công nghệ

Với nhu cầu:

  • Xử lý file PDF một cách nhanh nhất có thể, trích xuất data, chuyển đổi thành json thì không gì phù hợp hơn ⇒ Python với cú pháp đơn giản, thư viện phong phú cho việc xử lý PDF như PyPDF2, pdf2image, pytesseract.
  • Lưu trữ dữ liệu: nhiều ngân hàng khác nhau, không đồng nhất về mẫu file PDF sao kê > CSDL noSQL > MongoDB Atlas rẻ, có plan Free forever cho phép lưu trữ DB đến 512MB, nếu cần thiết thì nâng cấp lên cũng nhanh và đơn giản. Tích hợp tốt với các dịch vụ đám mây, đặc biệt là AWS.
  • Web App: đơn giản, chịu tải cao, phù hợp thiết bị di động, thiết kế hiện đại ⇒ ReactJS + Vite ung cấp tốc độ build nhanh và hot module replacement hiệu quả. Tổ hợp này cho phép tạo ra ứng dụng web đơn trang (SPA) phản hồi nhanh và trải nghiệm người dùng mượt mà. Dễ dàng tích hợp với các thư viện UI hiện đại để tạo giao diện đẹp và responsive
  • Backend: Nodejs hiệu suất cao với mô hình non-blocking I/O. Sử dụng cùng ngôn ngữ JavaScript như frontend, giúp đồng bộ hóa phát triển. Tích hợp tốt với các dịch vụ serverless của AWS như Lambda
  • Hạ tầng triển khai: các dịch vụ của AWS gồm Route53, Cloudfront, S3, API Gateway, Lamdba…

Thực ra mà nói thì như các startup hay mấy “sản phẩm đu trend” mấy cái mô hình ở trên lúc làm không có ai nghĩ tới hết, chỉ có một mục tiêu duy nhất “tranh thủ thời cơ ra sản phẩm golive nhanh nhất có thể”. Giờ ngồi đây viết lại mới tìm để lại gán vô cho có “lý luận chặt chẽ” thôi, bạn đọc cũng không cần quá hoang mang 😁

画像が読み込まれない場合はページを更新してみてください。

Photo by r/ProgrammerHumor.

3. Thiết kế

3.1. Software Architect Diagram
画像が読み込まれない場合はページを更新してみてください。
3.1.1. Giới thiệu

Chúng ta sẽ chọn mô hình N-Tier hết sức thân thuộc ra đời vào khoảng năm 2000 và trở nên phổ dụng nhờ sự phát triển của các ứng dụng web thời bấy giờ.

  • Tiers: Là các thành phần tách biệt nhau về mặt vật lý chạy trên các máy chủ riêng biệt, hoặc các services khác nhau trên cloud provider. Có thể gọi nhau trực tiếp hoặc sử dụng Queue-Based Architecture.
  • Layers: Các layer được chia ra nhằm phân tách rõ ràng các nhiệm vụ/ các phạm vi xử lý trong chương trình. Layer cao sử dụng được các dịch vụ định nghĩa trong layer thấp hơn nhưng ngược lại thì không. VD: Từ giao diện (Presentation Layer) sẽ gọi được các endpoint cung cấp dữ liệu.

N-Tier Architect sẽ giúp tách bạch rõ ràng về chức năng của từng layer, tăng tính tái sử dụng, mỡ rộng tốt hơn. Tuy nhiên, nếu vẫn theo các tiêu chuẩn truyền thống thì kiến trúc này cũng có những bất lợi như: performance không tốt nhất là ở tầng Data Access Layer chuyên thực hiện việc CURD, khó khăn trong việc scale.

May mắn thay những điều trên có thể dễ dàng xử lý nhờ các dịch vụ sẵn có của AWS, đặc biệt là Lambda giúp chúng ta nhanh chóng thiết kế ứng dụng theo các tiêu chuẩn microservice.

3.1.2. Tại sao dùng ReactJS + Vite?
  • Một là ReactJS: A Modern Web Development Framework
  • Hai là Vite giúp tiết kiệm nhiều thời gian trong quá trình phát triển mỗi lần cần start development environment vì Vite chỉ xử lý những module cần thiết, không như Webpack. Hỗ trợ các trend hot hit: Hot Module Replacement, Native ES modules. Xem thêm tại:
  • Ba là có thể kết hợp với các CSS framework khác dễ dàng như là Tailwind CSS.
画像が読み込まれない場合はページを更新してみてください。

Soure: vitejs.dev

3.2. Thiết kế hệ thống
画像が読み込まれない場合はページを更新してみてください。

4. Lập trình ứng dụng

4.1. Presentation Layer

Chuẩn bị:

NodeJS version 18+ or 20+

Chạy lệnh sau:

npm create vite@latest
画像が読み込まれない場合はページを更新してみてください。
画像が読み込まれない場合はページを更新してみてください。

SWC là Speedy Web Compiler một trình biên dịch TypeScript / JavaScript viết bằng Rust siêu nhanh.

Done, bạn đã tạo xong một dự án mới. Để kiểm tra xem mọi thứ có hoạt động hay không trước khi bắt đầu code, hãy chạy lệnh:

cd vite-project #your project name
npm install
npm run dev

Cài đặt các components cần thiết.

npm install @nextui-org/react framer-motion
npm install -D tailwindcss postcss autoprefixer

Tham khảo:

Sau khi cài đặt xong nhớ xóa dòng "type": "module", trong file package.jon để tránh lỗi

node:internal/process/promises:394 triggerUncaughtException(err, true /* fromPromise */);
[Failed to load PostCSS config: Failed to load PostCSS config (searchPath: /home/checkvar/src/frontend): [ReferenceError] module is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file extension and '/home/checkvar/src/frontend/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
ReferenceError: module is not defined in ES module scope
画像が読み込まれない場合はページを更新してみてください。

Ta tiến hành code, bạn có thể tham khảo mẫu code bên dưới. Nhớ đổi tên file sample.env thành .env.

VITE_APP_ENV=development
VITE_APP_TITLE="Font Bạt Detector v0.1 beta"
VITE_APP_API_BASE_URL=https://api.example.com

Tạo file tailwind.config.js

const { nextui } = require("@nextui-org/react");

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./*.{js,ts,jsx,tsx}",
    "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  darkMode: "class",
  plugins: [nextui()],
};

Tạo file postcss.config.js

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

Tạo file styles.css

@tailwind base;
@tailwind components;
@tailwind utilities;

File App.jsx

import React, { useState, useEffect } from "react";
import {Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, Pagination, Spinner, getKeyValue} from "@nextui-org/react";
import useSWR from "swr";
import {Input} from "@nextui-org/react";
import {Button, ButtonGroup} from "@nextui-org/button";

// const [searchTerm, setSearchTerm] = useState("");
const fetcher = (...args) => fetch(...args).then((res) => res.json());


export default function App() {
  const [page, setPage] = React.useState(1);
  const [searchTerm, setSearchTerm] = useState('');
  const [searchState, setSearchState] = useState('');
  const apiUrl = import.meta.env.VITE_APP_API_BASE_URL;
  // console.log('apiUrl', apiUrl);
  
  var dataUrl = `${apiUrl}?page=${page}`;
  // dataUrl = `https://swapi.py4e.com/api/people?page=${page}`;

  if (searchState == true && searchTerm != '') {
    dataUrl = `${apiUrl}?fontbat=${searchTerm}&page=${page}`;
    setSearchState(false);
  }

  var { data, isLoading } = useSWR(dataUrl, fetcher, {
    keepPreviousData: true,
  });

  const rowsPerPage = 30;

  var pages = React.useMemo(() => {
    return data?.count ? Math.ceil(data.count / rowsPerPage) : 0;
  }, [data?.count, rowsPerPage]);

  var loadingState = isLoading || data?.results.length === 0 ? "loading" : "idle";

  // console.log('dataresults',data.results);
  // console.log('url', window.location.href);
  var txtProcessBy = '';
    txtProcessBy = (<span style={{ fontSize: "small" }}>Xử lý bởi <a href="https://thocode.com" target="_blank">ThoCode.com</a> trên Dữ liệu của <a href="https://facebook.com/mttqvietnam" target="_blank" rel="nofollow noopener noreferrer">MTTQVN</a></span>)

  function test(){
    alert('Tính năng tìm kiếm hiện đang tắt');
  }

  const searchFunction = (event) => {
    console.log('e', event.target.value);
  }


  const handleSubmit = (event) => {
    event.preventDefault()
  }

  const handleKeyDown = (event) => {
    console.log('searchTerm', event.target.value);
    if(event.key === "Enter" && searchTerm != '')
    {
      setSearchState(true);
    }
  }

  useEffect(() => {
    document.title = import.meta.env.VITE_APP_TITLE;
  }, []); 

  return (

    <div style={{ width: "100%"}}>
      <h1>
        {import.meta.env.VITE_APP_TITLE}
        <span style={{ paddingLeft: 5, fontSize: "small", fontStyle: "italic"}}></span>
      </h1>
      {txtProcessBy}
      <div style={{ fontSize: "8pt"}}>
        <ul>
          <li><a href="/data/Thong tin ung ho qua TSK VCB 0011001932418 tu 01.09 den10.09.2024.zip#"></a>Thong tin ung ho qua TSK VCB 0011001932418 tu 01.09 den10.09.2024.pdf</li>
        </ul>
      </div>

      <div style={{ paddingBottom: 5 }} className="flex w-full flex-wrap md:flex-nowrap gap-4">
        <form action="/" onSubmit={handleSubmit}>
        <Input isClearable type="search" placeholder="Tìm kiếm"
          onClear={() => console.log("input cleared")}
          onChange={(e) => {
            // searchFunction(e);
            setSearchTerm(e.target.value);
            console.log("onchange")
          }}
          onKeyDown={handleKeyDown}
          // onValueChange={() => console.log("onvaluechange")}
          // onSubmit={() => console.log("onsubmit")}
        />

        </form>
      </div>


      <Table
        aria-label="Example table with client async pagination"
        bottomContent={
          pages > 0 ? (
            <div className="flex w-full justify-center">
              <Pagination
                isCompact
                showControls
                showShadow
                color="primary"
                page={page}
                total={pages}
                onChange={(page) => setPage(page)}
              />
            </div>
          ) : null
        }
      >
        <TableHeader>
          <TableColumn key="date">Ngày GD</TableColumn>
          <TableColumn key="code">Mã GD</TableColumn>
          <TableColumn key="amount">Số tiền</TableColumn>
          <TableColumn key="notes">Note</TableColumn>
        </TableHeader>
        <TableBody
          items={data?.results ?? []}
          loadingContent={<Spinner />}
          loadingState={loadingState}
        >
          {(item) => (
            <TableRow key={item?._id}>
              {(columnKey) => <TableCell>{getKeyValue(item, columnKey)}</TableCell>}
            </TableRow>
          )}
        </TableBody>
      </Table>

      
    </div>
  );
}

File main.jsx


import React from "react";
import ReactDOM from "react-dom/client";
import { NextUIProvider } from "@nextui-org/react";
import App from "./App";
import "./styles.css";

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <NextUIProvider>
      <div style={{ padding: 10 }} className="w-screen  justify-center">
        <App />
      </div>  
    </NextUIProvider>
  </React.StrictMode>
);

Chạy thử

npm run dev

Build ứng dụng để chuẩn bị cho bước upload lên S3

npm run build

4.2. Business Layer

Chúng ta có 4 module cần giải quyết, chia làm 2 nhóm chính: Query - Data Visulization & PDF split & data extraction - Data cleaner & import

Để tiện việc theo dõi, code của các phần liên quan đến Back-end serverless sẽ được trình bày tại mục 4.2.

5. Dựng hệ thống

5.1. Luồng xử lý Datasoure: S3 - Lambda - MongoDB

Một luồng xử lý sẽ gồm các thành phần như bên dưới: S3 Bucket chứa data source > Lambda function xử lý thông tin > Trigger nhận event từ S3 Bucket.

画像が読み込まれない場合はページを更新してみてください。

Source: AWS Documentation

5.1.1. S3 Bucket chứa Data source

Vào AWS Console, đi đến Amazon S3 > Buckets > Create bucket. Các cấu hình khác vẫn giữ nguyên như mặc định. Kéo đến cuối trang và bấm Create bucket.

画像が読み込まれない場合はページを更新してみてください。

Chú ý: Trong ví dụ này chúng ta đang tạo S3 Bucket ở AWS Region: Asia Pacific (Singapore) ap-southeast-1, trong các bước tiếp theo chúng ta sẽ cần tạo Lambda function cũng nằm cùng một region này.

5.1.2. Tạo policy phân quyền cho Lambda function
画像が読み込まれない場合はページを更新してみてください。

Source: AWS Documentation

AWS Console > IAM > Policies bấm nút Create policy. Chọn JSON và định nghĩa policy như bên dưới.

画像が読み込まれない場合はページを更新してみてください。
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:PutLogEvents",
                "logs:CreateLogGroup",
                "logs:CreateLogStream"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": "arn:aws:s3:::*/*"
        }
    ]
}

Bấm Next, đặt tên cho policy. Sau đó bấm Create policy để hoàn tất.

画像が読み込まれない場合はページを更新してみてください。

5.1.3. Tạo role thực thi cho Lambda function

Vẫn tại AWS Console > IAM > chuyển sang Roles, bấm nút Create role. Trusted entity type chọn AWS service, Service or use case chọn Lambda. Bấm nút Next.

画像が読み込まれない場合はページを更新してみてください。

Trong màn hình Add permissions, tìm kiếm policy bạn vừa tạo xong ở trên và chọn.

画像が読み込まれない場合はページを更新してみてください。

Bấm nút Next, đặt tên cho role và bấm nút Create role để hoàn tất.

画像が読み込まれない場合はページを更新してみてください。

5.1.4. Tạo Lambda > Functions xử lý dữ liệu
  • Bấm nút Create function
  • Chọn Author from scratch
  • Điền tên function vào Function name.
  • Chọn Python 3.12 trong mục Runtime.
  • Trong phần Change default execution role chọn Use an existing role sau đó tìm role vừa tạo ở trên, trong bài này sẽ là checkvar-lambda-s3-trigger.
  • Bấm nút Create function
画像が読み込まれない場合はページを更新してみてください。

Trong cửa sổ Code source, tiến hành viết code để xử lý dữ liệu PDF khi được upload trên S3 như sau, nhớ bấm nút Deploy để hoàn tất.

画像が読み込まれない場合はページを更新してみてください。
# Phong Bat Detector 2

import boto3
import os
import sys
import uuid
from urllib.parse import unquote_plus
#from PIL import Image
#import PIL.Image

import os
import pdfplumber
import re
import PyPDF2
import json

from pymongo import MongoClient

print('Loading function')

s3_client = boto3.client('s3')

def split_pdf(pdf_path, output_dir, num_pages_per_file=100):
  """
  Hàm tách PDF thành nhiều file nhỏ hơn.

  Args:
      pdf_path: Đường dẫn đến tệp PDF.
      output_dir: Thư mục để lưu các file PDF nhỏ.
      num_pages_per_file: Số trang mỗi file PDF nhỏ (tùy chọn).
  """
  if not os.path.exists(output_dir):
        os.makedirs(output_dir)

  with open(pdf_path, 'rb') as pdf_file:
    pdf_reader = PyPDF2.PdfReader(pdf_file)
    num_pages = len(pdf_reader.pages)

    for i in range(0, num_pages, num_pages_per_file):
      pdf_writer = PyPDF2.PdfWriter()
      for page_num in range(i, min(i + num_pages_per_file, num_pages)):
        pdf_writer.add_page(pdf_reader.pages[page_num])
      output_filename = f"{output_dir}/part_{i // num_pages_per_file + 1}.pdf"
      with open(output_filename, 'wb') as output_file:
        pdf_writer.write(output_file)


def merge_json_files(json_files, output_file):
  """
  Hàm kết hợp các file JSON thành một file JSON lớn.

  Args:
      json_files: Danh sách các file JSON cần kết hợp.
      output_file: Tên file JSON để lưu kết quả.
  """
  all_data = []
  for json_file in json_files:
    with open(json_file, "r") as f:
      data = json.load(f)
      all_data.extend(data)

  # Lưu JSON vào file
  with open(output_file, "w") as f:
    json.dump(all_data, f, indent=4)


def extract_tables_to_json(pdf_path, output_file="table_data.json"):
  """
  Hàm trích xuất dữ liệu từ tất cả bảng trong PDF và lưu vào file JSON.

  Args:
      pdf_path: Đường dẫn đến tệp PDF.
      output_file: Tên file JSON để lưu kết quả (tùy chọn).
  """
  json_data = []
  with pdfplumber.open(pdf_path) as pdf:
    for page_num in range(len(pdf.pages)):
      page = pdf.pages[page_num]
      tables = page.extract_tables()
      for table_data in tables:
        # Bỏ qua header (2 dòng đầu tiên)
        for row in table_data[1:]:
          # Kiểm tra nếu dòng có dữ liệu (không rỗng)
          if any(cell.strip() for cell in row):
            json_data.append({
              "date": row[1].strip(),
              "amount": row[4].strip().replace(".","").replace(",00","").replace(" ",""),
              "notes": row[5].strip(),
              "code": None,
              "bankAccountNumber": row[2].strip(),
              "source": "bidv-042209"
            })

  # Lưu JSON vào file
  with open(output_file, "w") as f:
    json.dump(json_data, f, indent=4)
    # json.dump(split_transactions(json_data), f, indent=4)


def import_json_to_mongodb(json_file, db_name, collection_name, mongo_uri="mongodb://localhost:27017/"):
    """
    Hàm để import dữ liệu từ file JSON vào MongoDB.

    Args:
        json_file: Đường dẫn đến file JSON cần import.
        db_name: Tên database trên MongoDB.
        collection_name: Tên collection trên MongoDB.
        mongo_uri: URI kết nối MongoDB (mặc định là localhost).
    """
    # Kết nối tới MongoDB
    client = MongoClient(mongo_uri)
    db = client[db_name]
    collection = db[collection_name]

    # Đọc file JSON
    with open(json_file, "r") as f:
        data = json.load(f)

    # Import dữ liệu vào collection
    if isinstance(data, list):
        # Nếu file JSON chứa một danh sách các documents
        collection.insert_many(data)
    else:
        # Nếu file JSON chỉ chứa một document
        collection.insert_one(data)

    print(f"Imported {len(data)} records into {db_name}.{collection_name}")



def lambda_handler(event, context):
    print("eventRecords", event['Records'][0]['s3'])
    
    for record in event['Records']:
        bucket = record['s3']['bucket']['name']
        key = unquote_plus(record['s3']['object']['key'])
        print("key", key)
        tmpkey = key.replace('/', '')
        print("tmpkey", tmpkey)
        #download_path = '/tmp/{}{}'.format(uuid.uuid4(), tmpkey)
        download_path = '/tmp/{}'.format(tmpkey)
        upload_path = '/tmp/resized-{}'.format(tmpkey)
        s3_client.download_file(bucket, key, download_path)
        print("download_path", download_path)
        print("upload_path", upload_path)
        print("record", record)
        file_size = os.path.getsize(download_path)
        print(f'Dung lượng file download từ S3: {file_size} bytes')

        # resize_image(download_path, upload_path)
        # s3_client.upload_file(upload_path, '{}-resized'.format(bucket), 'resized-{}'.format(key))
        # s3_client.upload_file(download_path, bucket, 'resized-{}'.format(key))
        
        # Kiểm tra và xử lý file PDF
        file_extension = os.path.splitext(key)[1].lower()
        file_name = os.path.splitext(key)[0]

        if file_extension == '.pdf':
            print("Đây là file PDF.")
            pdf_path = "/tmp/" + file_name + ".pdf"
            output_dir = "/tmp/" + file_name
            output_file = "/tmp/" + file_name + ".json"
            
            # Tách PDF
            split_pdf(pdf_path, output_dir)
            
            # Xử lý từng file PDF nhỏ
            json_files = []
            for filename in os.listdir(output_dir):
                if filename.endswith(".pdf"):
                    pdf_file = os.path.join(output_dir, filename)
                    json_file = os.path.splitext(pdf_file)[0] + ".json"
                    extract_tables_to_json(pdf_file, json_file)
                    json_files.append(json_file)
            
            
            # Kết hợp các file JSON
            merge_json_files(json_files, output_file)
            file_size = os.path.getsize(output_file)
            print(f'Dung lượng file JSON vừa tạo: {file_size} bytes')
            
            # Import dữ liệu vào MongoDB
            json_file = output_file
            db_name = "checkvar"
            collection_name = "trans"
            mongo_uri = os.environ['MONGO_URI']
            import_json_to_mongodb(json_file, db_name, collection_name, mongo_uri)
        else:
            print("Đây không phải là file PDF.")

Trong trường hợp may mắn nhất thì bạn bấm Test và mọi thứ sẽ hoạt động trơn tru. Tuy nhiên đáng buồn là thường thì không vì môi trường trên Lambda sẽ không giống với môi trường phát triển trên local nên bạn cần phải tiếp tục thực hiện thêm các bước bên dưới.

Một số lỗi chắc chắn sẽ xảy ra 😀

[ERROR] Runtime.ImportModuleError: Unable to import module 'lambda_function': No module named 'pdfplumber'

Tạo các dependencies và đưa vào Lambda Layer

pip install -r requirements.txt -t .\python --no-user

Riêng thư viện cryptography sử dụng một số runtime của hệ điều hành nên cần phải đảm bảo OS bạn build giống với OS mà Lambda sử dụng để invoke function. Tham số --platform manylinux2014_x86_64 sẽ giúp giải quyết vấn đề này.

[ERROR] Runtime.ImportModuleError: Unable to import module 'lambda_function': cannot import name 'exceptions' from 'cryptography.hazmat.bindings._rust' (unknown location)
 pip install --platform manylinux2014_x86_64 cryptography --only-binary=:all: --upgrade --target=build/package -t .\python --no-user

Nén toàn bộ thư mục python dưới dạng .zip, chú ý file zip phải có cấu trúc bao gồm cả thư mục python này.

Từ AWS Console vào lambda > Additional resources > Layers

画像が読み込まれない場合はページを更新してみてください。

Quay lại Lambda function vừa tạo ở trên, tại tab Code, kéo xuống dưới cùng bạn sẽ thấy mục Layer tương tự như hình.

画像が読み込まれない場合はページを更新してみてください。

Bấm nút Add a layer.

画像が読み込まれない場合はページを更新してみてください。

5.1.5. Cấu hình Trigger gọi Lambda function khi có dữ liệu được upload lên S3 Bucket
画像が読み込まれない場合はページを更新してみてください。

Source: AWS Documentation

Trong màn hình Function overview, bấm nút Add trigger.

画像が読み込まれない場合はページを更新してみてください。
  • Chọn S3.
  • Chọn tên Bucket vửa tạo tại phần Bucket.
  • Trong Event types chọn All object create events.
  • Nhớ check vô Recursive invocation.
  • Bấm nút Add.
画像が読み込まれない場合はページを更新してみてください。

Trong trường hợp bạn gặp lỗi tương tự bên dưới thì hãy kiểm tra lại execution policy.

An error occurred (AccessDenied) when calling the GetObject operation: User: arn:aws:sts::xxx:assumed-role/checkvar-transaction-data-ETL-role-q08z2ki5/checkvar-transaction-data-ETL is not authorized to perform: s3:GetObject on resource: "arn:aws:s3:::checkvar-datasource-bucket/THONG TIN UNG HO QUA STK BIDV 1261122666 TU NGAY 18.9 DEN 19.09.2024.pdf" because no identity-based policy allows the s3:GetObject action

5.1.6. Khởi tạo MongoDB
画像が読み込まれない場合はページを更新してみてください。

  • Bấm nút Create tại màn hình project Overview
画像が読み込まれない場合はページを更新してみてください。

  • Trong màn hình Deploy your cluster, thiết lập các tùy chọn tương tự như trong ảnh để tận dụng Free plan của MongoDB.
画像が読み込まれない場合はページを更新してみてください。

  • Sau khi khởi tạo thành công, màn hình tương tự xuất hiện. Bạn hãy tiến hành khởi tạo database user bằng cách nhập thông tin và bấm nút Create Database User. Tiếp đến chọn Choose a connection method.
画像が読み込まれない場合はページを更新してみてください。

  • Chọn Drivers tại mục Connect to your application để lấy thông tin chuỗi kết nối đến database.
画像が読み込まれない場合はページを更新してみてください。
画像が読み込まれない場合はページを更新してみてください。

Tại màn hình quản lý database, bạn vào Security > Network Access để cho phép kết nối đến MongoDB từ những địa chỉ IP nhất định hoặc không giới hạn như trong hình bên dưới.

[ERROR] ServerSelectionTimeoutError: SSL handshake failed: checkvarcluster0-shard-00-02.0vqt7.mongodb.net:27017: [SSL: TLSV1_ALERT_INTERNAL_ERROR] tlsv1 alert internal error (_ssl.c:1133) (configured timeouts: socketTimeoutMS: 20000.0ms, connectTimeoutMS: 20000.0ms)
画像が読み込まれない場合はページを更新してみてください。

5.2. Luồng xử lý cho người dùng: Certificate Manager (ACM) - Route53 - Cloudfront - S3 - Lambda - MongoDB

5.2.1. Certificate Manager (ACM)

Có một lưu ý nhỏ: để có thể sử dụng SSL được trên Cloudfront thì bạn cần phải tạo Certificate tại region US East (N. Virginia) us-east-1

Tại AWS Console > AWS Certificate Manager, bấm nút Request a certificate.

画像が読み込まれない場合はページを更新してみてください。

Một certificate có thể sử dụng với nhiều domain, rất thuận tiện cho các trường hợp cần dùng domain alias. Với phương pháp xác thực Validation method hãy đảm bảo chọn DNS validation, rất tiện dụng và nhanh chóng, chúng tôi đã thử nghiệm với cả 2 domain một dùng DNS services là Route 53, một dùng DNS services của một nhà cung cấp tên miền tại Vietnam, quá trình xác thực hoàn tất diễn ra chỉ trong vòng vài phút.

画像が読み込まれない場合はページを更新してみてください。

5.2.2. Tạo S3 Bucket chứa website

Tiến hành tạo S3 Bucket tương tự như với Bucket chứa Data source. Sau khi xong, tiến hành cấu hình tiếp theo:

  • Chọn tab Properties
  • Kéo đến cuối trang, bấm nút Edit ở phần Static website hosting
  • Chọn Enable và cấu hình như minh họa, sau đó bấm nút Save change để lưu các thay đổi
  • Sau khi hoàn tất bạn sẽ thấy địa chỉ truy cập tại mục Bucket website endpoint
画像が読み込まれない場合はページを更新してみてください。

Tiếp đến,

  • Chọn tab Permissions
  • Bấm nút Edit ở mục Block public access (bucket settings)
  • Bỏ chọn Block all public access
  • Bấm nút Save changes để lưu lại các thay đổi.
画像が読み込まれない場合はページを更新してみてください。
  • Bấm nút Edit ở phần Bucket policy và điền nội dung như bên dưới, bấm nút Save changes để lưu lại các thay đổi.
  • Policy này cho phép mọi người trên Internet đọc bất kỳ file nào trong bucket được chỉ định (your-bucket-name). Đây là một policy thường được dùng để làm public các đối tượng trong S3.
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::your-bucket-name/*"
        }
    ]
}

Tiến hành upload toàn bộ nội dung thư mục ứng dụng đã build, tiến hành test với địa chỉ Bucket website endpoint.

5.2.3. Lambda function xử lý các request từ Front-end

Tương tự Lambda cho Data source, chúng ta tạo Lambda để xử lý các request từ người dùng để lấy và trả dữ liệu từ MongoDB (được import ở 5.1.4). Do không cần giao tiếp với các hệ thống khác trong AWS nên có thể không cần quan tâm đến default execution role.

File index.js

// Import the MongoDB driver
const MongoClient = require("mongodb").MongoClient;
//import { MongoClient } from "mongodb";

// Define our connection string. Info on where to get this will be described below. In a real world application you'd want to get this string from a key vault like AWS Key Management, but for brevity, we'll hardcode it in our serverless function here.
const MONGODB_URI = process.env.MONGODB_URI;

// Once we connect to the database once, we'll store that connection and reuse it so that we don't have to connect to the database on every request.
let cachedDb = null;

async function connectToDatabase() {
  if (cachedDb) {
    return cachedDb;
  }

  // Connect to our MongoDB database hosted on MongoDB Atlas
  const client = await MongoClient.connect(MONGODB_URI);

  // Specify which database we want to use
  const db = await client.db("checkvar");

  cachedDb = db;
  return db;
}

exports.handler = async (event, context) => {


  // Lấy query từ event
  var searchQuery = ''
  if(event.queryStringParameters && event.queryStringParameters.fontbat)
  {
    searchQuery = event.queryStringParameters.fontbat;
  }
  
  var page = 1
  if(event.queryStringParameters && event.queryStringParameters.page)
  {
    page = event.queryStringParameters.page;
  }

  /* By default, the callback waits until the runtime event loop is empty before freezing the process and returning the results to the caller. Setting this property to false requests that AWS Lambda freeze the process soon after the callback is invoked, even if there are events in the event loop. AWS Lambda will freeze the process, any state data, and the events in the event loop. Any remaining events in the event loop are processed when the Lambda function is next invoked, if AWS Lambda chooses to use the frozen process. */
  context.callbackWaitsForEmptyEventLoop = false;

  // Get an instance of our database
  const db = await connectToDatabase();

  // 
  const limitDocument = 30;
  const pageSkip = limitDocument * (page - 1);
  var totalDocument = 0;
  totalDocument = searchQuery === '' ?  await db.collection("trans").estimatedDocumentCount() : await db.collection("trans").countDocuments({ notes: {$regex: searchQuery, $options: 'i' }});
  const trans = searchQuery === '' ?  await db.collection("trans").find({}).skip(pageSkip).limit(limitDocument).toArray() : await db.collection("trans").find({ notes: {$regex: searchQuery, $options: 'i' }}).skip(pageSkip).limit(limitDocument).toArray();

  const response = {
    statusCode: 200,
    headers: {
                "Access-Control-Allow-Origin": "*",
                // "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With, access-control-allow-headers,access-control-allow-methods,access-control-allow-origin",
                "Access-Control-Allow-Headers": "*",
                "Access-Control-Allow-Methods": "OPTIONS,POST,GET"
            },
    body: JSON.stringify({
        count: totalDocument,
        currentPage: page,
        next: '?page=3',
        previous: '?page=1',
        results: trans,
        status: "get_data_success"
    }),
  };

  return response;
};

5.2.4. Cấu hình API Gateway

Trên nguyên tắc, nếu bị hối ra sản phẩm gấp quá hay chỉ cần làm demo chạy cho Chủ tịch coi thì vẫn có thể sử dụng Function URL, mà có lần ThoCode đã đăng tải tại đây

Vẫn tương tự như ở phần 5.1.5. tạo trigger khi có S3 event, chúng ta quay lại màn hình Function overview, bấm nút Add trigger. Và chọn API Gateway thay vì S3 rồi tiến hành cấu hình như hình dưới, hệ thống sẽ tự động tạo mới một API Gateway đồng thời gán nó vào Trigger của Lambda function này.

画像が読み込まれない場合はページを更新してみてください。
5.2.5. Cấu hình Cloudfront

Thật ra thì bạn có thể chọc thẳng từ Route 53 đến S3 Bucket website endpoint luôn, nhưng có thểm Cloudfront sẽ giúp rất nhiều:

  • Giảm độ trễ (latency) và tăng tốc độ tải trang, mang lại trải nghiệm nhanh hơn cho người dùng trên toàn thế giới. Thay vì mỗi request đều phải chạy vòng vèo sang tận Singapore là nơi gần nhất để lấy data thì giờ có thể được trả kết quả về từ pop tại HNI, SGN.
  • Dễ dàng cấu hình TTL cho từng loại nội dung (hình ảnh, CSS, JavaScript, v.v.) để tối ưu hóa hiệu suất cache.
  • CloudFront cung cấp SSL/TLS encryption cho trang web của bạn. Điều này cho phép bạn mã hóa toàn bộ dữ liệu truyền tải giữa người dùng và CloudFront, tạo ra kênh bảo mật cho website.
  • Origin Access Identity (OAI): Bạn có thể thiết lập CloudFront với OAI để đảm bảo chỉ CloudFront mới có quyền truy cập vào nội dung của S3 bucket, ngăn chặn truy cập trực tiếp từ người dùng đến bucket.
  • WAF (Web Application Firewall): CloudFront hỗ trợ tích hợp với AWS WAF, giúp bảo vệ trang web khỏi các tấn công DDoS, SQL Injection, Cross-site Scripting (XSS), và các lỗ hổng web khác.
  • Khi sử dụng CloudFront, băng thông yêu cầu tới S3 sẽ giảm nhờ tính năng caching tại các edge locations. Các yêu cầu chỉ cần được gửi đến S3 khi nội dung hết hạn hoặc chưa có trong cache, giúp giảm đáng kể chi phí truyền tải dữ liệu từ S3. Data Transfer Out từ CloudFront cũng rẻ hơn so với từ S3 trực tiếp ra Internet.

Trên AWS Console > CloudFront > Distributions bấm nút Create distribution.

  • Chọn S3 bucket chứa website trong mục Origin domain.
  • Nếu đã cấu hình thành công Static website hosting thì Cloudfront sẽ tự động hỏi như hình minh họa, hãy chọn Use website endpoint.
画像が読み込まれない場合はページを更新してみてください。

  • Viewer protocol policy: Redirect HTTP to HTTPS
  • Allowed HTTP methods: GET, HEAD, OPTIONS
  • Bấm nút Create distribution để lưu các thay đổi.
画像が読み込まれない場合はページを更新してみてください。

5.2.6. Cơm thêm bao no
  • S3 Bucket for logging
  • S3 Gateway endpoint
  • Lambda function for notification service
  • CloudWatch
  • Simple Notification Service
  • Slack Webhook

Ngoài các services có thể bổ sung để tăng tính tiện dụng thì chúng ta còn có thể refactor code. Và đây là code của 5.1.4.

画像が読み込まれない場合はページを更新してみてください。

lambda_function.py

import boto3
import os
import sys
import uuid
from urllib.parse import unquote_plus


from processors.file_processor import FileProcessorFactory, FileProcessor
from utils.mongo_utils import import_json_to_mongodb

print('Loading function')
s3_client = boto3.client('s3')

def lambda_handler(event, context):
    print("eventRecords", event['Records'][0]['s3'])
    for record in event['Records']:
        bucket = record['s3']['bucket']['name']
        key = unquote_plus(record['s3']['object']['key'])
        download_path = '/tmp/' + key.replace('/', '')
        s3_client.download_file(bucket, key, download_path)

        # Determine file type and process
        file_extension = os.path.splitext(key)[1].lower()
        
        file_size = os.path.getsize(download_path)
        print(f'Dung lượng file download từ S3: {file_size} bytes')
        print('file_extension', file_extension)

        processor = FileProcessorFactory.get_processor(file_extension)
        processed_file = processor.process(download_path)
        print('processed_file', processed_file)

        # Further operations, like MongoDB import
        if file_extension == '.pdf':
            mongo_uri = os.environ['MONGO_URI']
            import_json_to_mongodb(processed_file, "checkvar", "trans", mongo_uri)

processors/file_processor.py

from abc import ABC, abstractmethod

class FileProcessor(ABC):
    @abstractmethod
    def process(self, file_path):
        pass

class FileProcessorFactory:
    @staticmethod
    def get_processor(file_extension):
        #print('FileProcessorFactory', file_extension)
        if file_extension == '.pdf':
            from processors.pdf_processor import PDFProcessor
            return PDFProcessor()
        elif file_extension == '.json':
            from processors.json_processor import JSONProcessor
            return JSONProcessor()
        else:
            raise ValueError(f"Unsupported file type: {file_extension}")

processors/json_processor.py

processors/pdf_processor.py

import os
from utils.pdf_utils import split_pdf, extract_tables_to_json, merge_json_files
from processors.file_processor import FileProcessor

class PDFProcessor(FileProcessor):
    def process(self, file_path):
        output_dir = os.path.splitext(file_path)[0]
        output_file = os.path.splitext(file_path)[0] + ".json"
        print('PDFProcessor output_dir', output_dir)

        split_pdf(file_path, output_dir)

        json_files = []
        print('os.listdir(output_dir)', os.listdir(output_dir))
        for filename in os.listdir(output_dir):
            if filename.endswith(".pdf"):
                pdf_file = os.path.join(output_dir, filename)
                json_file = os.path.splitext(pdf_file)[0] + ".json"
                extract_tables_to_json(pdf_file, json_file)
                json_files.append(json_file)

        print('PDFProcessor json_files', json_files)
        merge_json_files(json_files, output_file)
        return output_file

utils/mongo_utils.py

from pymongo import MongoClient
import json

def import_json_to_mongodb(json_file, db_name, collection_name, mongo_uri="mongodb://localhost:27017/"):
    """
    Hàm để import dữ liệu từ file JSON vào MongoDB.

    Args:
        json_file: Đường dẫn đến file JSON cần import.
        db_name: Tên database trên MongoDB.
        collection_name: Tên collection trên MongoDB.
        mongo_uri: URI kết nối MongoDB (mặc định là localhost).
    """
    # Kết nối tới MongoDB
    client = MongoClient(mongo_uri)
    db = client[db_name]
    collection = db[collection_name]

    # Đọc file JSON
    with open(json_file, "r") as f:
        data = json.load(f)

    # Import dữ liệu vào collection
    if isinstance(data, list):
        # Nếu file JSON chứa một danh sách các documents
        collection.insert_many(data)
    else:
        # Nếu file JSON chỉ chứa một document
        collection.insert_one(data)

    print(f"Imported {len(data)} records into {db_name}.{collection_name}")

utils/pdf_utils.py

import os
import PyPDF2
import pdfplumber
import json

def split_pdf(pdf_path, output_dir, num_pages_per_file=100):
  """
  Hàm tách PDF thành nhiều file nhỏ hơn.

  Args:
      pdf_path: Đường dẫn đến tệp PDF.
      output_dir: Thư mục để lưu các file PDF nhỏ.
      num_pages_per_file: Số trang mỗi file PDF nhỏ (tùy chọn).
  """
  if not os.path.exists(output_dir):
        os.makedirs(output_dir)

  with open(pdf_path, 'rb') as pdf_file:
    pdf_reader = PyPDF2.PdfReader(pdf_file)
    num_pages = len(pdf_reader.pages)

    for i in range(0, num_pages, num_pages_per_file):
      pdf_writer = PyPDF2.PdfWriter()
      for page_num in range(i, min(i + num_pages_per_file, num_pages)):
        pdf_writer.add_page(pdf_reader.pages[page_num])
      output_filename = f"{output_dir}/part_{i // num_pages_per_file + 1}.pdf"
      with open(output_filename, 'wb') as output_file:
        pdf_writer.write(output_file)
        print('split_pdf output_file', output_file)


def extract_tables_to_json(pdf_path, output_file="table_data.json"):
  """
  Hàm trích xuất dữ liệu từ tất cả bảng trong PDF và lưu vào file JSON.

  Args:
      pdf_path: Đường dẫn đến tệp PDF.
      output_file: Tên file JSON để lưu kết quả (tùy chọn).
  """
  json_data = []
  with pdfplumber.open(pdf_path) as pdf:
    print('pdf_path', pdf_path, range(len(pdf.pages)))
    for page_num in range(len(pdf.pages)):
      page = pdf.pages[page_num]
      tables = page.extract_tables()
      for table_data in tables:
        # Bỏ qua header (2 dòng đầu tiên)
        for row in table_data[1:]:
          # Kiểm tra nếu dòng có dữ liệu (không rỗng)
          if any(cell.strip() for cell in row):
            json_data.append({
              "date": row[1].strip(),
              "amount": row[4].strip().replace(".","").replace(",00","").replace(" ",""),
              "notes": row[5].strip(),
              "code": None,
              "bankAccountNumber": row[2].strip(),
              "source": "bidv-042209"
            })
            
  print('json_data', json_data)
  
  # Lưu JSON vào file
  with open(output_file, "w") as f:
    json.dump(json_data, f, indent=4)
    print('output_file', output_file)
    # json.dump(split_transactions(json_data), f, indent=4)


def merge_json_files(json_files, output_file):
  """
  Hàm kết hợp các file JSON thành một file JSON lớn.

  Args:
      json_files: Danh sách các file JSON cần kết hợp.
      output_file: Tên file JSON để lưu kết quả.
  """
  all_data = []
  for json_file in json_files:
    with open(json_file, "r") as f:
      data = json.load(f)
      all_data.extend(data)

  # Lưu JSON vào file
  with open(output_file, "w") as f:
    json.dump(all_data, f, indent=4)

6. Dọn dẹp

Quay ngược lại các bước trong mục 5. đi từ dưới lên, tuần tự xóa hết các resources mà bạn đã tạo ra nếu không muốn quên nó đi và cuối tháng hết hồn.

Biên soạn: Anh Dũng.

Illustrator: Tí Dev on Cover photo by: Mickaela Scarpedis-Casper (Unsplash).

Sài Gòn, những ngày cuối tháng 09/2024.