作为一个长期从事Java Web开发的工程师,我最近完成了一个基于Spring Boot和HTML的书城阅读器系统。这个项目源于我观察到身边朋友对数字阅读日益增长的需求——他们希望有一个能随时随地访问、界面简洁且功能完善的在线阅读平台。
传统纸质书籍存在携带不便、存储空间占用大等问题,而市面上许多阅读平台要么功能臃肿,要么广告太多。因此,我决定开发一个轻量级但功能完备的书城系统,采用B/S架构让用户通过浏览器就能直接使用,无需安装额外应用。
这个系统最核心的价值在于:
选择Spring Boot作为后端框架是经过深思熟虑的。相比传统的Spring MVC,Spring Boot有以下几个显著优势:
我使用的是Spring Boot 2.3.4.RELEASE版本,搭配JDK1.8。数据库选择了MySQL 5.7,主要考虑因素包括:
数据库连接池使用了HikariCP,这是目前性能最好的Java连接池实现。ORM层采用MyBatis而非JPA,因为:
前端采用HTML+CSS+JavaScript基础技术栈,并引入Vue.js 2.x作为核心框架。Vue的选择基于以下考量:
没有选择React或Angular的原因是:
UI组件库使用了Element UI,它提供了丰富的现成组件,能显著加快开发速度。对于电子书阅读器核心功能,我自定义实现了:
整体采用经典的三层架构:
code复制表示层(HTML+CSS+JS)
↑↓
业务逻辑层(Spring Boot)
↑↓
数据访问层(MyBatis+MySQL)
安全方面集成了Apache Shiro,实现了:
API设计遵循RESTful风格,主要端点示例:
code复制GET /api/books - 获取图书列表
GET /api/books/{id} - 获取特定图书
POST /api/books - 新增图书
PUT /api/books/{id} - 更新图书
DELETE /api/books/{id} - 删除图书
用户系统采用了经典的邮箱+密码注册登录方式。密码存储使用BCrypt加密算法,这是目前最安全的密码哈希算法之一。
关键代码片段:
java复制// 注册时密码加密
public String register(User user) {
String hashedPassword = new BCryptPasswordEncoder().encode(user.getPassword());
user.setPassword(hashedPassword);
userMapper.insert(user);
return "注册成功";
}
// 登录验证
public String login(String email, String password) {
User user = userMapper.selectByEmail(email);
if(user == null || !new BCryptPasswordEncoder().matches(password, user.getPassword())) {
throw new AuthenticationException("用户名或密码错误");
}
// 生成token等后续操作
}
注意:永远不要在日志或响应中返回原始密码或加密后的密码,即使是用于调试。
电子书支持EPUB和PDF两种主流格式。EPUB解析使用了开源库epublib-core:
java复制// EPUB解析示例
public Book parseEpub(MultipartFile file) throws IOException {
EpubReader epubReader = new EpubReader();
Book book = epubReader.readEpub(file.getInputStream());
Book entity = new Book();
entity.setTitle(book.getMetadata().getFirstTitle());
entity.setAuthor(book.getMetadata().getAuthors().get(0));
// 提取第一章内容作为预览
Resource firstChapter = book.getResources().get(book.getSpine().getSpineReferences().get(0).getResourceId());
entity.setPreview(new String(firstChapter.getData()));
return entity;
}
PDF解析使用Apache PDFBox:
java复制// PDF解析示例
public String extractTextFromPdf(MultipartFile file) throws IOException {
PDDocument document = PDDocument.load(file.getInputStream());
PDFTextStripper stripper = new PDFTextStripper();
String text = stripper.getText(document);
document.close();
return text;
}
阅读器实现了以下关键功能:
javascript复制function calculatePages(content, wordsPerPage) {
const words = content.split(/\s+/);
const pageCount = Math.ceil(words.length / wordsPerPage);
const pages = [];
for(let i = 0; i < pageCount; i++) {
const start = i * wordsPerPage;
const end = start + wordsPerPage;
pages.push(words.slice(start, end).join(' '));
}
return pages;
}
java复制@PostMapping("/reading-progress")
public ResponseEntity<?> saveReadingProgress(
@RequestParam Long bookId,
@RequestParam int progress,
@AuthenticationPrincipal User user) {
ReadingProgress readingProgress = progressRepository
.findByUserIdAndBookId(user.getId(), bookId)
.orElse(new ReadingProgress());
readingProgress.setUserId(user.getId());
readingProgress.setBookId(bookId);
readingProgress.setProgress(progress);
readingProgress.setLastUpdated(new Date());
progressRepository.save(readingProgress);
return ResponseEntity.ok().build();
}
sql复制CREATE TABLE bookmarks (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
book_id BIGINT NOT NULL,
location VARCHAR(255) NOT NULL, -- 可以是百分比、章节号等
note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (book_id) REFERENCES books(id)
);
sql复制-- 为常用查询字段添加索引
CREATE INDEX idx_books_title ON books(title);
CREATE INDEX idx_books_author ON books(author);
CREATE INDEX idx_books_category ON books(category_id);
-- 复合索引
CREATE INDEX idx_user_book_progress ON reading_progress(user_id, book_id);
采用Redis作为缓存层,主要缓存:
Spring Cache配置示例:
java复制@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.disableCachingNullValues();
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
// 使用缓存
@Service
public class BookService {
@Cacheable(value = "books", key = "#id")
public Book getBookById(Long id) {
return bookMapper.selectById(id);
}
}
javascript复制// 图片懒加载
const lazyImages = document.querySelectorAll('img.lazy');
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if(entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
});
lazyImages.forEach(img => imageObserver.observe(img));
javascript复制// webpack.config.js
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
}
采用Docker容器化部署,docker-compose.yml示例:
yaml复制version: '3'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- DB_URL=jdbc:mysql://db:3306/bookstore
- DB_USER=root
- DB_PASSWORD=secret
depends_on:
- db
- redis
db:
image: mysql:5.7
environment:
- MYSQL_ROOT_PASSWORD=secret
- MYSQL_DATABASE=bookstore
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:alpine
volumes:
- redis_data:/data
volumes:
mysql_data:
redis_data:
properties复制# application.properties
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=always
java复制@Configuration
public class PrometheusConfig {
@Bean
MeterRegistryCustomizer<PrometheusMeterRegistry> configureMetricsRegistry() {
return registry -> registry.config().commonTags("application", "bookstore");
}
}
初期直接使用Spring MultipartFile处理大文件上传时经常出现内存溢出。解决方案:
properties复制# application.properties
spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=100MB
javascript复制// 前端分片上传
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunks = Math.ceil(file.size / chunkSize);
for(let i = 0; i < chunks; i++) {
const start = i * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('chunkNumber', i);
formData.append('totalChunks', chunks);
formData.append('originalFilename', file.name);
await axios.post('/upload', formData);
}
java复制@PostMapping("/upload")
public ResponseEntity<?> uploadChunk(
@RequestParam MultipartFile file,
@RequestParam int chunkNumber,
@RequestParam int totalChunks,
@RequestParam String originalFilename) throws IOException {
String tempDir = System.getProperty("java.io.tmpdir") + "/uploads/";
Files.createDirectories(Paths.get(tempDir));
String tempFilename = originalFilename + ".part" + chunkNumber;
file.transferTo(new File(tempDir + tempFilename));
// 检查是否所有分片都已上传
if(isUploadComplete(tempDir, originalFilename, totalChunks)) {
mergeFiles(tempDir, originalFilename, totalChunks);
return ResponseEntity.ok().body("上传完成");
}
return ResponseEntity.ok().body("分片上传成功");
}
不同设备上阅读体验不一致的问题通过以下方式解决:
css复制/* 移动端适配 */
@media (max-width: 768px) {
.reader-container {
padding: 10px;
}
.page-controls {
flex-direction: column;
}
}
css复制body {
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, Oxygen-Sans,
Ubuntu, Cantarell, "Helvetica Neue",
sans-serif;
}
javascript复制// 检测浏览器支持的特性
function supportsFeature(feature) {
switch(feature) {
case 'flexbox':
return 'flex' in document.documentElement.style;
case 'webp':
return document.createElement('canvas').toDataURL('image/webp').indexOf('data:image/webp') === 0;
default:
return false;
}
}
使用Shiro实现RBAC模型:
java复制@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(securityManager);
Map<String, String> filterChain = new LinkedHashMap<>();
filterChain.put("/api/auth/**", "anon");
filterChain.put("/api/**", "authc");
filterChain.put("/admin/**", "roles[admin]");
factoryBean.setFilterChainDefinitionMap(filterChain);
return factoryBean;
}
java复制@Bean
public FilterRegistrationBean<XssFilter> xssFilter() {
FilterRegistrationBean<XssFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new XssFilter());
registration.addUrlPatterns("/*");
registration.setName("xssFilter");
return registration;
}
java复制@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
经过三个月的开发和优化,这个书城阅读器系统已经具备了核心的阅读功能和完善的用户体验。在开发过程中,我深刻体会到:
未来计划扩展的功能包括:
这个项目让我对现代Web开发有了更深入的理解,特别是在前后端分离架构和响应式设计方面积累了宝贵经验。希望这个案例能为准备开发类似系统的开发者提供一些参考价值。