init commit

This commit is contained in:
Yanfeng 2025-08-20 11:54:40 +01:00
commit 8fe9084295
Signed by: yanfeng
GPG Key ID: 00610B08C1BF7BE9
65 changed files with 298756 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.vs
.idea
__pycache__

9
README.md Normal file
View File

@ -0,0 +1,9 @@
# Performance Comparison of Communication Protocols in Microservices
**HTTP RESTful** vs **gRPC**
![](rest_grpc_compare_frontend/public/icon.png)
# Architecture
![](rest_grpc_compare_frontend/public/server_architecture.png)

View File

@ -0,0 +1,14 @@
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
RUN chmod +x run_server.sh
EXPOSE 9520
# 启动脚本
CMD ["./run_server.sh"]

View File

@ -0,0 +1,86 @@
import grpc
from server.grpc.proto import Book_pb2, Book_pb2_grpc
from utils import get_size_from_dict, NetworkingSize
class TrafficInterceptor(grpc.UnaryUnaryClientInterceptor):
def __init__(self):
self.last_call_info = None
def intercept_unary_unary(self, continuation, client_call_details, request):
request_size = len(request.SerializeToString())
response = continuation(client_call_details, request)
response_size = len(response.result().SerializeToString())
self.last_call_info = {
'request_bytes': request_size,
'response_bytes': response_size
}
return response
class GrpcClient:
def __init__(self, address='localhost:50051'):
self.traffic_interceptor = TrafficInterceptor()
base_channel = grpc.insecure_channel(address, options=[
('grpc.max_receive_message_length', -1),
('grpc.max_send_message_length', -1),
])
self.channel = grpc.intercept_channel(
base_channel,
self.traffic_interceptor
)
self.stub = Book_pb2_grpc.BookServiceStub(self.channel)
def get_list(self, pages=1, per_page=10000, list_data_limit=30):
try:
request = Book_pb2.GetListRequest(pages=pages, per_page=per_page, list_data_limit=list_data_limit)
response = self.stub.GetList(request)
return response, get_size_from_dict(self.traffic_interceptor.last_call_info)
except grpc.RpcError as e:
print(f"Error: {e}")
def add_books(self, books, test_only=False):
try:
request = Book_pb2.AddBookRequest(books=books, test_only=test_only)
response = self.stub.AddBooks(request)
return response, get_size_from_dict(self.traffic_interceptor.last_call_info)
except grpc.RpcError as e:
print(f"Error: {e}")
def delete_books(self, book_ids, delete_last_count=-1):
try:
request = Book_pb2.DeleteBookRequest(book_ids=book_ids, delete_last_count=delete_last_count)
response = self.stub.DeleteBooks(request)
return response, get_size_from_dict(self.traffic_interceptor.last_call_info)
except grpc.RpcError as e:
print(f"Error: {e}")
def update_book(self, book):
try:
request = Book_pb2.UpdateBookRequest(book=book)
response = self.stub.UpdateBook(request)
return response, get_size_from_dict(self.traffic_interceptor.last_call_info)
except grpc.RpcError as e:
print(f"Error: {e}")
def ping(self):
try:
self.stub.Ping(Book_pb2.Empty())
except grpc.RpcError as e:
print(f"Error: {e}")
def close(self):
# 程序结束时关闭连接
self.channel.close()
def run_client():
GrpcClient().get_list(1, 10000)
if __name__ == '__main__':
run_client()

View File

@ -0,0 +1,67 @@
from flask import Flask
from flask import request, jsonify
from flask_cors import CORS
import performance_test
app = Flask(__name__)
CORS(app, supports_credentials=True)
REQUEST_PASSWORD = "test123456"
def return_json(data=None, message="", success=True, status_code=200):
return jsonify({
"success": success,
"message": message,
"data": data
}), status_code
def error_catch_and_password_check(func):
def wrapper(*args, **kwargs):
try:
if request.headers.get('password') != REQUEST_PASSWORD:
return return_json(message="Unauthorized", success=False, status_code=403)
return func(*args, **kwargs)
except Exception as e:
# raise e
return return_json(message=f"Error: {repr(e)}", success=False, status_code=500)
wrapper.__name__ = func.__name__
return wrapper
@app.route('/ping', methods=['GET'])
@error_catch_and_password_check
def ping_request():
return return_json(message="pong", success=True, status_code=200)
@app.route("/performance_test_get_list", methods=["GET"])
@error_catch_and_password_check
def performance_test_get_list_request():
per_page = request.args.get("count", default=10000, type=int)
example_data_limit = request.args.get("example_data_limit", default=10, type=int)
idx = request.args.get("idx", default=-1, type=int)
grpc_result, rest_result = performance_test.test_get_main(per_page=per_page, list_data_limit=example_data_limit)
return return_json({
"idx": idx,
"request_count": per_page,
"grpc": grpc_result,
"rest": rest_result
})
@app.route("/performance_test_add_books", methods=["GET"])
@error_catch_and_password_check
def performance_test_add_books_request():
book_count = request.args.get("count", default=1000, type=int)
grpc_result, rest_result = performance_test.test_add_main(book_count=book_count)
idx = request.args.get("idx", default=-1, type=int)
return return_json({
"idx": idx,
"request_count": book_count,
"grpc": grpc_result,
"rest": rest_result
})
if __name__ == "__main__":
app.run("0.0.0.0", port=9520)

View File

@ -0,0 +1,163 @@
import json
import time
from dataclasses import dataclass, asdict
from typing import Any
from grpc_client import GrpcClient
from rest_client import RestClient
from utils import NetworkingSize, generate_random_book_data
from google.protobuf.json_format import MessageToDict
from google._upb._message import RepeatedCompositeContainer
import psutil
import copy
# SERVER_ADDRESS = "localhost"
# REST_SERVER_PORT = 9500
# GRPC_SERVER_PORT = 50051
@dataclass
class PerformanceResult:
client_networking_ping: float = 0.0
client_request_time: float = 0.0
client_request_cpu: float = 0.0
server_deserialize_time: float = 0.0
server_deserialize_cpu: float = 0.0
server_serialize_time: float = 0.0
server_serialize_cpu: float = 0.0
server_protocol_total_time: float = 0.0
response_data: Any = None
networking_size: NetworkingSize = None
class ClientPerformanceTest:
def __init__(self, server_address, rest_server_port, grpc_server_port):
self.grpc = GrpcClient(f"{server_address}:{grpc_server_port}")
self.rest = RestClient(f"http://{server_address}:{rest_server_port}")
@staticmethod
def ping_test(func, count=5):
time_total = 0
for i in range(count):
time_start = time.perf_counter()
func()
time_end = time.perf_counter()
time_total += (time_end - time_start)
time.sleep(0.1)
return time_total / count
@staticmethod
def client_performance_test(networking_ping, func, *args, **kwargs):
# networking_ping = ping_test(grpc.ping, count=5)
process = psutil.Process()
cpu_count = psutil.cpu_count()
request_start_time = time.perf_counter()
process.cpu_percent()
# cpu_request_start = process.cpu_times()
# result = grpc.get_list(pages=page, per_page=per_page)
result, networking_size = func(*args, **kwargs)
cpu_request_percent = process.cpu_percent()
# cpu_request_end = process.cpu_times()
request_end_time = time.perf_counter()
client_request_time = request_end_time - request_start_time
# CPU time seconds
# client_request_cpu_time = (cpu_request_end.user - cpu_request_start.user) + \
# (cpu_request_end.system - cpu_request_start.system)
# client_request_cpu_usage = client_request_cpu_time / client_request_time * 100
server_deserialize_time = result.server_deserialize.time
server_deserialize_cpu = result.server_deserialize.cpu
server_serialize_time = result.server_serialize.time
server_serialize_cpu = result.server_serialize.cpu
server_protocol_total_time = result.server_protocol_total_time
return PerformanceResult(client_networking_ping=networking_ping,
client_request_time=client_request_time,
client_request_cpu=cpu_request_percent / cpu_count,
server_deserialize_time=server_deserialize_time,
server_deserialize_cpu=server_deserialize_cpu,
server_serialize_time=server_serialize_time,
server_serialize_cpu=server_serialize_cpu,
server_protocol_total_time=server_protocol_total_time,
response_data=result.response_data,
networking_size=networking_size)
def test_get_list_performance_grpc(self, per_page=10000, page=1, list_data_limit=30):
networking_ping = self.ping_test(self.grpc.ping, count=3)
return self.client_performance_test(networking_ping, self.grpc.get_list, pages=page, per_page=per_page
, list_data_limit=list_data_limit)
def test_get_list_performance_rest(self, per_page=10000, page=1, list_data_limit=30):
networking_ping = self.ping_test(self.rest.ping, count=3)
return self.client_performance_test(networking_ping, self.rest.get_list, pages=page, per_page=per_page,
list_data_limit=list_data_limit)
def _test_add_books_performance_grpc(self, books, test_only=False):
networking_ping = self.ping_test(self.grpc.ping, count=3)
return self.client_performance_test(networking_ping, self.grpc.add_books, books=books, test_only=test_only)
def _test_add_books_performance_rest(self, books, test_only=False):
networking_ping = self.ping_test(self.rest.ping, count=3,)
return self.client_performance_test(networking_ping, self.rest.add_book, books=books, test_only=test_only)
def test_add_books_performance(self, book_count=1000, test_only=True):
books_data = generate_random_book_data(book_count)
grpc_result = self._test_add_books_performance_grpc(books_data, test_only=test_only)
if not test_only:
self.grpc.delete_books([], book_count)
rest_result = self._test_add_books_performance_rest(books_data, test_only=test_only)
if not test_only:
self.rest.delete_books([], book_count)
return grpc_result, rest_result
def test_get_main(per_page=1_0000, list_data_limit=100):
client_performance_test = ClientPerformanceTest("127.0.0.1", 9500, 50051)
grpc_result = client_performance_test.test_get_list_performance_grpc(per_page=per_page, page=1)
if len(grpc_result.response_data) > list_data_limit:
grpc_result.response_data = grpc_result.response_data[:list_data_limit]
if isinstance(grpc_result.response_data, RepeatedCompositeContainer):
grpc_result.response_data = list(grpc_result.response_data)
for n, i in enumerate(grpc_result.response_data):
grpc_result.response_data[n] = MessageToDict(i, preserving_proto_field_name=True)
if len(grpc_result.response_data) <= 0:
grpc_result.response_data = []
# result_dict = copy.deepcopy(grpc_result.__dict__)
# result_dict.pop("response_data")
# print("gRPC Performance Result:", result_dict)
rest_result = client_performance_test.test_get_list_performance_rest(per_page=per_page, page=1)
if len(rest_result.response_data) > list_data_limit:
rest_result.response_data = rest_result.response_data[:list_data_limit]
for n, i in enumerate(rest_result.response_data):
rest_result.response_data[n] = dict(i)
# result_dict = copy.deepcopy(rest_result.__dict__)
# result_dict.pop("response_data")
# print("REST Performance Result:", result_dict)
return asdict(grpc_result), asdict(rest_result)
def test_add_main(book_count=1_0000):
client_performance_test = ClientPerformanceTest("127.0.0.1", 9500, 50051)
grpc_result, rest_result = client_performance_test.test_add_books_performance(book_count=book_count)
grpc_result.response_data = MessageToDict(grpc_result.response_data, preserving_proto_field_name=True)
rest_result.response_data = dict(rest_result.response_data)
# print("gRPC Add Books Performance Result:", grpc_result)
# print("REST Add Books Performance Result:", rest_result)
return asdict(grpc_result), asdict(rest_result)
if __name__ == '__main__':
print(test_get_main(10000, 2))
# test_add_main()

View File

@ -0,0 +1,13 @@
protobuf~=6.31.1
psycopg2-binary~=2.9.10
grpcio~=1.74.0
grpcio-tools~=1.74.0
SQLAlchemy~=2.0.42
tqdm~=4.67.1
Faker~=37.5.3
psutil~=7.0.0
Flask~=3.1.1
flask-cors~=6.0.1
flask-sqlalchemy~=3.0.2
requests~=2.32.4
pydantic~=2.11.7

View File

@ -0,0 +1,101 @@
import requests
from requests.adapters import HTTPAdapter
from typing import List, Dict, Any, Tuple
from rest_client_models import GetListResponse, GeneralResponse, MessageResponse
from utils import get_size_from_dict, NetworkingSize
class TrafficAdapter(HTTPAdapter):
def __init__(self, *args, **kwargs):
self.last_request_size = 0
self.last_response_size = 0
super().__init__(*args, **kwargs)
def send(self, request, **kwargs):
self.last_request_size = len(request.method.encode()) + len(request.url.encode()) + 12 # "HTTP/1.1\r\n"
if request.headers:
for name, value in request.headers.items():
self.last_request_size += len(name.encode()) + len(value.encode()) + 4 # ": " + "\r\n"
self.last_request_size += 2 # "\r\n"
if request.body:
if isinstance(request.body, bytes):
self.last_request_size += len(request.body)
else:
self.last_request_size += len(str(request.body).encode())
response = super().send(request, **kwargs)
self.last_response_size = len(f"HTTP/1.1 {response.status_code}".encode()) + 2
if response.headers:
for name, value in response.headers.items():
self.last_response_size += len(name.encode()) + len(value.encode()) + 4
self.last_response_size += 2 # "\r\n"
self.last_response_size += len(response.content)
return response
class TrafficSession(requests.Session):
def __init__(self):
super().__init__()
self.traffic_adapter = TrafficAdapter()
self.mount('http://', self.traffic_adapter)
self.mount('https://', self.traffic_adapter)
def get_last_traffic(self):
return {
'request_bytes': self.traffic_adapter.last_request_size,
'response_bytes': self.traffic_adapter.last_response_size
}
class RestClient:
def __init__(self, base_url):
self.base_url = base_url
# self.session = requests.Session()
self.session = TrafficSession()
def get_list(self, pages: int = 1, per_page: int = 100, list_data_limit=30) -> Tuple[GetListResponse, NetworkingSize]:
response = self.session.get(
f"{self.base_url}/api/get_list",
params={"pages": pages, "per_page": per_page, "list_data_limit": list_data_limit}
)
response.raise_for_status()
data = response.json()
return GetListResponse(**data), get_size_from_dict(self.session.get_last_traffic())
def add_book(self, books: List[Dict[str, Any]], test_only=False) -> Tuple[GeneralResponse, NetworkingSize]:
data = {"books": books, "test_only": test_only}
response = self.session.post(
f"{self.base_url}/api/add_book",
json=data
)
response.raise_for_status()
return GeneralResponse(**response.json()), get_size_from_dict(self.session.get_last_traffic())
def delete_books(self, book_ids: List[int], delete_last_count=-1) -> Tuple[GeneralResponse, NetworkingSize]:
data = {"book_ids": book_ids, "delete_last_count": delete_last_count}
response = self.session.delete(
f"{self.base_url}/api/delete_books",
json=data
)
response.raise_for_status()
return GeneralResponse(**response.json()), get_size_from_dict(self.session.get_last_traffic())
def update_book(self, book_data: Dict[str, Any]) -> Tuple[GeneralResponse, NetworkingSize]:
data = {"book": book_data}
response = self.session.put(
f"{self.base_url}/api/update_book",
json=data
)
response.raise_for_status()
return GeneralResponse(**response.json()), get_size_from_dict(self.session.get_last_traffic())
def ping(self):
self.session.get(f"{self.base_url}/api/ping")
def close(self):
self.session.close()

View File

@ -0,0 +1,54 @@
from pydantic import BaseModel, Field
from typing import List, Optional
class BookInfo(BaseModel):
abstract: Optional[str] = ""
author: Optional[str] = ""
barcode: Optional[str] = ""
binding: Optional[str] = ""
category_id: Optional[int] = 0
cover_image: Optional[str] = ""
description: Optional[str] = ""
edition: Optional[str] = ""
editor: Optional[str] = ""
format: Optional[str] = ""
id: Optional[int] = 0
isbn: Optional[str] = ""
keywords: Optional[str] = ""
language: Optional[str] = ""
pages: Optional[int] = 0
publication_date: Optional[int] = 0 # Unix timestamp
publisher: Optional[str] = ""
subject: Optional[str] = ""
subtitle: Optional[str] = ""
title: Optional[str] = ""
translator: Optional[str] = ""
weight: Optional[float] = 0.0
class Config:
extra = "ignore"
exclude_none = True
class ServerDeserialize(BaseModel):
cpu: float = 0.0
time: float = 0.0
class MessageResponse(BaseModel):
message: str = ""
class GeneralResponse(BaseModel):
response_data: MessageResponse = Field(default_factory=MessageResponse)
server_deserialize: ServerDeserialize = Field(default_factory=ServerDeserialize)
server_protocol_total_time: float = 0.0
server_serialize: ServerDeserialize = Field(default_factory=ServerDeserialize, alias="server_serialize")
class GetListResponse(BaseModel):
response_data: List[BookInfo] = Field(default_factory=list)
server_deserialize: ServerDeserialize = Field(default_factory=ServerDeserialize)
server_protocol_total_time: float = 0.0
server_serialize: ServerDeserialize = Field(default_factory=ServerDeserialize)

Binary file not shown.

View File

@ -0,0 +1,2 @@
cd server/grpc && start py grpc_server.py -p 50051
cd ../rest && start py server_rest.py -p 9500

View File

@ -0,0 +1,7 @@
#!/bin/bash
python main.py &
cd server/grpc && python grpc_server.py -p 50051 &
cd server/rest && python server_rest.py -p 9500 &
wait

View File

@ -0,0 +1,221 @@
from sqlalchemy import create_engine, select, func, Column, Integer, String, Text, Float, BigInteger
from sqlalchemy.orm import sessionmaker, Session, declarative_base
from sqlalchemy.pool import QueuePool
import sqlalchemy
from datetime import datetime
from tqdm import tqdm
from enum import Enum
from faker import Faker
import random
import os
from contextlib import contextmanager
from typing import List, Optional, Any
from dataclasses import dataclass
engine = create_engine(
# 'sqlite:///rest_server_books.db',
'postgresql://postgres:liuyanfeng66@localhost:5432/BookRestGrpcCompare',
poolclass=QueuePool,
pool_size=30,
max_overflow=50,
pool_pre_ping=True,
echo=False,
# connect_args={
# "check_same_thread": False,
# "timeout": 30 # 文件锁最长等待秒数,视情况可调
# }
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
@dataclass
class PaginationResult:
"""分页结果数据类"""
items: List[Any]
total: int
page: int
per_page: int
pages: int
has_prev: bool
has_next: bool
prev_num: Optional[int]
next_num: Optional[int]
class Paginator:
def __init__(self, session: Session):
self.session = session
def paginate(self, query, page: int = 1, per_page: int = 20, error_out: bool = True) -> PaginationResult:
if per_page <= 0:
return PaginationResult(
items=[],
total=0,
page=1,
per_page=0,
pages=0,
has_prev=False,
has_next=False,
prev_num=None,
next_num=None
)
page = max(1, page)
per_page = max(1, per_page)
# Get total count of items
count_query = select(func.count()).select_from(query.statement.alias())
total = self.session.scalar(count_query)
# Get total pages
pages = (total + per_page - 1) // per_page
if error_out and page > pages and total > 0:
raise ValueError(f"Page {page} out of range (1-{pages})")
offset = (page - 1) * per_page
items = query.offset(offset).limit(per_page).all()
return PaginationResult(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
has_prev=page > 1,
has_next=page < pages,
prev_num=page - 1 if page > 1 else None,
next_num=page + 1 if page < pages else None
)
class BaseModel(Base):
__abstract__ = True
def to_dict(self, exclude: set = None):
exclude = exclude or set()
ret = {}
for c in self.__table__.columns:
if c.name not in exclude:
curr_item = getattr(self, c.name)
if isinstance(curr_item, Enum):
ret[c.name] = curr_item.value
else:
ret[c.name] = curr_item
return ret
class Books(BaseModel):
__tablename__ = 'books'
id = Column(BigInteger, primary_key=True, autoincrement=True)
isbn: str = Column(String(20), nullable=True, index=True)
barcode: str = Column(String(50), nullable=True)
title: str = Column(Text, nullable=False)
subtitle: str = Column(Text, nullable=True)
author: str = Column(Text, nullable=False)
translator: str = Column(Text, nullable=True)
editor: str = Column(Text, nullable=True)
publisher: str = Column(String(200), nullable=True)
publication_date = Column(BigInteger, nullable=True)
edition: str = Column(String(50), nullable=True)
pages: int = Column(Integer, nullable=True)
language: str = Column(String(50), nullable=True)
category_id: int = Column(Integer, nullable=True, index=True)
subject: str = Column(String(200), nullable=True)
keywords: str = Column(Text, nullable=True)
description: str = Column(Text, nullable=True)
abstract: str = Column(Text, nullable=True)
format: str = Column(String(50), nullable=True)
binding: str = Column(String(20), nullable=True)
weight: float = Column(Float, nullable=True)
cover_image: str = Column(String(500), nullable=True)
def generate_random_book_data(book_id: int, fake: Faker) -> dict:
return {
'id': book_id,
'isbn': fake.isbn13(separator='-'),
'barcode': fake.ean(length=13),
'title': fake.sentence(nb_words=6),
'subtitle': fake.sentence(nb_words=3),
'author': fake.name(),
'translator': fake.name(),
'editor': fake.name(),
'publisher': fake.company(),
'publication_date': random.randint(-2190472676, 1754206067),
'edition': fake.word(),
'pages': random.randint(100, 1000),
'language': random.choice(['English', 'Français', 'Deutsch']),
'category_id': random.randint(1, 100),
'subject': fake.word(),
'keywords': ", ".join(fake.words(nb=5, unique=True, ext_word_list=None)),
'description': fake.text(max_nb_chars=200),
'abstract': fake.text(max_nb_chars=100),
'format': random.choice(['16mo', '8vo', '4to', 'Folio', 'Quarto']),
'binding': random.choice(['expensive', 'cheap', 'paperback', 'hardcover']),
'weight': random.uniform(100, 2000),
'cover_image': "https://example.com/cover_image.jpg"
}
@contextmanager
def get_db_session():
session = SessionLocal()
try:
yield session
except Exception as e:
session.rollback()
print(f"Database error: {e}")
raise
finally:
session.close()
def check_and_generate_random_data(generate_count=10000):
Base.metadata.create_all(bind=engine)
fake = Faker('en_GB')
batch_size = 10000
with get_db_session() as session:
data_count = session.query(Books).count()
if data_count != 0:
if input(
f"Database already contains data ({data_count}). Do you want to regenerate random data? (y/n): ").lower() != 'y':
return
if input("Delete all existing data? (y/n): ").lower() == 'y':
if data_count > 0:
session.query(Books).delete()
session.commit()
for batch_start in tqdm(range(0, generate_count, batch_size), desc="Inserting batches"):
batch_end = min(batch_start + batch_size, generate_count)
batch_data = [
generate_random_book_data(i, fake)
for i in range(batch_start, batch_end)
]
with get_db_session() as session:
session.bulk_insert_mappings(Books, batch_data)
session.commit()
#
# with app.app_context():
# check_and_generate_random_data()
# check_and_generate_random_data()
if __name__ == "__main__":
check_and_generate_random_data(int(input("Enter the number of random books to generate: ")))
print("Database initialized and random data generated.")

View File

@ -0,0 +1,193 @@
import grpc
from concurrent import futures
import logging
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from proto import Book_pb2_grpc, Book_pb2
from google.protobuf.json_format import MessageToDict
from database import Books, get_db_session, Paginator
import psutil
import time
import argparse
def performance_test(response_object):
def decorator(func):
def wrapper(self, request, context):
response_data = func(self, request, context)
test_return = response_object(
response_data=response_data
)
process = psutil.Process()
# 1. parse request
cpu_count = psutil.cpu_count()
process.cpu_percent()
# cpu_start = process.cpu_times()
parse_start = time.perf_counter()
request_deserializer = request.__class__.FromString
request_deserializer(request.SerializeToString())
parse_end = time.perf_counter()
cpu_percent_mid = process.cpu_percent()
# cpu_mid = process.cpu_times()
# 2. serialize response
serialize_start = time.perf_counter()
process.cpu_percent()
# cpu_serialize_start = process.cpu_times()
response_serializer = response_object.SerializeToString
response_serializer(test_return)
serialize_end = time.perf_counter()
cpu_end_percent = process.cpu_percent()
# cpu_end = process.cpu_times()
parse_total_time = parse_end - parse_start
# parse_cpu_time = (cpu_mid.user - cpu_start.user) + (cpu_mid.system - cpu_start.system)
serialize_total_time = serialize_end - serialize_start
# serialize_cpu_time = (cpu_end.user - cpu_serialize_start.user) + (cpu_end.system - cpu_serialize_start.system)
timing_data = {
'server_deserialize': {
'time': parse_total_time,
'cpu': cpu_percent_mid / cpu_count # / parse_total_time * 100
},
'server_serialize': {
'time': serialize_total_time,
'cpu': cpu_end_percent / cpu_count # / serialize_total_time * 100
},
'server_protocol_total_time': parse_end - parse_start + serialize_end - serialize_start
}
return response_object(
response_data=response_data,
**timing_data
)
return wrapper
return decorator
class BookServiceServicer(Book_pb2_grpc.BookServiceServicer):
@performance_test(Book_pb2.GetListResponse)
def GetList(self, request, context):
# print("request", request)
pages = request.pages
per_page = request.per_page
list_data_limit = request.list_data_limit
if list_data_limit <= 0:
list_data_limit = per_page
with get_db_session() as session:
paginator = Paginator(session)
query = session.query(Books).order_by(Books.id)
data = paginator.paginate(query, page=pages, per_page=min(per_page, list_data_limit), error_out=False)
books = [book.to_dict() for book in data.items]
if len(books) < per_page:
books = books * (per_page // len(books)) + books[:per_page % len(books)]
return books
@performance_test(Book_pb2.GeneralResponse)
def AddBooks(self, request, context):
books = request.books
test_only = request.test_only
if test_only:
return Book_pb2.MessageResponse(message="Books added successfully")
with get_db_session() as session:
try:
for book in books:
book_dict = MessageToDict(book, preserving_proto_field_name=True)
if "id" in book_dict:
book_dict.pop("id")
new_book = Books(**book_dict)
session.add(new_book)
session.commit()
except IntegrityError as e:
print(f"Unique constraint violation: {e}, trying to reset sequence")
session.rollback()
session.execute(text("SELECT setval('books_id_seq', (SELECT COALESCE(MAX(id), 0) FROM books))"))
session.commit()
for book in books:
book_dict = MessageToDict(book, preserving_proto_field_name=True)
if "id" in book_dict:
book_dict.pop("id")
new_book = Books(**book_dict)
session.add(new_book)
session.commit()
return Book_pb2.MessageResponse(message="Books added successfully")
@performance_test(Book_pb2.GeneralResponse)
def DeleteBooks(self, request, context):
book_ids = request.book_ids
delete_last_count = request.delete_last_count
with get_db_session() as session:
if delete_last_count > 0:
last_ids = session.query(Books.id).order_by(Books.id.desc()).limit(delete_last_count).subquery()
session.query(Books).filter(Books.id.in_(
session.query(last_ids.c.id)
)).delete(synchronize_session=False)
else:
session.query(Books).filter(Books.id.in_(book_ids)).delete(synchronize_session=False)
session.commit()
return Book_pb2.MessageResponse(message="Books deleted successfully")
@performance_test(Book_pb2.GeneralResponse)
def UpdateBook(self, request, context):
book_data = request.book
with get_db_session() as session:
book = session.query(Books).filter(Books.id == book_data.id).first()
if book:
for key, value in book_data.items():
setattr(book, key, value)
session.commit()
return Book_pb2.MessageResponse(message="Book updated successfully")
else:
context.set_code(grpc.StatusCode.NOT_FOUND)
context.set_details("Book not found")
return Book_pb2.MessageResponse(message="Book not found")
def Ping(self, request, context):
return Book_pb2.MessageResponse(message="Pong!")
def serve(port=50051):
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10),
options=[
('grpc.max_receive_message_length', -1),
('grpc.max_send_message_length', -1),
])
Book_pb2_grpc.add_BookServiceServicer_to_server(
BookServiceServicer(), server
)
listen_addr = f'[::]:{port}'
server.add_insecure_port(listen_addr)
server.start()
print(f"gRPC server runs on: {listen_addr}")
try:
server.wait_for_termination()
except KeyboardInterrupt:
server.stop(0)
if __name__ == '__main__':
arg_parse = argparse.ArgumentParser(description="Run REST server")
arg_parse.add_argument("--port", "-p", type=int, default=50051, help="Port to run the server on")
args = arg_parse.parse_args()
logging.basicConfig(level=logging.INFO)
serve(args.port)

View File

@ -0,0 +1,83 @@
syntax = "proto3";
service BookService {
rpc GetList (GetListRequest) returns (GetListResponse);
rpc AddBooks (AddBookRequest) returns (GeneralResponse);
rpc DeleteBooks (DeleteBookRequest) returns (GeneralResponse);
rpc UpdateBook (UpdateBookRequest) returns (GeneralResponse);
rpc Ping (Empty) returns (MessageResponse);
}
message Empty {}
message GetListRequest {
int32 pages = 1;
int32 per_page = 2;
int32 list_data_limit = 3;
}
message AddBookRequest {
repeated Book_info books = 1;
bool test_only = 2;
}
message DeleteBookRequest {
repeated int32 book_ids = 1;
int32 delete_last_count = 2;
}
message UpdateBookRequest {
Book_info book = 1;
}
message MessageResponse {
string message = 1;
}
message GeneralResponse {
MessageResponse response_data = 1;
Server_deserialize server_deserialize = 2;
double server_protocol_total_time = 3;
Server_deserialize server_serialize = 4;
}
message GetListResponse {
repeated Book_info response_data = 1;
Server_deserialize server_deserialize = 2;
double server_protocol_total_time = 3;
Server_deserialize server_serialize = 4;
}
message Server_deserialize {
double cpu = 1;
double time = 2;
}
message Book_info {
string abstract = 1;
string author = 2;
string barcode = 3;
string binding = 4;
int32 category_id = 5;
string cover_image = 6;
string description = 7;
string edition = 8;
string editor = 9;
string format = 10;
int32 id = 11;
string isbn = 12;
string keywords = 13;
string language = 14;
int32 pages = 15;
int64 publication_date = 16;
string publisher = 17;
string subject = 18;
string subtitle = 19;
string title = 20;
string translator = 21;
double weight = 22;
}

View File

@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: Book.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
'Book.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\nBook.proto\"\x07\n\x05\x45mpty\"J\n\x0eGetListRequest\x12\r\n\x05pages\x18\x01 \x01(\x05\x12\x10\n\x08per_page\x18\x02 \x01(\x05\x12\x17\n\x0flist_data_limit\x18\x03 \x01(\x05\">\n\x0e\x41\x64\x64\x42ookRequest\x12\x19\n\x05\x62ooks\x18\x01 \x03(\x0b\x32\n.Book_info\x12\x11\n\ttest_only\x18\x02 \x01(\x08\"@\n\x11\x44\x65leteBookRequest\x12\x10\n\x08\x62ook_ids\x18\x01 \x03(\x05\x12\x19\n\x11\x64\x65lete_last_count\x18\x02 \x01(\x05\"-\n\x11UpdateBookRequest\x12\x18\n\x04\x62ook\x18\x01 \x01(\x0b\x32\n.Book_info\"\"\n\x0fMessageResponse\x12\x0f\n\x07message\x18\x01 \x01(\t\"\xbe\x01\n\x0fGeneralResponse\x12\'\n\rresponse_data\x18\x01 \x01(\x0b\x32\x10.MessageResponse\x12/\n\x12server_deserialize\x18\x02 \x01(\x0b\x32\x13.Server_deserialize\x12\"\n\x1aserver_protocol_total_time\x18\x03 \x01(\x01\x12-\n\x10server_serialize\x18\x04 \x01(\x0b\x32\x13.Server_deserialize\"\xb8\x01\n\x0fGetListResponse\x12!\n\rresponse_data\x18\x01 \x03(\x0b\x32\n.Book_info\x12/\n\x12server_deserialize\x18\x02 \x01(\x0b\x32\x13.Server_deserialize\x12\"\n\x1aserver_protocol_total_time\x18\x03 \x01(\x01\x12-\n\x10server_serialize\x18\x04 \x01(\x0b\x32\x13.Server_deserialize\"/\n\x12Server_deserialize\x12\x0b\n\x03\x63pu\x18\x01 \x01(\x01\x12\x0c\n\x04time\x18\x02 \x01(\x01\"\x8f\x03\n\tBook_info\x12\x10\n\x08\x61\x62stract\x18\x01 \x01(\t\x12\x0e\n\x06\x61uthor\x18\x02 \x01(\t\x12\x0f\n\x07\x62\x61rcode\x18\x03 \x01(\t\x12\x0f\n\x07\x62inding\x18\x04 \x01(\t\x12\x13\n\x0b\x63\x61tegory_id\x18\x05 \x01(\x05\x12\x13\n\x0b\x63over_image\x18\x06 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x07 \x01(\t\x12\x0f\n\x07\x65\x64ition\x18\x08 \x01(\t\x12\x0e\n\x06\x65\x64itor\x18\t \x01(\t\x12\x0e\n\x06\x66ormat\x18\n \x01(\t\x12\n\n\x02id\x18\x0b \x01(\x05\x12\x0c\n\x04isbn\x18\x0c \x01(\t\x12\x10\n\x08keywords\x18\r \x01(\t\x12\x10\n\x08language\x18\x0e \x01(\t\x12\r\n\x05pages\x18\x0f \x01(\x05\x12\x18\n\x10publication_date\x18\x10 \x01(\x03\x12\x11\n\tpublisher\x18\x11 \x01(\t\x12\x0f\n\x07subject\x18\x12 \x01(\t\x12\x10\n\x08subtitle\x18\x13 \x01(\t\x12\r\n\x05title\x18\x14 \x01(\t\x12\x12\n\ntranslator\x18\x15 \x01(\t\x12\x0e\n\x06weight\x18\x16 \x01(\x01\x32\xf5\x01\n\x0b\x42ookService\x12,\n\x07GetList\x12\x0f.GetListRequest\x1a\x10.GetListResponse\x12-\n\x08\x41\x64\x64\x42ooks\x12\x0f.AddBookRequest\x1a\x10.GeneralResponse\x12\x33\n\x0b\x44\x65leteBooks\x12\x12.DeleteBookRequest\x1a\x10.GeneralResponse\x12\x32\n\nUpdateBook\x12\x12.UpdateBookRequest\x1a\x10.GeneralResponse\x12 \n\x04Ping\x12\x06.Empty\x1a\x10.MessageResponseb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'Book_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_EMPTY']._serialized_start=14
_globals['_EMPTY']._serialized_end=21
_globals['_GETLISTREQUEST']._serialized_start=23
_globals['_GETLISTREQUEST']._serialized_end=97
_globals['_ADDBOOKREQUEST']._serialized_start=99
_globals['_ADDBOOKREQUEST']._serialized_end=161
_globals['_DELETEBOOKREQUEST']._serialized_start=163
_globals['_DELETEBOOKREQUEST']._serialized_end=227
_globals['_UPDATEBOOKREQUEST']._serialized_start=229
_globals['_UPDATEBOOKREQUEST']._serialized_end=274
_globals['_MESSAGERESPONSE']._serialized_start=276
_globals['_MESSAGERESPONSE']._serialized_end=310
_globals['_GENERALRESPONSE']._serialized_start=313
_globals['_GENERALRESPONSE']._serialized_end=503
_globals['_GETLISTRESPONSE']._serialized_start=506
_globals['_GETLISTRESPONSE']._serialized_end=690
_globals['_SERVER_DESERIALIZE']._serialized_start=692
_globals['_SERVER_DESERIALIZE']._serialized_end=739
_globals['_BOOK_INFO']._serialized_start=742
_globals['_BOOK_INFO']._serialized_end=1141
_globals['_BOOKSERVICE']._serialized_start=1144
_globals['_BOOKSERVICE']._serialized_end=1389
# @@protoc_insertion_point(module_scope)

View File

@ -0,0 +1,269 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
from . import Book_pb2 as Book__pb2
GRPC_GENERATED_VERSION = '1.74.0'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ f' but the generated code in Book_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class BookServiceStub(object):
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.GetList = channel.unary_unary(
'/BookService/GetList',
request_serializer=Book__pb2.GetListRequest.SerializeToString,
response_deserializer=Book__pb2.GetListResponse.FromString,
_registered_method=True)
self.AddBooks = channel.unary_unary(
'/BookService/AddBooks',
request_serializer=Book__pb2.AddBookRequest.SerializeToString,
response_deserializer=Book__pb2.GeneralResponse.FromString,
_registered_method=True)
self.DeleteBooks = channel.unary_unary(
'/BookService/DeleteBooks',
request_serializer=Book__pb2.DeleteBookRequest.SerializeToString,
response_deserializer=Book__pb2.GeneralResponse.FromString,
_registered_method=True)
self.UpdateBook = channel.unary_unary(
'/BookService/UpdateBook',
request_serializer=Book__pb2.UpdateBookRequest.SerializeToString,
response_deserializer=Book__pb2.GeneralResponse.FromString,
_registered_method=True)
self.Ping = channel.unary_unary(
'/BookService/Ping',
request_serializer=Book__pb2.Empty.SerializeToString,
response_deserializer=Book__pb2.MessageResponse.FromString,
_registered_method=True)
class BookServiceServicer(object):
"""Missing associated documentation comment in .proto file."""
def GetList(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def AddBooks(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def DeleteBooks(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def UpdateBook(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def Ping(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_BookServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'GetList': grpc.unary_unary_rpc_method_handler(
servicer.GetList,
request_deserializer=Book__pb2.GetListRequest.FromString,
response_serializer=Book__pb2.GetListResponse.SerializeToString,
),
'AddBooks': grpc.unary_unary_rpc_method_handler(
servicer.AddBooks,
request_deserializer=Book__pb2.AddBookRequest.FromString,
response_serializer=Book__pb2.GeneralResponse.SerializeToString,
),
'DeleteBooks': grpc.unary_unary_rpc_method_handler(
servicer.DeleteBooks,
request_deserializer=Book__pb2.DeleteBookRequest.FromString,
response_serializer=Book__pb2.GeneralResponse.SerializeToString,
),
'UpdateBook': grpc.unary_unary_rpc_method_handler(
servicer.UpdateBook,
request_deserializer=Book__pb2.UpdateBookRequest.FromString,
response_serializer=Book__pb2.GeneralResponse.SerializeToString,
),
'Ping': grpc.unary_unary_rpc_method_handler(
servicer.Ping,
request_deserializer=Book__pb2.Empty.FromString,
response_serializer=Book__pb2.MessageResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'BookService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('BookService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class BookService(object):
"""Missing associated documentation comment in .proto file."""
@staticmethod
def GetList(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/BookService/GetList',
Book__pb2.GetListRequest.SerializeToString,
Book__pb2.GetListResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def AddBooks(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/BookService/AddBooks',
Book__pb2.AddBookRequest.SerializeToString,
Book__pb2.GeneralResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def DeleteBooks(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/BookService/DeleteBooks',
Book__pb2.DeleteBookRequest.SerializeToString,
Book__pb2.GeneralResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def UpdateBook(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/BookService/UpdateBook',
Book__pb2.UpdateBookRequest.SerializeToString,
Book__pb2.GeneralResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def Ping(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/BookService/Ping',
Book__pb2.Empty.SerializeToString,
Book__pb2.MessageResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)

View File

@ -0,0 +1 @@
from . import Book_pb2, Book_pb2_grpc

View File

@ -0,0 +1 @@
py -m grpc_tools.protoc --python_out=. --grpc_python_out=. --proto_path=. Book.proto

View File

@ -0,0 +1,111 @@
from flask_sqlalchemy import SQLAlchemy
from flask import Flask
from flask_cors import CORS
from enum import Enum
from faker import Faker
import random
app = Flask(__name__)
# app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///rest_server_books.db'
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://postgres:liuyanfeng66@localhost:5432/BookRestGrpcCompare'
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_size': 30, # 主连接数
'max_overflow': 50, # 额外连接数
'pool_pre_ping': True, # 检测失效连接,自动重连
# 'connect_args': {
# 'check_same_thread': False,
# 'timeout': 30 # 文件锁最长等待秒数,视情况可调
# }
}
CORS(app, supports_credentials=True)
db = SQLAlchemy(app)
class BaseModel(db.Model):
__abstract__ = True
def to_dict(self, exclude: set = None):
exclude = exclude or set()
ret = {}
for c in self.__table__.columns:
if c.name not in exclude:
curr_item = getattr(self, c.name)
if isinstance(curr_item, Enum):
ret[c.name] = curr_item.value
else:
ret[c.name] = curr_item
return ret
class Books(BaseModel):
__tablename__ = 'books'
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
isbn: str = db.Column(db.String(20), nullable=True, index=True)
barcode: str = db.Column(db.String(50), nullable=True)
title: str = db.Column(db.Text, nullable=False)
subtitle: str = db.Column(db.Text, nullable=True)
author: str = db.Column(db.Text, nullable=False)
translator: str = db.Column(db.Text, nullable=True)
editor: str = db.Column(db.Text, nullable=True)
publisher: str = db.Column(db.String(200), nullable=True)
publication_date = db.Column(db.BigInteger, nullable=True)
edition: str = db.Column(db.String(50), nullable=True)
pages: int = db.Column(db.Integer, nullable=True)
language: str = db.Column(db.String(50), nullable=True)
category_id: int = db.Column(db.Integer, nullable=True, index=True)
subject: str = db.Column(db.String(200), nullable=True)
keywords: str = db.Column(db.Text, nullable=True)
description: str = db.Column(db.Text, nullable=True)
abstract: str = db.Column(db.Text, nullable=True)
format: str = db.Column(db.String(50), nullable=True)
binding: str = db.Column(db.String(20), nullable=True)
weight: float = db.Column(db.Float, nullable=True)
cover_image: str = db.Column(db.String(500), nullable=True)
def generate_random_book_data(book_id: int, fake: Faker) -> dict:
return {
'id': book_id,
'isbn': fake.isbn13(separator='-'),
'barcode': fake.ean(length=13),
'title': fake.sentence(nb_words=6),
'subtitle': fake.sentence(nb_words=3),
'author': fake.name(),
'translator': fake.name(),
'editor': fake.name(),
'publisher': fake.company(),
'publication_date': random.randint(-2190472676, 1754206067),
'edition': fake.word(),
'pages': random.randint(100, 1000),
'language': random.choice(['English', 'Français', 'Deutsch']),
'category_id': random.randint(1, 100),
'subject': fake.word(),
'keywords': ", ".join(fake.words(nb=5, unique=True, ext_word_list=None)),
'description': fake.text(max_nb_chars=200),
'abstract': fake.text(max_nb_chars=100),
'format': random.choice(['16mo', '8vo', '4to', 'Folio', 'Quarto']),
'binding': random.choice(['expensive', 'cheap', 'paperback', 'hardcover']),
'weight': random.uniform(100, 2000),
'cover_image': "https://example.com/cover_image.jpg"
}
def check_and_generate_random_data():
db.create_all()
if Books.query.count() == 0:
fake = Faker('en_GB')
for i in range(10000):
book = Books(
**generate_random_book_data(i, fake)
)
db.session.add(book)
db.session.commit()
# with app.app_context():
# check_and_generate_random_data()

View File

@ -0,0 +1,150 @@
from flask import Flask, request, jsonify
import time
import psutil
from database import app, db, Books
import argparse
def performance_test(is_get=False):
def decorator(func):
def wrapper(*args, **kwargs):
process = psutil.Process()
cpu_count = psutil.cpu_count()
# 1. parse request
process.cpu_percent()
# cpu_start = process.cpu_times()
parse_start = time.perf_counter()
if is_get:
data = request.args.to_dict()
else:
data = request.get_json()
parse_end = time.perf_counter()
cpu_percent_mid = process.cpu_percent()
# cpu_mid = process.cpu_times()
# process
result = func(*args, **kwargs, request_data=data)
# 2. serialize response
serialize_start = time.perf_counter()
process.cpu_percent()
# cpu_serialize_start = process.cpu_times()
response_data = jsonify(result)
serialize_end = time.perf_counter()
cpu_end_percent = process.cpu_percent()
# cpu_end = process.cpu_times()
parse_time = parse_end - parse_start
# parse_cpu_time = (cpu_mid.user - cpu_start.user) + (cpu_mid.system - cpu_start.system)
server_serialize_time = serialize_end - serialize_start
# server_serialize_cpu_time = (cpu_end.user - cpu_serialize_start.user) + (cpu_end.system - cpu_serialize_start.system)
# timing
timing_data = {
'server_deserialize': {
'time': parse_time,
'cpu': cpu_percent_mid / cpu_count # / parse_time * 100
},
'server_serialize': {
'time': server_serialize_time,
'cpu': cpu_end_percent / cpu_count # / server_serialize_time * 100
},
'server_protocol_total_time': parse_end - parse_start + serialize_end - serialize_start,
'response_data': result,
}
# print(f"Server timing: {timing_data}")
return jsonify(timing_data), response_data.status_code
wrapper.__name__ = func.__name__
return wrapper
return decorator
@app.route('/api/get_list', methods=['GET'])
@performance_test(is_get=True)
def get_list(request_data):
page_count = int(request_data.get("pages", 1))
per_page = int(request_data.get("per_page", 100))
list_data_limit = int(request_data.get("list_data_limit", per_page))
if list_data_limit <= 0:
list_data_limit = per_page
if per_page <= 0:
return []
data = Books.query.order_by(Books.id).paginate(page=page_count, per_page=min(per_page, list_data_limit), error_out=False)
books = [book.to_dict() for book in data.items]
if len(books) < per_page:
books = books * (per_page // len(books)) + books[:per_page % len(books)]
return books
@app.route('/api/add_book', methods=['POST'])
@performance_test(is_get=False)
def add_book(request_data):
books_data = request_data.get('books', [])
test_only = request_data.get('test_only', False)
if test_only:
return {"message": "Books added successfully"}
for book in books_data:
new_book = Books(**book)
db.session.add(new_book)
db.session.commit()
return {"message": "Books added successfully"}
@app.route('/api/delete_books', methods=['DELETE'])
@performance_test(is_get=False)
def delete_books(request_data):
book_ids = request_data.get('book_ids', [])
delete_last_count = request_data.get('delete_last_count', -1)
if delete_last_count > 0:
last_ids = db.session.query(Books.id).order_by(Books.id.desc()).limit(delete_last_count).subquery()
Books.query.filter(Books.id.in_(
db.session.query(last_ids.c.id)
)).delete(synchronize_session=False)
else:
Books.query.filter(Books.id.in_(book_ids)).delete(synchronize_session=False)
db.session.commit()
return {"message": "Books deleted successfully"}
@app.route('/api/update_book', methods=['PUT'])
@performance_test(is_get=False)
def update_book(request_data):
book_data = request_data.get('book', {})
book_id = book_data.get('id')
book = db.session.get(Books, book_id)
if not book:
return {"message": "Book not found"}
for key, value in book_data.items():
if key == 'id':
continue
setattr(book, key, value)
db.session.commit()
return {"message": "Book updated successfully"}
@app.route('/api/ping', methods=['GET'])
def ping():
return jsonify({"message": "pong"})
if __name__ == '__main__':
arg_parse = argparse.ArgumentParser(description="Run REST server")
arg_parse.add_argument("--port", "-p", type=int, default=9500, help="Port to run the server on")
args = arg_parse.parse_args()
app.run("0.0.0.0", port=args.port)

View File

@ -0,0 +1,46 @@
from dataclasses import dataclass
import random
from faker import Faker
@dataclass
class NetworkingSize:
request_size: int = 0
response_size: int = 0
def get_size_from_dict(data: dict):
return NetworkingSize(
request_size=data.get('request_bytes', 0),
response_size=data.get('response_bytes', 0)
)
def generate_random_book_data(count: int) -> list[dict]:
fake = Faker("en_GB")
return [
{
# 'id': book_id,
'isbn': fake.isbn13(separator='-'),
'barcode': fake.ean(length=13),
'title': fake.sentence(nb_words=6),
'subtitle': fake.sentence(nb_words=3),
'author': fake.name(),
'translator': fake.name(),
'editor': fake.name(),
'publisher': fake.company(),
'publication_date': random.randint(-2190472676, 1754206067),
'edition': fake.word(),
'pages': random.randint(100, 1000),
'language': random.choice(['English', 'Français', 'Deutsch']),
'category_id': random.randint(1, 100),
'subject': fake.word(),
'keywords': ", ".join(fake.words(nb=5, unique=True, ext_word_list=None)),
'description': fake.text(max_nb_chars=200),
'abstract': fake.text(max_nb_chars=100),
'format': random.choice(['16mo', '8vo', '4to', 'Folio', 'Quarto']),
'binding': random.choice(['expensive', 'cheap', 'paperback', 'hardcover']),
'weight': random.uniform(100, 2000),
'cover_image': "https://example.com/cover_image.jpg"
} for _ in range(count)
]

View File

@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md

View File

@ -0,0 +1 @@
VITE_API_ENDPOINT = "http://127.0.0.1:9520"

6
rest_grpc_compare_frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/

View File

@ -0,0 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

View File

@ -0,0 +1,87 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
To build and run using Docker:
```bash
docker build -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

View File

@ -0,0 +1,15 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
html,
body {
@apply bg-white dark:bg-gray-950;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}

View File

@ -0,0 +1,250 @@
import {Affix, Button, Divider, Grid, Text, rem, Stack} from "@mantine/core";
import { AreaChart } from '@mantine/charts';
import type {PerformanceGetListData} from "~/utils/models.ts";
import React from "react";
export default function ({data, gridSpan, hideServerDeserialize, hideServerSerialize}: {
data: PerformanceGetListData[], gridSpan: number, hideServerDeserialize: boolean, hideServerSerialize: boolean
}) {
const chartData = data.map(item => ({
RequestCount: item.request_count,
gRpcClientPing: item.grpc.client_networking_ping,
gRpcClientCPU: item.grpc.client_request_cpu,
gRpcTimeTotal: item.grpc.client_request_time,
gRpcRequestSize: item.grpc.networking_size.request_size,
gRpcResponseSize: item.grpc.networking_size.response_size,
gRpcServerDeserializeCPU: item.grpc.server_deserialize_cpu,
gRpcServerDeserializeTime: item.grpc.server_deserialize_time,
gRpcServerSerializeCPU: item.grpc.server_serialize_cpu,
gRpcServerSerializeTime: item.grpc.server_serialize_time,
gRpcServerProtocolTotalTime: item.grpc.server_protocol_total_time,
RestClientPing: item.rest.client_networking_ping,
RestClientCPU: item.rest.client_request_cpu,
RestTimeTotal: item.rest.client_request_time,
RestRequestSize: item.rest.networking_size.request_size,
RestResponseSize: item.rest.networking_size.response_size,
RestServerDeserializeCPU: item.rest.server_deserialize_cpu,
RestServerDeserializeTime: item.rest.server_deserialize_time,
RestServerSerializeCPU: item.rest.server_serialize_cpu,
RestServerSerializeTime: item.rest.server_serialize_time,
RestServerProtocolTotalTime: item.rest.server_protocol_total_time,
}));
return (
<Grid gutter="xs" grow>
<Grid.Col span={gridSpan} mb="md">
<Text mb="md" pl="md" ta="center" fw={700}>Client CPU Usage</Text>
<AreaChart
h={300}
data={chartData}
dataKey="RequestCount"
type="stacked"
xAxisLabel="Data Count"
yAxisLabel="CPU Usage (%)"
withLegend
withPointLabels={false}
withDots={false}
valueFormatter={(value) => new Intl.NumberFormat('en-US').format(value)}
areaChartProps={{ syncId: 'RequestCount' }}
series={[
{ name: 'gRpcClientCPU', label: 'gRPC', color: 'teal.6' },
{ name: 'RestClientCPU', label: 'Rest', color: 'indigo.6' },
]}
/>
<Divider my="0px" labelPosition="center" />
</Grid.Col>
<Grid.Col span={gridSpan} mb="md">
<Text mb="md" pl="md" ta="center" fw={700}>Client Total Time (s)</Text>
<AreaChart
h={300}
data={chartData}
dataKey="RequestCount"
type="stacked"
xAxisLabel="Data Count"
yAxisLabel="Total Time (s)"
withLegend
withPointLabels={false}
withDots={false}
valueFormatter={(value) => new Intl.NumberFormat('en-GB').format(value)}
areaChartProps={{ syncId: 'RequestCount' }}
series={[
{ name: 'gRpcTimeTotal', label: 'gRPC', color: 'teal.6' },
{ name: 'RestTimeTotal', label: 'Rest', color: 'indigo.6' },
]}
/>
<Divider my="0px" labelPosition="center" />
</Grid.Col>
{!hideServerDeserialize &&
<Grid.Col span={gridSpan} mb="md">
<Text mb="md" pl="md" ta="center" fw={700}>Networking Request Data Size (KB)</Text>
<AreaChart
h={300}
data={chartData}
dataKey="RequestCount"
type="stacked"
xAxisLabel="Data Count"
yAxisLabel="Request Data Size (KB)"
withLegend
withPointLabels={false}
withDots={false}
valueFormatter={(value) => new Intl.NumberFormat('en-GB').format(value / 1024)}
areaChartProps={{ syncId: 'RequestCount' }}
series={[
{ name: 'gRpcRequestSize', label: 'gRPC', color: 'teal.6' },
{ name: 'RestRequestSize', label: 'Rest', color: 'indigo.6' },
]}
/>
<Divider my="0px" labelPosition="center" />
</Grid.Col>
}
{!hideServerSerialize &&
<Grid.Col span={gridSpan} mb="md">
<Text mb="md" pl="md" ta="center" fw={700}>Networking Response Data Size (KB)</Text>
<AreaChart
h={300}
data={chartData}
dataKey="RequestCount"
type="stacked"
xAxisLabel="Data Count"
yAxisLabel="Response Data Size (KB)"
withLegend
withPointLabels={false}
withDots={false}
valueFormatter={(value) => new Intl.NumberFormat('en-GB').format(value / 1024)}
areaChartProps={{ syncId: 'RequestCount' }}
series={[
{ name: 'gRpcResponseSize', label: 'gRPC', color: 'teal.6' },
{ name: 'RestResponseSize', label: 'Rest', color: 'indigo.6' },
]}
/>
<Divider my="0px" labelPosition="center" />
</Grid.Col>
}
{!hideServerDeserialize &&
<Grid.Col span={gridSpan} mb="md">
<Text mb="md" pl="md" ta="center" fw={700}>Server Deserialize CPU Usage (%)</Text>
<AreaChart
h={300}
data={chartData}
dataKey="RequestCount"
type="stacked"
xAxisLabel="Data Count"
yAxisLabel="CPU Usage (%)"
withLegend
withPointLabels={false}
withDots={false}
valueFormatter={(value) => new Intl.NumberFormat('en-GB').format(value)}
areaChartProps={{ syncId: 'RequestCount' }}
series={[
{ name: 'gRpcServerDeserializeCPU', label: 'gRPC', color: 'teal.6' },
{ name: 'RestServerDeserializeCPU', label: 'Rest', color: 'indigo.6' },
]}
/>
<Divider my="0px" labelPosition="center" />
</Grid.Col>
}
{!hideServerSerialize &&
<Grid.Col span={gridSpan} mb="md">
<Text mb="md" pl="md" ta="center" fw={700}>Server Serialize CPU Usage (%)</Text>
<AreaChart
h={300}
data={chartData}
dataKey="RequestCount"
type="stacked"
xAxisLabel="Data Count"
yAxisLabel="CPU Usage (%)"
withLegend
withPointLabels={false}
withDots={false}
valueFormatter={(value) => new Intl.NumberFormat('en-GB').format(value)}
areaChartProps={{ syncId: 'RequestCount' }}
series={[
{ name: 'gRpcServerSerializeCPU', label: 'gRPC', color: 'teal.6' },
{ name: 'RestServerSerializeCPU', label: 'Rest', color: 'indigo.6' },
]}
/>
<Divider my="0px" labelPosition="center" />
</Grid.Col>
}
{!hideServerDeserialize &&
<Grid.Col span={gridSpan} mb="md">
<Text mb="md" pl="md" ta="center" fw={700}>Server Deserialize Time (ms)</Text>
<AreaChart
h={300}
data={chartData}
dataKey="RequestCount"
type="stacked"
xAxisLabel="Data Count"
yAxisLabel="Time (ms)"
withLegend
withPointLabels={false}
withDots={false}
valueFormatter={(value) => new Intl.NumberFormat('en-GB').format(value * 1000)}
areaChartProps={{ syncId: 'RequestCount' }}
series={[
{ name: 'gRpcServerDeserializeTime', label: 'gRPC', color: 'teal.6' },
{ name: 'RestServerDeserializeTime', label: 'Rest', color: 'indigo.6' },
]}
/>
<Divider my="0px" labelPosition="center" />
</Grid.Col>
}
{!hideServerSerialize &&
<Grid.Col span={gridSpan} mb="md">
<Text mb="md" pl="md" ta="center" fw={700}>Server Serialize Time (ms)</Text>
<AreaChart
h={300}
data={chartData}
dataKey="RequestCount"
type="stacked"
xAxisLabel="Data Count"
yAxisLabel="Time (ms)"
withLegend
withPointLabels={false}
withDots={false}
valueFormatter={(value) => new Intl.NumberFormat('en-GB').format(value * 1000)}
areaChartProps={{ syncId: 'RequestCount' }}
series={[
{ name: 'gRpcServerSerializeTime', label: 'gRPC', color: 'teal.6' },
{ name: 'RestServerSerializeTime', label: 'Rest', color: 'indigo.6' },
]}
/>
<Divider my="0px" labelPosition="center" />
</Grid.Col>
}
<Grid.Col span={gridSpan} mb="md">
<Text mb="md" pl="md" ta="center" fw={700}>Server Protocol Total Time (ms)</Text>
<AreaChart
h={300}
data={chartData}
dataKey="RequestCount"
type="stacked"
xAxisLabel="Data Count"
yAxisLabel="Time (ms)"
withLegend
withPointLabels={false}
withDots={false}
valueFormatter={(value) => new Intl.NumberFormat('en-GB').format(value * 1000)}
areaChartProps={{ syncId: 'RequestCount' }}
series={[
{ name: 'gRpcServerProtocolTotalTime', label: 'gRPC', color: 'teal.6' },
{ name: 'RestServerProtocolTotalTime', label: 'Rest', color: 'indigo.6' },
]}
/>
<Divider my="0px" labelPosition="center" />
</Grid.Col>
</Grid>
)
}

View File

@ -0,0 +1,47 @@
import {Affix, Button, Group, rem, Stack} from "@mantine/core";
import ArrowUpIcon from "mdi-react/ArrowUpIcon";
import HomeIcon from "mdi-react/HomeIcon";
import {useNavigate} from "react-router";
import {useWindowScroll} from "@mantine/hooks";
import {ThemeToggle} from "~/components/subs/ThemeToggle.tsx";
import {roundThemeButton} from "~/styles.ts";
export default function () {
const navigate = useNavigate();
const [scroll, scrollTo] = useWindowScroll();
const buttonSize = 23;
const circleSize = 55;
return (
<>
<Affix position={{ bottom: 20, right: 20 }}>
<Group dir="reverse">
<Button
radius={circleSize}
w={circleSize}
h={circleSize}
p={0}
disabled={scroll.y <= 0}
onClick={() => scrollTo({ y: 0 })}
>
<ArrowUpIcon size={buttonSize} />
</Button>
<ThemeToggle extraStyle={roundThemeButton} iconStyle={{width: rem(buttonSize - 3), height: rem(buttonSize - 3)}}/>
<Button
radius={circleSize}
w={circleSize}
h={circleSize}
p={0}
onClick={() => navigate("/")}
>
<HomeIcon size={buttonSize}/>
</Button>
</Group>
</Affix>
</>
)
}

View File

@ -0,0 +1,62 @@
import {Burger, Group, Image, Text} from "@mantine/core";
import {ThemeToggle} from "./subs/ThemeToggle.tsx";
import React, {useEffect, useState} from "react";
export function PageHeader({opened, toggle, appendTitle}: {opened: boolean, toggle: () => void, appendTitle?: string}) {
const [width, setWidth] = useState(window.innerWidth)
// const [height, setHeight] = useState(window.innerHeight)
const [title, setTitle] = useState('Performance Test Dashboard');
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
// setHeight(window.innerHeight);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
// useEffect(() => {
// const titleElement = document.querySelector('title');
// const observer = new MutationObserver((mutations) => {
// mutations.forEach((mutation) => {
// mutation.target.textContent && setTitle(mutation.target.textContent);
// });
// });
//
// if (titleElement) {
// observer.observe(titleElement, { childList: true });
// }
//
// return () => {
// observer.disconnect();
// };
// }, []);
return (
<Group justify="space-between">
<Group>
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
/>
<Image src="/icon.png" w={30} h={30}/>
{width >= 290 && <Text size="lg" fw={700}>{title}</Text>}
{appendTitle && <Text size="sm">{appendTitle}</Text>}
</Group>
<Group>
<ThemeToggle/>
</Group>
</Group>
)
}

View File

@ -0,0 +1,56 @@
import {Button, Divider, Flex, Tabs} from "@mantine/core";
import {iconMStyle, maxWidth} from "../styles.ts";
import React from "react";
import {DashboardPageType} from "~/utils/enums.ts";
import HomeIcon from "mdi-react/HomeIcon";
import InformationIcon from "mdi-react/InformationIcon";
import LogoutIcon from "mdi-react/LogoutIcon";
import DownloadIcon from "mdi-react/DownloadIcon";
import UploadIcon from "mdi-react/UploadIcon";
export function PageNavbar({currentStat, changePageStat}:
{currentStat: DashboardPageType, changePageStat: (p: DashboardPageType) => any}) {
const onClickLogout = () => {
localStorage.removeItem("password")
changePageStat(DashboardPageType.Home)
}
return (
<>
<Tabs variant="pills" orientation="vertical" defaultValue="gallery" value={currentStat} style={{flex: 1}}
onChange={(e) => {if (e) changePageStat(e as DashboardPageType)}}>
<Tabs.List style={maxWidth}>
<Tabs.Tab value={DashboardPageType.Dashboard} leftSection={<HomeIcon style={iconMStyle}/>}>
Home
</Tabs.Tab>
<Divider my="0px" label="Performance Test" labelPosition="center" />
<Tabs.Tab value={DashboardPageType.DashboardTestGetList} leftSection={<DownloadIcon style={iconMStyle}/>}>
Fetch Data
</Tabs.Tab>
<Tabs.Tab value={DashboardPageType.DashboardTestPushData} leftSection={<UploadIcon style={iconMStyle}/>}>
Push Data
</Tabs.Tab>
</Tabs.List>
</Tabs>
<Flex gap="md" justify="flex-start" align="flex-end" direction="row" wrap="wrap" style={maxWidth}>
<Button variant="outline" color="blue" fullWidth justify="start" onClick={() => changePageStat(DashboardPageType.About)}
leftSection={<InformationIcon style={iconMStyle}/>}>
About
</Button>
<Button variant="outline" color="red" fullWidth justify="start" onClick={onClickLogout}
leftSection={<LogoutIcon style={iconMStyle}/>}>
Logout
</Button>
</Flex>
</>
)
}

View File

@ -0,0 +1,275 @@
import {
Stack,
Group,
Card,
Accordion,
Text,
Table,
RingProgress,
ScrollArea,
Space
} from "@mantine/core";
import {marginTopBottom} from "~/styles.ts";
import {PerformanceTestFilter} from "~/components/subs/PerformanceTestFilter.tsx";
import { ResponsiveTableContainer } from "~/components/subs/ResponsiveTableContainer";
import React, {useEffect, useRef, useState} from "react";
import type {BookInfo, PerformanceGetListData} from "~/utils/models.ts";
import { proxy, useSnapshot } from 'valtio';
import {convertNumber, showErrorMessage, sleep} from "~/utils/utils.ts";
import {apiPerformanceGetList, apiPerformanceUploadData} from "~/utils/compare_api.ts";
import Charts from "~/components/Charts.tsx";
import {getTestExampleData, pushTestExampleData} from "~/exapleData.ts";
import {BookTable} from "~/components/subs/BookInfoTable.tsx";
import {confirmExportData} from "~/components/subs/confirms.tsx";
// const performanceDataState = proxy<PerformanceGetListData[]>([]);
export default function Component({isPushTest, performanceDataState}: { isPushTest: boolean, performanceDataState: PerformanceGetListData[] }) {
const [startsCount, setStartsCount] = useState(200)
const [endsCount, setEndsCount] = useState(100000)
const [addCount, setAddCount] = useState(200)
const [avgCount, setAvgCount] = useState(3)
const [maxExampleCount, setMaxExampleCount] = useState(30)
const [isStarting, setIsStarting] = useState(false)
const [requestStopping, setRequestStopping] = useState(false)
const requestStoppingRef = useRef(requestStopping);
requestStoppingRef.current = requestStopping;
const [currentCount, setCurrentCount] = useState(0)
const [currentIdx, setCurrentIdx] = useState(0)
const [currentAvgCount, setCurrentAvgCount] = useState(0)
const [hideServerDeserialize, setHideServerDeserialize] = useState(!isPushTest)
const [hideServerSerialize, setHideServerSerialize] = useState(isPushTest)
const [chartsSize, setChartsSize] = useState(6)
const performanceDataSnap = useSnapshot(performanceDataState);
useEffect(() => {
getExampleData()
}, [])
const exportData = () => {
if (!performanceDataSnap) return
confirmExportData(performanceDataSnap as PerformanceGetListData[], isPushTest ? "performance_test_push_data" : "performance_test_get_list_data")
}
const updateFilter = (startsCount: number, endsCount: number, addCount: number, avgCount: number, maxExampleCount: number, hideServerDeserialize: boolean, hideServerSerialize: boolean, chartsSize: number) => {
setStartsCount(startsCount);
setEndsCount(endsCount);
setAddCount(addCount);
setAvgCount(avgCount);
setMaxExampleCount(maxExampleCount);
setHideServerDeserialize(hideServerDeserialize);
setHideServerSerialize(hideServerSerialize);
setChartsSize(chartsSize);
}
const onClickStart = () => {
StartTest()
.finally(() => setIsStarting(false))
}
const OnClickStop = () => {
if (!isStarting) return
setRequestStopping(true)
}
const getCurrentSingleStep = (queryIdx: number | undefined) => {
if (!isStarting) return queryIdx;
if (queryIdx !== currentIdx) return queryIdx;
let value = currentAvgCount / avgCount * 100;
return <RingProgress
sections={[{ value, color: 'blue' }]}
transitionDuration={250}
label={<Text ta="center">{currentAvgCount} / {avgCount}</Text>}
/>
}
const getExampleData = () => {
performanceDataState.splice(0);
let exampleData = isPushTest ? pushTestExampleData : getTestExampleData
for (let i = 0; i < exampleData.length; i++) {
// @ts-ignore
performanceDataState.push(exampleData[i])
}
}
const StartTest = async () => {
setIsStarting(true)
setRequestStopping(false)
if (addCount <= 0) {
showErrorMessage("Step Count must be greater than 0", "Invalid Step Count");
return;
}
if (startsCount > endsCount) {
showErrorMessage("Starts Count can not be greater than Ends Count", "Invalid Starts Count");
return;
}
let currentForCount = startsCount;
let idx = 0;
performanceDataState.splice(0);
while (true) {
setCurrentCount(currentForCount);
setCurrentIdx(idx)
for (let i = 0; i < avgCount; i++) {
try {
if (requestStoppingRef.current) {
setRequestStopping(false)
return
}
let response = isPushTest ?
await apiPerformanceUploadData(currentForCount, idx) :
await apiPerformanceGetList(currentForCount, maxExampleCount, idx)
if (response.success && response.data) {
const checkLastIndex = performanceDataState.length - 1
if (performanceDataState.length > 0 && performanceDataState[checkLastIndex].idx === response.data.idx) {
const lastData = performanceDataState[checkLastIndex]
performanceDataState[checkLastIndex].grpc.client_networking_ping = (lastData.grpc.client_networking_ping + response.data.grpc.client_networking_ping) / 2;
performanceDataState[checkLastIndex].grpc.client_request_cpu = (lastData.grpc.client_request_cpu + response.data.grpc.client_request_cpu) / 2;
performanceDataState[checkLastIndex].grpc.client_request_time = (lastData.grpc.client_request_time + response.data.grpc.client_request_time) / 2;
performanceDataState[checkLastIndex].grpc.networking_size.request_size = (lastData.grpc.networking_size.request_size + response.data.grpc.networking_size.request_size) / 2;
performanceDataState[checkLastIndex].grpc.networking_size.response_size = (lastData.grpc.networking_size.response_size + response.data.grpc.networking_size.response_size) / 2;
performanceDataState[checkLastIndex].grpc.server_deserialize_cpu = (lastData.grpc.server_deserialize_cpu + response.data.grpc.server_deserialize_cpu) / 2;
performanceDataState[checkLastIndex].grpc.server_deserialize_time = (lastData.grpc.server_deserialize_time + response.data.grpc.server_deserialize_time) / 2;
performanceDataState[checkLastIndex].grpc.server_serialize_cpu = (lastData.grpc.server_serialize_cpu + response.data.grpc.server_serialize_cpu) / 2;
performanceDataState[checkLastIndex].grpc.server_serialize_time = (lastData.grpc.server_serialize_time + response.data.grpc.server_serialize_time) / 2;
performanceDataState[checkLastIndex].grpc.server_protocol_total_time = (lastData.grpc.server_protocol_total_time + response.data.grpc.server_protocol_total_time) / 2;
performanceDataState[checkLastIndex].rest.client_networking_ping = (lastData.rest.client_networking_ping + response.data.rest.client_networking_ping) / 2;
performanceDataState[checkLastIndex].rest.client_request_cpu = (lastData.rest.client_request_cpu + response.data.rest.client_request_cpu) / 2;
performanceDataState[checkLastIndex].rest.client_request_time = (lastData.rest.client_request_time + response.data.rest.client_request_time) / 2;
performanceDataState[checkLastIndex].rest.networking_size.request_size = (lastData.rest.networking_size.request_size + response.data.rest.networking_size.request_size) / 2;
performanceDataState[checkLastIndex].rest.networking_size.response_size = (lastData.rest.networking_size.response_size + response.data.rest.networking_size.response_size) / 2;
performanceDataState[checkLastIndex].rest.server_deserialize_cpu = (lastData.rest.server_deserialize_cpu + response.data.rest.server_deserialize_cpu) / 2;
performanceDataState[checkLastIndex].rest.server_deserialize_time = (lastData.rest.server_deserialize_time + response.data.rest.server_deserialize_time) / 2;
performanceDataState[checkLastIndex].rest.server_serialize_cpu = (lastData.rest.server_serialize_cpu + response.data.rest.server_serialize_cpu) / 2;
performanceDataState[checkLastIndex].rest.server_serialize_time = (lastData.rest.server_serialize_time + response.data.rest.server_serialize_time) / 2;
performanceDataState[checkLastIndex].rest.server_protocol_total_time = (lastData.rest.server_protocol_total_time + response.data.rest.server_protocol_total_time) / 2;
}
else {
performanceDataState.push(response.data)
}
}
}
catch (error: any) {
showErrorMessage(error.toString(), "Error during performance test");
}
finally {
setCurrentAvgCount(i + 1)
}
}
idx++;
setCurrentIdx(idx)
await sleep(200)
if (currentForCount >= endsCount) {
break;
}
currentForCount += addCount;
if (currentForCount > endsCount) {
currentForCount = endsCount;
}
}
}
const rows = performanceDataSnap.map(item => (
<Table.Tr key={`${item.idx}_${item.request_count}`}>
<Table.Td>{getCurrentSingleStep(item.idx)}</Table.Td>
<Table.Td>{item.request_count}</Table.Td>
<Table.Td>{convertNumber(item.grpc.client_networking_ping, 1000, 2)} / {convertNumber(item.rest.client_networking_ping, 1000, 2)}</Table.Td>
<Table.Td>{convertNumber(item.grpc.client_request_cpu, 1, 2)} / {convertNumber(item.rest.client_request_cpu, 1, 2)}</Table.Td>
<Table.Td>{convertNumber(item.grpc.client_request_time, 1, 2)} / {convertNumber(item.rest.client_request_time, 1, 2)}</Table.Td>
<Table.Td>{convertNumber(item.grpc.server_deserialize_cpu, 1, 2)} / {convertNumber(item.rest.server_deserialize_cpu, 1, 2)}</Table.Td>
<Table.Td>{convertNumber(item.grpc.server_deserialize_time, 1000, 4)} / {convertNumber(item.rest.server_deserialize_time, 1000, 4)}</Table.Td>
<Table.Td>{convertNumber(item.grpc.server_serialize_cpu, 1, 2)} / {convertNumber(item.rest.server_serialize_cpu, 1, 2)}</Table.Td>
<Table.Td>{convertNumber(item.grpc.server_serialize_time, 1000, 4)} / {convertNumber(item.rest.server_serialize_time, 1000, 4)}</Table.Td>
<Table.Td>{convertNumber(item.grpc.networking_size.request_size, 1 / 1024, 4)} / {convertNumber(item.rest.networking_size.request_size, 1 / 1024, 4)}</Table.Td>
<Table.Td>{convertNumber(item.grpc.networking_size.response_size, 1 / 1024, 4)} / {convertNumber(item.rest.networking_size.response_size, 1 / 1024, 4)}</Table.Td>
</Table.Tr>
))
return (
<Stack>
<Card padding="lg" radius="md" withBorder style={marginTopBottom}>
<Card.Section withBorder>
<Accordion variant="filled" chevronPosition="left" defaultValue="advanced-filter">
<Accordion.Item value="advanced-filter">
<Accordion.Control>
<Group>
<Text>Config</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<PerformanceTestFilter onChange={updateFilter} onClickStart={onClickStart} onClickStop={OnClickStop} onClickExportData={exportData}
isStarting={isStarting} currentCount={currentCount} requestStopping={requestStopping} isPushTest={isPushTest} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Card.Section>
<Card.Section withBorder>
<Charts data={performanceDataSnap.flatMap(item => item || []) as PerformanceGetListData[]}
gridSpan={chartsSize} hideServerDeserialize={hideServerDeserialize} hideServerSerialize={hideServerSerialize} />
</Card.Section>
<Card.Section>
<ResponsiveTableContainer minWidth={600}>
<Table striped highlightOnHover withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th colSpan={2}></Table.Th>
<Table.Th colSpan={3}>Client (gRPC / Rest)</Table.Th>
<Table.Th colSpan={2}>Server Deserialization (gRPC / Rest)</Table.Th>
<Table.Th colSpan={2}>Server Serialization (gRPC / Rest)</Table.Th>
<Table.Th colSpan={2}>Networking Pack Size (gRPC / Rest)</Table.Th>
</Table.Tr>
<Table.Tr key="header">
<Table.Th>Index</Table.Th>
<Table.Th>Count</Table.Th>
<Table.Th>Ping (ms)</Table.Th>
<Table.Th>CPU (%)</Table.Th>
<Table.Th>Time (s)</Table.Th>
<Table.Th>CPU (%)</Table.Th>
<Table.Th>Time (ms)</Table.Th>
<Table.Th>CPU (%)</Table.Th>
<Table.Th>Time (ms)</Table.Th>
<Table.Th>Request (KB)</Table.Th>
<Table.Th>Response (KB)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows}
</Table.Tbody>
</Table>
</ResponsiveTableContainer>
{/*<Pagination withEdges total={staffInfo.total_pages} value={currPage} onChange={setCurrPage} mt="sm"*/}
{/* style={{justifyItems: "flex-end", ...marginRightBottom}}/>*/}
</Card.Section>
{!isPushTest &&
<Card.Section>
<Space h="lg"/>
<Text ta="center" fw={700}>Example Books Data</Text>
<ScrollArea w="100%">
<BookTable books={(performanceDataSnap && performanceDataSnap.length > 0 ? performanceDataSnap[performanceDataSnap.length - 1].rest.response_data || [] : []) as BookInfo[]} />
</ScrollArea>
</Card.Section>
}
</Card>
</Stack>
)
}

View File

@ -0,0 +1,81 @@
import { Table } from "@mantine/core";
import type {BookInfo} from "~/utils/models.ts";
export const BookTable = ({ books }: {books: BookInfo[]}) => {
// 格式化发布日期
const formatDate = (timestamp: string) => {
const date = new Date(parseInt(timestamp) * 1000);
return date.toLocaleDateString();
};
// 生成表格行
const rows = books.map((book, index) => (
<Table.Tr key={book.isbn || index}>
<Table.Td>{book.title}</Table.Td>
<Table.Td>{book.subtitle}</Table.Td>
<Table.Td>{book.author}</Table.Td>
<Table.Td>{book.editor}</Table.Td>
<Table.Td>{book.translator}</Table.Td>
<Table.Td>{book.publisher}</Table.Td>
<Table.Td>{formatDate(book.publication_date)}</Table.Td>
<Table.Td>{book.isbn}</Table.Td>
<Table.Td>{book.barcode}</Table.Td>
<Table.Td>{book.language}</Table.Td>
<Table.Td>{book.pages}</Table.Td>
<Table.Td>{book.format}</Table.Td>
<Table.Td>{book.binding}</Table.Td>
<Table.Td>{book.edition}</Table.Td>
<Table.Td>{book.weight.toFixed(2)} g</Table.Td>
<Table.Td>{book.category_id}</Table.Td>
<Table.Td>{book.subject}</Table.Td>
<Table.Td style={{ maxWidth: '200px', wordBreak: 'break-word' }}>
{book.keywords}
</Table.Td>
<Table.Td style={{ maxWidth: '300px', wordBreak: 'break-word' }}>
{book.abstract}
</Table.Td>
<Table.Td style={{ maxWidth: '300px', wordBreak: 'break-word' }}>
{book.description}
</Table.Td>
<Table.Td>
<a href={book.cover_image} target="_blank" rel="noopener noreferrer">
View Image
</a>
</Table.Td>
</Table.Tr>
));
return (
<Table striped highlightOnHover withColumnBorders withTableBorder w="200%">
<Table.Thead>
<Table.Tr>
<Table.Th>Title</Table.Th>
<Table.Th>Subtitle</Table.Th>
<Table.Th>Author</Table.Th>
<Table.Th>Editor</Table.Th>
<Table.Th>Translator</Table.Th>
<Table.Th>Publisher</Table.Th>
<Table.Th>Publication Date</Table.Th>
<Table.Th>ISBN</Table.Th>
<Table.Th>Barcode</Table.Th>
<Table.Th>Language</Table.Th>
<Table.Th>Pages</Table.Th>
<Table.Th>Format</Table.Th>
<Table.Th>Binding</Table.Th>
<Table.Th>Edition</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Category ID</Table.Th>
<Table.Th>Subject</Table.Th>
<Table.Th>Keywords</Table.Th>
<Table.Th>Abstract</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Cover Image</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows}
</Table.Tbody>
</Table>
);
};

View File

@ -0,0 +1,152 @@
import {
Button, Checkbox, Flex,
Grid, Group,
NumberInput,
Progress, Slider, Stack, Text,
} from "@mantine/core";
import React, {useEffect, useMemo, useState} from "react";
import {flexLeftWeight, maxWidth} from "~/styles.ts";
import DownloadIcon from "mdi-react/DownloadIcon";
import PlayIcon from "mdi-react/PlayIcon";
import StopIcon from "mdi-react/StopIcon";
export function PerformanceTestFilter(
{onChange, onClickStart, onClickStop, onClickExportData, isStarting, currentCount, requestStopping, isPushTest}: {
onChange: (startsCount: number, endsCount: number, addCount: number, avgCount: number, maxExampleCount: number, hideServerDeserialize: boolean,
hideServerSerialize: boolean, chartsSize: number) => void,
onClickStart: () => void,
onClickStop: () => void,
onClickExportData: () => void,
isStarting: boolean,
requestStopping: boolean,
currentCount: number,
isPushTest: boolean
}
) {
const [startsCount, setStartsCount] = useState(200)
const [endsCount, setEndsCount] = useState(100000)
const [addCount, setAddCount] = useState(200)
const [avgCount, setAvgCount] = useState(3)
const [maxExampleCount, setMaxExampleCount] = useState(30)
const [hideServerDeserialize, setHideServerDeserialize] = useState(!isPushTest)
const [hideServerSerialize, setHideServerSerialize] = useState(isPushTest)
const [chartsSize, setChartsSize] = useState(6)
useEffect(() => {
onChange(startsCount, endsCount, addCount, avgCount, maxExampleCount, hideServerDeserialize, hideServerSerialize, chartsSize)
}, [startsCount, endsCount, addCount, avgCount, maxExampleCount, hideServerDeserialize, hideServerSerialize, chartsSize]);
const getCurrentProgress = (start: number, end: number, value: number) => {
if (end <= start) return 0;
return Math.min(Math.max(0, value - start), end - start) / (end - start) * 100;
}
const validChartsSizeValues = [1, 2, 3, 4, 6, 12];
return (
<Grid gutter="xs" grow>
<Grid.Col span={4} mb="md">
<NumberInput
label="Starts Count"
description="Starting number of records"
thousandSeparator=","
value={startsCount}
disabled={isStarting}
onChange={(value) => (setStartsCount(typeof value === 'number' ? value : parseInt(value)))}
/>
</Grid.Col>
<Grid.Col span={4} mb="md">
<NumberInput
label="Ends Count"
description="Maximum number of records"
max={110000}
thousandSeparator=","
value={endsCount}
disabled={isStarting}
onChange={(value) => (setEndsCount(typeof value === 'number' ? value : parseInt(value)))}
/>
</Grid.Col>
<Grid.Col span={4} mb="md">
<NumberInput
label="Step Count"
description="Records to add per test iteration"
thousandSeparator=","
value={addCount}
disabled={isStarting}
onChange={(value) => (setAddCount(typeof value === 'number' ? value : parseInt(value)))}
/>
</Grid.Col>
<Grid.Col span={6} mb="md">
<NumberInput
label="Iterations"
description="Number of times to repeat each test"
thousandSeparator=","
value={avgCount}
disabled={isStarting}
onChange={(value) => (setAvgCount(typeof value === 'number' ? value : parseInt(value)))}
/>
</Grid.Col>
{!isPushTest &&
<Grid.Col span={6} mb="md">
<NumberInput
label="Sample Limit"
description="Max records to display in preview"
thousandSeparator=","
value={maxExampleCount}
disabled={isStarting}
onChange={(value) => (setMaxExampleCount(typeof value === 'number' ? value : parseInt(value)))}
/>
</Grid.Col>
}
<Grid.Col span={7} mb="md">
<Text fw={500} size="sm">Charts Size</Text>
<Slider min={1} max={6} defaultValue={5}
marks={Array.from({ length: 6 }, (_, i) => ({
value: i + 1,
label: `${i + 1}`
}))}
value={validChartsSizeValues.indexOf(chartsSize) + 1} onChange={(value) => setChartsSize(validChartsSizeValues[value - 1])} />
</Grid.Col>
<Grid.Col span={3} mb="md">
<Stack h="95%" justify="flex-end">
<Group>
<Checkbox label="Hide Server Deserialize Info Chart" checked={hideServerDeserialize} defaultChecked={hideServerDeserialize}
onChange={(event) => setHideServerDeserialize(event.currentTarget.checked)}/>
<Checkbox label="Hide Server Serialize Info Chart" checked={hideServerSerialize} defaultChecked={hideServerSerialize}
onChange={(event) => setHideServerSerialize(event.currentTarget.checked)}/>
</Group>
</Stack>
</Grid.Col>
<Grid.Col span={12} mb="md">
<Flex align="center" gap="lg">
<Stack gap="1px" style={flexLeftWeight}>
<Text size="sm">{`${currentCount} / ${endsCount}`}</Text>
<Progress.Root size="xl">
<Progress.Section value={getCurrentProgress(startsCount, endsCount, currentCount)} animated={isStarting}>
<Progress.Label>{`${currentCount} / ${endsCount}`}</Progress.Label>
</Progress.Section>
</Progress.Root>
</Stack>
<Group>
{
isStarting
? <Button color="red" onClick={onClickStop} disabled={requestStopping} leftSection={<StopIcon />}>Stop</Button>
: <Button onClick={onClickStart} leftSection={<PlayIcon />}>Start</Button>
}
<Button leftSection={<DownloadIcon />} onClick={onClickExportData}>Export</Button>
</Group>
</Flex>
</Grid.Col>
</Grid>
)
}

View File

@ -0,0 +1,27 @@
import { type ReactNode, forwardRef } from 'react';
import { Table } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
type ResponsiveTableContainerProps = Omit<
React.ComponentProps<typeof Table.ScrollContainer>,
'children'
> & {
children: ReactNode;
};
export const ResponsiveTableContainer = forwardRef<
HTMLDivElement,
ResponsiveTableContainerProps
>(({ children, ...rest }, ref) => {
const isNarrow = useMediaQuery('(max-width: 700px)');
return isNarrow ? (
<Table.ScrollContainer ref={ref} {...rest}>
{children}
</Table.ScrollContainer>
) : (
<>{children}</>
);
});
ResponsiveTableContainer.displayName = 'ResponsiveTableContainer';

View File

@ -0,0 +1,41 @@
import {
ActionIcon,
Group, rem,
Tooltip,
useComputedColorScheme,
useMantineColorScheme,
useMantineTheme
} from "@mantine/core";
import {iconMStyle} from "~/styles";
import ThemeLightDarkIcon from "mdi-react/ThemeLightDarkIcon";
import WeatherNightIcon from "mdi-react/WeatherNightIcon";
import WeatherSunnyIcon from "mdi-react/WeatherSunnyIcon";
import type {CSSProperties} from "react";
export function ThemeToggle({extraStyle, iconStyle}: {extraStyle?: CSSProperties, iconStyle?: CSSProperties}) {
const { colorScheme, setColorScheme } = useMantineColorScheme();
// const [colorSchemeState, toggleColorSchemeState] = useToggle(['auto', 'dark', 'light'] as const);
const computedColorScheme = useComputedColorScheme('light');
const theme = useMantineTheme();
const nextScheme = colorScheme === "auto" ? "dark" : colorScheme === "dark" ? "light" : "auto";
const onClickChangeColorScheme = () => {
setColorScheme(nextScheme)
}
return (
<Group justify="center">
<Tooltip label={colorScheme === 'auto' ? 'Auto' : colorScheme === 'dark' ? 'Dark' : 'Light'} position="left">
<ActionIcon variant="light" size="md" onClick={onClickChangeColorScheme} color={
colorScheme === 'auto' ? undefined : computedColorScheme === 'dark' ? theme.colors.blue[4] : theme.colors.yellow[6]
} style={ extraStyle ? {...extraStyle} : {} }>
{colorScheme === 'auto' ? <ThemeLightDarkIcon style={iconStyle || iconMStyle}/> :
colorScheme === 'dark' ? <WeatherNightIcon style={iconStyle || iconMStyle}/> : <WeatherSunnyIcon style={iconStyle || iconMStyle}/>}
</ActionIcon>
</Tooltip>
</Group>
);
}

View File

@ -0,0 +1,152 @@
import {modals} from "@mantine/modals";
import {Button, Group, Stack, Text} from "@mantine/core";
import {showInfoMessage} from "~/utils/utils.ts";
import type {PerformanceGetListData} from "~/utils/models.ts";
export function confirmExportData(exportObject: PerformanceGetListData[], fileName: string) {
const onClickExportJson = (download: boolean) => {
const dataStr = JSON.stringify(exportObject, null, 4)
const blob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
if (download) {
a.download = `${fileName}_${Date.now()}.json`
}
else {
a.target = '_blank'
}
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showInfoMessage("Data exported successfully", "Export Data")
modals.closeAll()
}
const onClickExportCsv = () => {
const csvData = exportObject.map(item => {
// 处理 grpc 数据,去掉 response_data展开 networking_size
const grpcData = { ...item.grpc };
delete grpcData.response_data;
const grpcFlattened = {
...grpcData,
networking_size_request_size: grpcData.networking_size?.request_size || 0,
networking_size_response_size: grpcData.networking_size?.response_size || 0
};
// @ts-ignore
delete grpcFlattened.networking_size;
const restData = { ...item.rest };
delete restData.response_data;
const restFlattened = {
...restData,
networking_size_request_size: restData.networking_size?.request_size || 0,
networking_size_response_size: restData.networking_size?.response_size || 0
};
// @ts-ignore
delete restFlattened.networking_size;
// 组合最终的行数据
return {
idx: item.idx,
request_count: item.request_count,
grpc_client_networking_ping: grpcFlattened.client_networking_ping,
grpc_client_request_cpu: grpcFlattened.client_request_cpu,
grpc_client_request_time: grpcFlattened.client_request_time,
grpc_server_deserialize_cpu: grpcFlattened.server_deserialize_cpu,
grpc_server_deserialize_time: grpcFlattened.server_deserialize_time,
grpc_server_serialize_cpu: grpcFlattened.server_serialize_cpu,
grpc_server_serialize_time: grpcFlattened.server_serialize_time,
grpc_server_protocol_total_time: grpcFlattened.server_protocol_total_time,
grpc_networking_size_request_size: grpcFlattened.networking_size_request_size,
grpc_networking_size_response_size: grpcFlattened.networking_size_response_size,
rest_client_networking_ping: restFlattened.client_networking_ping,
rest_client_request_cpu: restFlattened.client_request_cpu,
rest_client_request_time: restFlattened.client_request_time,
rest_server_deserialize_cpu: restFlattened.server_deserialize_cpu,
rest_server_deserialize_time: restFlattened.server_deserialize_time,
rest_server_serialize_cpu: restFlattened.server_serialize_cpu,
rest_server_serialize_time: restFlattened.server_serialize_time,
rest_server_protocol_total_time: restFlattened.server_protocol_total_time,
rest_networking_size_request_size: restFlattened.networking_size_request_size,
rest_networking_size_response_size: restFlattened.networking_size_response_size,
};
});
const headers = [
'idx',
'request_count',
'grpc_client_networking_ping',
'grpc_client_request_cpu',
'grpc_client_request_time',
'grpc_server_deserialize_cpu',
'grpc_server_deserialize_time',
'grpc_server_serialize_cpu',
'grpc_server_serialize_time',
'grpc_server_protocol_total_time',
'grpc_networking_size_request_size',
'grpc_networking_size_response_size',
'rest_client_networking_ping',
'rest_client_request_cpu',
'rest_client_request_time',
'rest_server_deserialize_cpu',
'rest_server_deserialize_time',
'rest_server_serialize_cpu',
'rest_server_serialize_time',
'rest_server_protocol_total_time',
'rest_networking_size_request_size',
'rest_networking_size_response_size'
];
// 生成 CSV 内容
const csvContent = [
headers.join(','),
...csvData.map(row =>
headers.map(header => {
return row[header as keyof typeof row];
}).join(',')
)
].join('\n');
const BOM = '\uFEFF';
const csvWithBOM = BOM + csvContent;
const blob = new Blob([csvWithBOM], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName}_${Date.now()}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showInfoMessage("Data exported successfully", "Export Data");
modals.closeAll();
};
modals.open({
title: "Export Performance Test Data",
centered: true,
children: (
<Stack>
<Text>Export Performance Test Data</Text>
<Group>
<Button onClick={() => onClickExportJson(true)}>Download Json</Button>
<Button onClick={() => onClickExportJson(false)}>Preview Json</Button>
<Button onClick={() => onClickExportCsv()}>CSV</Button>
</Group>
<Group mt="md" justify="flex-end">
<Button onClick={() => modals.closeAll()}>Cancel</Button>
</Group>
</Stack>
)
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,80 @@
import type {Route} from "../../.react-router/types/app/pages/+types/Dashboard";
import {useDisclosure, useWindowScroll} from "@mantine/hooks";
import {Affix, AppShell, Button, Space, Stack, Transition} from "@mantine/core";
import {PageHeader} from "~/components/PageHeader.tsx";
import {PageNavbar} from "~/components/PageNavbar.tsx";
import {useEffect, useState} from "react";
import {Outlet, useLocation, useNavigate} from "react-router";
import {getEnumKeyByValue} from "~/utils/utils.ts";
import {apiPing} from "~/utils/compare_api.ts";
import ArrowUpIcon from "mdi-react/ArrowUpIcon";
import HomeIcon from "mdi-react/HomeIcon";
import GlobalAffix from "~/components/GlobalAffix.tsx";
import {DashboardPageType} from "~/utils/enums.ts";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Dashboard" },
{ name: "description", content: "Dashboard Main" },
];
}
export default function Component() {
const navigate = useNavigate();
const [currentStat, setCurrentStat] = useState(DashboardPageType.Home)
const [opened, { toggle }] = useDisclosure()
const location = useLocation();
const changePage = (pageType: DashboardPageType, navigateTo?: string) => {
// if (currentStat == pageType) return
setCurrentStat(pageType)
navigate(navigateTo || pageType)
}
const refreshMyInfo = () => {
apiPing()
.then((res => {
if (!res.success) {
navigate(DashboardPageType.Home)
}
}))
.catch(err => {
console.log("apiGetMyInfo failed", err)
navigate(DashboardPageType.Home)
})
}
useEffect(() => {
refreshMyInfo()
let currPage = getEnumKeyByValue(DashboardPageType, location.pathname)
if (currPage) {
setCurrentStat(DashboardPageType[currPage])
}
else {
const pathParts = location.pathname.split("/")
const path = pathParts.slice(0, pathParts.length - 1).join("/")
currPage = getEnumKeyByValue(DashboardPageType, path + "/")
if (currPage) {
setCurrentStat(DashboardPageType[currPage])
}
}
}, []);
return (
<AppShell header={{ height: 60 }} navbar={{width: 300, breakpoint: `sm`, collapsed: {mobile: !opened}}} padding="md">
<AppShell.Header p="md">
<PageHeader opened={opened} toggle={toggle} appendTitle={""}/>
</AppShell.Header>
<AppShell.Navbar p="md">
<PageNavbar currentStat={currentStat} changePageStat={changePage} />
</AppShell.Navbar>
<AppShell.Main>
<Outlet context={{ refreshMyInfo, changePage }} />
<Space h={50} />
<GlobalAffix />
</AppShell.Main>
</AppShell>
);
}

View File

@ -0,0 +1,117 @@
import type {Route} from "../../.react-router/types/app/pages/+types/HomePage";
import {flexLeftWeight, iconMStyle, marginRound, maxWidth} from "~/styles";
import {
Button,
em,
Flex,
Group,
Image,
Input,
MantineProvider,
PasswordInput,
Stack,
Text,
TextInput
} from "@mantine/core";
import {useForm} from "@mantine/form";
import {useEffect, useState} from "react";
import AccountIcon from "mdi-react/AccountIcon";
import LockIcon from "mdi-react/LockIcon";
import {Link} from "react-router";
import { useNavigate } from "react-router";
import {apiPing, apiPingWithPassword} from "~/utils/compare_api.ts";
import {showErrorMessage, showInfoMessage} from "~/utils/utils";
import {DashboardPageType} from "~/utils/enums.ts";
import GlobalAffix from "~/components/GlobalAffix.tsx";
import WebIcon from "mdi-react/WebIcon";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Login" },
{ name: "description", content: "Login page" },
];
}
export default function Component() {
const form = useForm({
initialValues: {
// serverAddress: 'http://127.0.0.1:9520',
serverAddress: 'https://api-grpc-vs-rest.yanfeng.uk',
password: ''
},
validate: {
serverAddress: (value) => (value.length == 0 ? "Input the server address." : null),
password: (value) => (value.length == 0 ? "Input your password." : null),
},
});
const [isLoggingIn, setIsLoggingIn] = useState(false);
const navigate = useNavigate();
const onClickLogin = (values: {serverAddress:string, password: string}) => {
setIsLoggingIn(true)
apiPingWithPassword(values.serverAddress, values.password)
.then((res) => {
if (!res.success) {
showErrorMessage(res.message, "Login failed")
return
}
else {
navigate(DashboardPageType.Dashboard)
}
})
.catch((e) => showErrorMessage(e.toString(), "Login error"))
.finally(() => setIsLoggingIn(false))
}
return (
<Flex gap="md" align="center" justify="center" p={em(20)} h="100vh" w="100vw">
<Stack align="stretch" justify="center" gap="md" style={{...flexLeftWeight, maxWidth: "800px"}}>
<Text size="1.5em" fw={700}>Log in</Text>
<form onSubmit={form.onSubmit((values) => onClickLogin(values))}>
<Stack gap="xs">
{/*<TextInput*/}
{/* withAsterisk*/}
{/* label="Username"*/}
{/* placeholder="Username"*/}
{/* leftSection={<AccountIcon style={iconMStyle}/>}*/}
{/* {...form.getInputProps('userName')}*/}
{/*/>*/}
<TextInput
withAsterisk
label="Test Server Address"
placeholder="Server Address"
leftSection={<WebIcon style={iconMStyle}/>}
{...form.getInputProps('serverAddress')}
/>
<PasswordInput
withAsterisk
label="Password"
placeholder="Password"
leftSection={<LockIcon style={iconMStyle}/>}
{...form.getInputProps('password')}
/>
<Text size="sm" c="dimmed" ta="right">
Forgot Password?
</Text>
<Group justify="space-between">
{/*<Button onClick={() => navigate(DashboardPageType.RegisterPage)}>Register</Button>*/}
<Button type="submit" disabled={isLoggingIn}>Login</Button>
</Group>
</Stack>
</form>
<GlobalAffix/>
</Stack>
</Flex>
)
}

View File

@ -0,0 +1,21 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/Home";
import {Badge, Button, Card, em, Grid, Group, Image, Modal, PasswordInput, Stack, Table, Text} from "@mantine/core";
import React from "react";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Home" },
{ name: "description", content: "Dashboard Home" },
];
}
export default function Component() {
return (
<Group justify="center" align="center">
<Stack align="center" justify="center">
<Text fw={700} size={em(28)}>Performance Test Server Architecture</Text>
<Image src="/server_architecture.png" w={1200}/>
</Stack>
</Group>
);
}

View File

@ -0,0 +1,40 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/Home";
import {Badge, Button, Card, em, Grid, Group, Image, Modal, PasswordInput, Stack, Table, Text} from "@mantine/core";
import React from "react";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Home" },
{ name: "description", content: "Dashboard Home" },
];
}
export default function Component() {
return (
<Group justify="center" align="center">
<Stack align="center" justify="center">
<Image src="/icon.png" w={400} h={400}/>
<Text fw={700} size={em(28)}>Performance Comparison of Communication Protocols in Microservices</Text>
<Text fw={500} size="xl">
<Text
size="xl"
fw={900}
variant="gradient"
gradient={{ from: 'teal', to: 'lime', deg: 90 }}
span
>
HTTP RESTful
</Text> vs <Text
size="xl"
fw={900}
variant="gradient"
gradient={{ from: 'yellow', to: 'orange', deg: 90 }}
span
>
gRPC
</Text>
</Text>
</Stack>
</Group>
);
}

View File

@ -0,0 +1,22 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/Page2";
import {useDisclosure} from "@mantine/hooks";
import {useState} from "react";
import {useNavigate} from "react-router";
import { useOutletContext } from 'react-router';
import type {OutletContextType} from "~/utils/models.ts";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Dashboard P2" },
{ name: "description", content: "Dashboard P2" },
];
}
export default function Component() {
const navigate = useNavigate();
const { refreshMyInfo, changePage } = useOutletContext<OutletContextType>();
return (
<h1>Dashboard Page2</h1>
);
}

View File

@ -0,0 +1,20 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/PerformanceTestGetList";
import React from "react";
import type {PerformanceGetListData} from "~/utils/models.ts";
import { proxy } from 'valtio';
import PerformanceTest from "~/components/PerformanceTest";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Performance Test GetList" },
{ name: "description", content: "Performance Test GetList" },
];
}
const performanceDataState = proxy<PerformanceGetListData[]>([]);
export default function Component() {
return (
<PerformanceTest isPushTest={false} performanceDataState={performanceDataState} />
)
}

View File

@ -0,0 +1,20 @@
import type {Route} from "../../../.react-router/types/app/pages/dashboard/+types/PerformanceTestPushData";
import React from "react";
import type {PerformanceGetListData} from "~/utils/models.ts";
import { proxy } from 'valtio';
import PerformanceTest from "~/components/PerformanceTest";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Performance Test PushData" },
{ name: "description", content: "Performance Test PushData" },
];
}
const performanceDataState = proxy<PerformanceGetListData[]>([]);
export default function Component() {
return (
<PerformanceTest isPushTest={true} performanceDataState={performanceDataState} />
)
}

View File

@ -0,0 +1,96 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/charts/styles.css';
import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core';
import {Notifications} from "@mantine/notifications";
import {ModalsProvider} from "@mantine/modals";
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import {theme} from "~/theme.ts";
dayjs.extend(customParseFormat);
// export const links: Route.LinksFunction = () => [
// { rel: "preconnect", href: "https://fonts.googleapis.com" },
// {
// rel: "preconnect",
// href: "https://fonts.gstatic.com",
// crossOrigin: "anonymous",
// },
// {
// rel: "stylesheet",
// href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
// },
// ];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" {...mantineHtmlProps}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<ColorSchemeScript />
<Meta />
<Links />
</head>
<body>
<MantineProvider theme={theme}>
<ModalsProvider>
<Notifications position="top-center" zIndex={1000} />
{children}
</ModalsProvider>
</MantineProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

View File

@ -0,0 +1,16 @@
import { type RouteConfig, index, route, prefix } from "@react-router/dev/routes";
export default [
index("pages/HomePage.tsx"),
// route("page2", "pages/Page2.tsx"),
route("dashboard", "pages/Dashboard.tsx", [
index("pages/dashboard/Home.tsx"),
route("test_get_list", "pages/dashboard/PerformanceTestGetList.tsx"),
route("test_push_data", "pages/dashboard/PerformanceTestPushData.tsx"),
route("about", "pages/dashboard/About.tsx"),
route("page2", "pages/dashboard/Page2.tsx"),
]),
] satisfies RouteConfig;

View File

@ -0,0 +1,53 @@
import type {CSSProperties} from "react";
import {rem} from "@mantine/core";
export const maxWidth: CSSProperties = {
width: "100%"
}
export const marginTopBottom: CSSProperties = {
marginTop: "1em",
marginBottom: "1em"
}
export const marginTop: CSSProperties = {
marginTop: "1em",
}
export const marginRightBottom: CSSProperties = {
marginRight: "1em",
marginBottom: "1em"
}
export const marginRound: CSSProperties = {
margin: "1em"
}
export const marginLeftRight: CSSProperties = {
marginLeft: "1em",
marginRight: "1em"
}
export const iconMStyle: CSSProperties = {
width: rem(18),
height: rem(18)
}
export const flexLeftWeight: CSSProperties = {
flex: 1
}
export const textCenter: CSSProperties = {
textAlign: 'center'
}
export const noColumnGap: CSSProperties = {
columnGap: "0"
}
export const roundThemeButton: CSSProperties = {
width: "55px",
height: "55px",
borderRadius: "55px",
padding: "0"
}

View File

@ -0,0 +1,5 @@
import { createTheme } from "@mantine/core";
export const theme = createTheme({
});

View File

@ -0,0 +1,54 @@
import type {
BaseRetData, PerformanceGetListData, PerformanceGetListResponse,
} from "./models"
import {sleep} from "~/utils/utils.ts";
// export const apiEndpoint = import.meta.env.VITE_API_ENDPOINT
export async function fetchAPI(path: string, method: string, body?: any, headers?: any, contentType: string | null = "application/json"): Promise<Response> {
const baseHeaders: { [key: string]: any } = {
"password": `${localStorage.getItem("password")}`,
}
if (contentType) {
baseHeaders["Content-Type"] = contentType
}
let reqHeaders: any
if (headers) {
reqHeaders = {...baseHeaders, ...headers}
}
else {
reqHeaders = baseHeaders
}
return new Promise((resolve, reject) => {
fetch(`${localStorage.getItem("testServerAddress")}/${path}`, {
method,
credentials: "include",
headers: reqHeaders,
body: contentType === "application/json" ? (body ? JSON.stringify(body) : undefined) : body,
})
.then((v) => resolve(v))
.catch((e) => reject(e))
})
}
export async function apiPing(): Promise<BaseRetData> {
const resp = await fetchAPI("ping", "GET")
return resp.json()
}
export async function apiPingWithPassword(serverAddress: string, password: string): Promise<BaseRetData> {
localStorage.setItem("testServerAddress", serverAddress)
localStorage.setItem("password", password)
return apiPing()
}
export async function apiPerformanceGetList(count: number, example_data_limit: number, idx: number): Promise<PerformanceGetListResponse> {
const resp = await fetchAPI(`performance_test_get_list?count=${count}&example_data_limit=${example_data_limit}&idx=${idx}`, "GET")
return resp.json()
}
export async function apiPerformanceUploadData(count: number, idx: number): Promise<PerformanceGetListResponse> {
const resp = await fetchAPI(`performance_test_add_books?count=${count}&idx=${idx}`, "GET")
return resp.json()
}

View File

@ -0,0 +1,7 @@
export enum DashboardPageType {
Home = "/",
About = "/dashboard/about",
Dashboard = "/dashboard",
DashboardTestGetList = "/dashboard/test_get_list",
DashboardTestPushData = "/dashboard/test_push_data",
}

View File

@ -0,0 +1,71 @@
import type {DashboardPageType} from "~/utils/enums.ts";
interface IKeyString {
[key: string]: any;
}
export interface BaseRetData extends IKeyString {
success: boolean,
message: string,
data?: any
}
export interface PerformanceGetListResponse extends BaseRetData {
data?: PerformanceGetListData
}
export interface PerformanceGetListData extends BaseRetData {
idx: number,
request_count: number,
grpc: PerformanceResultsGetList,
rest: PerformanceResultsGetList,
}
export interface PerformanceResultsGetList extends PerformanceResults {
response_data?: BookInfo[]
}
export interface PerformanceResults extends IKeyString {
client_networking_ping: number,
client_request_cpu: number,
client_request_time: number,
server_deserialize_cpu: number,
server_deserialize_time: number,
server_serialize_cpu: number,
server_serialize_time: number,
server_protocol_total_time: number,
networking_size: {
request_size: number,
response_size: number
},
response_data?: any
}
export interface BookInfo extends IKeyString {
"abstract": string
"author": string
"barcode": string
"binding": string
"category_id": number,
"cover_image": string
"description": string
"edition": string
"editor": string
"format": string
"isbn": string
"keywords": string
"language": string
"pages": number,
"publication_date": string
"publisher": string
"subject": string
"subtitle": string
"title": string
"translator": string
"weight": number
}
export interface OutletContextType {
refreshMyInfo: () => void;
changePage: (pageType: DashboardPageType, navigateTo?: string) => void;
}

View File

@ -0,0 +1,58 @@
import {notifications} from "@mantine/notifications";
export function showErrorMessage(msg: string, title: string = "Error", autoClose: boolean | number = 10000) {
console.log("ErrorMessage:", title, msg)
notifications.show({
title: title,
message: msg,
color: 'red',
autoClose: autoClose
})
}
export function showWarningMessage(msg: string, title: string = "Warning", autoClose: boolean | number = 10000) {
console.log("WarningMessage:", title, msg)
notifications.show({
title: title,
message: msg,
color: 'yellow',
autoClose: autoClose,
})
}
export function showInfoMessage(msg: string, title: string = "Info", autoClose: boolean | number = 10000) {
notifications.show({
title: title,
message: msg,
color: 'blue',
autoClose: autoClose,
})
}
export function getEnumKeyByValue<T extends Record<string, string>>(
enumObj: T,
value: string
): keyof T | undefined {
return (Object.keys(enumObj) as Array<keyof T>)
.find(k => enumObj[k] === value);
}
export function convertNumber(value: number, multiplier: number, decimalPlaces: number) {
if (!Number.isFinite(value) || !Number.isFinite(multiplier)) {
return "NaN"
}
if (!Number.isInteger(decimalPlaces) || decimalPlaces < 0) {
return "NaN"
}
const result = value * multiplier;
return result.toFixed(decimalPlaces);
}
export const sleep = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
{
"name": "rest-grpc-compare",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@mantine/charts": "^7.17.4",
"@mantine/core": "^7.17.4",
"@mantine/dates": "^7.17.4",
"@mantine/form": "^7.17.4",
"@mantine/hooks": "^7.17.4",
"@mantine/modals": "^7.17.4",
"@mantine/notifications": "^7.17.4",
"@react-router/node": "^7.5.0",
"@react-router/serve": "^7.5.0",
"@vitejs/plugin-react": "^4.4.0",
"dayjs": "^1.11.13",
"isbot": "^5.1.17",
"mdi-react": "^9.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.5.0",
"recharts": "^2.15.4",
"valtio": "^2.1.5"
},
"devDependencies": {
"@react-router/dev": "^7.5.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^20",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"react-router-devtools": "^1.1.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-tsconfig-paths": "^5.1.4"
},
"overrides": {
"react-is": "^19.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@ -0,0 +1,7 @@
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: false,
} satisfies Config;

View File

@ -0,0 +1,33 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["node", "vite/client"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"allowImportingTsExtensions": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
}
}

View File

@ -0,0 +1,8 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
});