ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Java+HTML手写简易数据库系统:从SQL解析到存储引擎实战

用Java+HTML手写简易数据库系统:从SQL解析到存储引擎实战 简介这是一份基于Java与HTML实现的简易数据库系统设计源码面向数据库初学者、计算机专业学生以及有轻量数据管理需求的开发者主要用于理解后端连接、查询、更新以及Web交互界面的基础实现方式。压缩包共29个文件约181KB其中13个Java源文件构成核心逻辑11个XML文件负责配置管理配合HTML页面、SQL初始化脚本、IDEA项目文件及许可说明整体结构清晰便于按模块查阅。已有304人学习下载。资源集中展示了一个迷你数据库管理系统的完整骨架从Java后端的数据访问、缓冲管理、目录管理到XML配置中的数据库连接参数再到SQL脚本与前端展示都有对应文件可对照学习对于想快速上手Java与HTML整合开发、梳理数据库系统基础模块的读者是一份轻量但完整的参考样例。1. 简易数据库系统不是玩具Java HTML 能造出一个能跑 SQL 的迷你数据库很多人在课程设计里拿到「基于Java与HTML的简易数据库系统设计源码」这个题目第一反应是去装 MySQL再用 Java 写个页面做增删改查。这不是题目想让你做的事它想让你把《数据库系统概论》里那张原理图亲手实现一遍SQL 怎么拆词、表结构怎么组织、数据怎么持久化最后用 HTML 页面把整个过程可视化。常见做法是用 JDK 自带的 HttpServer 当后端用文本文件或序列化文件存表数据做一个不依赖 MySQL 也能运行的迷你关系数据库。这个方向适合两类人正在学数据库系统概论、想把理论落到代码的学生以及缺一个能写进简历的 Java 实战项目、却不想只重复 SSM 业务的开发者。下文会从技术边界、存储引擎、HTTP 暴露、避坑经验到进阶技巧把一条可以照做的技术路线完整讲清楚。2. 先定技术边界为什么用 Java 写内核、HTML 当前端而不是直接连 MySQL2.1 简易数据库系统的核心模块拆解一个真正能跑 SQL 的简易数据库系统至少要有五个模块缺一个都会变成「换皮 CRUD」。它们分别是 SQL 解析器、表管理器、执行器、存储引擎和前端交互层。如果一开始不分层全挤在一个类里后面每加一个语句都要重构这就成了很多人做一半就放弃的原因。从输入输出看整个系统的链路是HTML 页面接收用户输入 → 把 SQL 字符串通过 HTTP 请求发给 Java 后端 → SQL 解析器把字符串拆成结构化指令 → 表管理器找到对应表 → 执行器完成操作 → 结果再原路返回给 HTML 渲染。这里面最关键的地方是SQL 解析和执行不能依赖任何数据库厂商代码否则项目的核心价值就没了。所谓「简易数据库系统」的设计重点就在区分谁负责什么。我在实际拆分时通常这样分SQL 解析器只负责把CREATE TABLE student (id INT, name VARCHAR)变成[CREATE, TABLE, student, (id, INT,, ...]这类 token它不关心存不存得进去执行器只认 token 的语义比如CREATE后面必须跟TABLE和表名存储引擎只处理字节流负责把表对象变成一个文件。这样每一层都能独立测试也方便后面加索引和事务。为什么不直接连 MySQL因为连接 MySQL 的方案里SQL 解析、事务、并发控制全部由厂商实现你学到的只是 JDBC 的prepareStatement用法而不是数据库系统的原理。而「数据库系统设计」这门课要求你去打开那些黑匣子。自己造一个哪怕只能执行五种语句的迷你库对原理的理解深度会远超调一年的 MySQL API。2.2 前后端通信方案选型HttpServer fetch 是最省事的一条路前后端通信的常见选项有三个用 Tomcat 跑 Servlet、用 Spring Boot 起接口、用 JDK 自带的com.sun.net.httpserver.HttpServer。前两者是工程化标准但对这个题目来说部署成本太高。为了演示一个源码包最好的方案是双击就能跑浏览器一打开就能用因此 HttpServer 是首选。HttpServer 是 JDK 自带模块不需要额外引入 jar 包。它支持createContext注册接口路径能处理 GET 和 POST还能设置响应头。唯一的短板是写法偏底层需要自己读请求体、手动设置字符编码但这也正好符合「简易数据库系统」的学习目标把 HTTP 细节暴露出来而不是让框架替你遮住。前端侧我建议用原生fetch而不是 jQuery。因为现在的 HTML 页面不需要引入任何第三方库一个script标签里写两段异步函数就能完成请求和渲染。整个前后端链路里只有一个接口/api/query输入是sqlCREATE TABLE ...输出是文本或 JSON。这种设计简单到新手也能在十分钟内跑通同时又不失前后端分离的雏形。很多同学会在这个点上纠结要不要上 Spring Boot我的观点是如果简历已经有两个 Spring Boot 项目这个简易数据库系统就不需要如果还没有请把精力放在数据库内核上。因为招聘方看这个项目的兴趣点在于「你实现了什么」而不是「你用了什么框架」。用最朴素的 HttpServer 写一个内核反而比用 Spring Boot 包一层更有辨识度。2.3 最小可行的源码工程结构用 VSCode 打开工程时目录不要设计得太复杂否则第一眼就把人吓退。我一般会用下面这个结构minidb/ ├── src/main/java/com/minidb/ │ ├── MiniDBServer.java // 启动HTTP服务接收前端请求 │ ├── SqlParser.java // 把SQL字符串拆成指令 │ ├── Database.java // 表管理器 执行器 │ ├── Table.java // 表结构和行数据的定义 │ └── StorageEngine.java // 负责把表对象写盘和读盘 ├── src/main/webapp/ │ └── index.html // 简易数据库管理页面 └── data/ // 表数据文件目录程序运行时自动创建这个结构的优点是每个文件的职责一眼能看出来。Table.java是纯数据模型不依赖数据库逻辑Database.java是核心持有所有表对象并处理增删改查StorageEngine.java单独负责文件 IO这样以后想从序列化改 JSON 存储只需要动一个类。data/目录是运行时产生的源码包里不需要预置任何表。第一次启动时Database.java构造方法会检查这个目录是否存在不存在就mkdirs()创建。目录名和端口号最好写成常量后面测试脚本会用到。这里有一个实操细节表数据文件的扩展名我统一用.tbl这样在文件管理器里看到student.tbl就知道是表数据不会和源代码文件混淆。3. 用 Java 实现一个能跑 SQL 的存储引擎从表结构到执行器3.1 表结构与行数据模型存储引擎的第一步是定义表结构。对于简易数据库字段类型不需要太多支持INT和VARCHAR两种就足够。行数据我推荐用ListObject[]而不是ListMapString,Object。原因是Object[]的字段顺序和表定义中的columns列表一一对应遍历时用row[colIndex]取值内存占用小序列化时也更干净。Table.java代码如下public class Table implements Serializable { public String name; public ListString columns new ArrayList(); public ListString types new ArrayList(); // INT 或 VARCHAR public ListObject[] rows new ArrayList(); public Table(String name) { this.name name; } public int columnIndex(String colName) { for (int i 0; i columns.size(); i) { if (columns.get(i).equalsIgnoreCase(colName)) { return i; } } return -1; } }这里让Table实现Serializable是直接为了把整个表对象写入文件。简易系统最省事的持久化方式就是 Java 原生序列化代码三行重启后能原样读回内存。它的代价是文件里可能有\u0000之类的二进制字符不方便肉眼查看但对课程设计完全够用。columnIndex是一个实用的辅助方法后续无论是WHERE name张三还是DELETE WHERE id1都要靠它把字段名转成下标。需要注意的是我希望字段名不区分大小写所以用了equalsIgnoreCase否则SELECT * FROM student WHERE Namex就会因为大小写差异查不到数据。3.2 SQL 拆词与解析解析器是整个项目里最容易翻车的部分。常见做法是trim().split(\\s)但这样会把INSERT INTO student VALUES (1, 张 三)中的张 三拆成张和三两个 token导致字段数对不上。正确做法是写一个带引号状态的 tokenizer遇到单引号就切换状态引号内的空格不再作为分隔符。下面是一个能处理字符串空格的最小实现import java.util.*; public class SqlParser { public static ListString tokenize(String sql) { String clean sql.trim(); if (clean.endsWith(;)) { clean clean.substring(0, clean.length() - 1); } ListString tokens new ArrayList(); StringBuilder sb new StringBuilder(); boolean inQuote false; for (char c : clean.toCharArray()) { if (c \) { inQuote !inQuote; if (!inQuote) { tokens.add(sb.toString().trim()); sb.setLength(0); continue; } sb.append(c); } else if (Character.isWhitespace(c) !inQuote) { if (sb.length() 0) { tokens.add(sb.toString()); sb.setLength(0); } } else { sb.append(c); } } if (sb.length() 0) { tokens.add(sb.toString().trim()); } return tokens; } }这段代码的核心逻辑是维护一个inQuote布尔值。当它不是引号状态时空白字符分割 token一旦进入引号状态空格就变成普通字符。INSERT INTO student VALUES (1, 张 三)最后会拆成INSERT、INTO、student、VALUES、(1,、张 三)字符串部分完整保留。这个解析器有个容易被忽略的细节它在引号结束符处直接截断所以张 三这个 token 里是包含前后单引号的后续执行器里处理值时需要用replace(, )去掉引号。这个设计是有意为之目的是让 token 携带原始上下文方便解析器判断一个字符串是字段名还是字面量。3.3 执行器INSERT、SELECT、DELETE 的落地实现解析器只负责把 SQL 变成 token真正的判断逻辑在Database.java的execute方法里。我用 switch 按第一个 token 分发到不同处理方法命令格式约定为全大写比较避免用户输入insert时无法识别。public class Database { private final MapString, Table tables new LinkedHashMap(); private final StorageEngine storage; public Database(String dataDir) throws IOException { this.storage new StorageEngine(dataDir); this.storage.loadAll(tables); } public String execute(String sql) { ListString t SqlParser.tokenize(sql); String cmd t.get(0).toUpperCase(); switch (cmd) { case CREATE: return createTable(t); case INSERT: return insertRow(t); case SELECT: return selectRows(t); case DELETE: return deleteRows(t); case DROP: return dropTable(t); default: return ERR: 未知SQL命令: cmd; } } private String selectRows(ListString t) { // SELECT * FROM student WHERE id1 String tableName t.get(3); Table table tables.get(tableName); if (table null) { return ERR: 表不存在 tableName; } StringBuilder out new StringBuilder(); for (Object[] row : table.rows) { boolean match true; if (t.size() 5 WHERE.equalsIgnoreCase(t.get(5))) { String[] kv t.get(6).split(); int colIdx table.columnIndex(kv[0]); String expect kv[1].replace(, ); String actual String.valueOf(row[colIdx]); if (table.types.get(colIdx).equals(INT)) { match Integer.parseInt(actual) Integer.parseInt(expect); } else { match actual.equals(expect); } } if (match) { out.append(String.join(,, toStrs(row))).append(\n); } } return out.length() 0 ? EMPTY : out.toString().trim(); } private String[] toStrs(Object[] row) { String[] s new String[row.length]; for (int i 0; i row.length; i) s[i] String.valueOf(row[i]); return s; } }上面代码中的selectRows有两个重要设计。第一WHERE条件被固定成字段值的形态不支持、这是简易边界后续要扩展只需要把split()改成按运算符拆分。第二INT字段比较时调用Integer.parseInt避免id2错误匹配到id10。这是面试官最喜欢追问的边界细节。execute方法返回 String 而不是void是为了让前端拿到后直接展示。当查询结果为空时返回EMPTY前端看到这个词就知道没有数据而不是一张空表。每执行完CREATE、INSERT、DELETE这种写操作我会紧接着调用storage.save(table)也就是「操作成功后就落盘」而不是等程序退出再统一写。3.4 持久化到文件什么时候写盘写盘格式StorageEngine 的核心只有 load 和 save。save 把整个表对象序列化到一个.tbl文件load 则扫描目录下所有.tbl文件反序列化后装载到tables映射表。public class StorageEngine { private final File dir; public StorageEngine(String dataDir) throws IOException { this.dir new File(dataDir); if (!dir.exists()) dir.mkdirs(); } public void save(Table table) throws IOException { File target new File(dir, table.name .tbl); try (ObjectOutputStream oos new ObjectOutputStream(new FileOutputStream(target))) { oos.writeObject(table); } } public void loadAll(MapString, Table tables) throws IOException { File[] files dir.listFiles((d, name) - name.endsWith(.tbl)); if (files null) return; for (File f : files) { try (ObjectInputStream ois new ObjectInputStream(new FileInputStream(f))) { Table t (Table) ois.readObject(); tables.put(t.name, t); } catch (ClassNotFoundException e) { throw new IOException(表文件损坏: f.getName(), e); } } } }这里必须强调一个血泪经验ObjectOutputStream写入同一个文件时不能做追加写。每次 save 要新建流、整体覆盖否则文件里会出现两套流头load 时直接抛StreamCorruptedException。这也是我把单个表放在单文件里的原因一次操作一个文件结构简单。写盘时机也很关键。简单做法是每次写操作都全量保存整张表数据量小的时候没问题如果一张表超过几千行性能会明显下降。简易系统不需要考虑那么大的量但建议在注释里写清楚「这里是全量写盘进阶时改成 append-only 日志」这样代码一看就有成长性。4. 用 HttpServer 和 HTML 把数据库变成网页应用4.1 启动一个不依赖 Tomcat 的 HTTP 服务端前后端打通的第一步是让 Java 程序能接收浏览器发来的 SQL。用 HttpServer 写一个主类绑定 8080 端口注册/api/query路由。这里用getRawQuery()而不是getQuery()因为getQuery()可能已经被容器解码过再手动URLDecoder会造成中文双重编码。import com.sun.net.httpserver.HttpServer; import java.net.InetSocketAddress; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; public class MiniDBServer { public static void main(String[] args) throws IOException { Database db new Database(./data); HttpServer server HttpServer.create(new InetSocketAddress(8080), 0); server.createContext(/api/query, exchange - { try { String reqBody new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); String sql reqBody.startsWith(sql) ? reqBody.substring(4) : reqBody; String result db.execute(sql); byte[] resp result.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set(Content-Type, text/plain; charsetutf-8); exchange.sendResponseHeaders(200, resp.length); exchange.getResponseBody().write(resp); } catch (Exception e) { String err ERR: e.getMessage(); byte[] resp err.getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(500, resp.length); exchange.getResponseBody().write(resp); } finally { exchange.close(); } }); server.start(); System.out.println(MiniDB started at http://localhost:8080); } }这段代码里有几个值得注意的参数。new InetSocketAddress(8080)的 8080 是服务端口如果被占用改成 9090 或 8081 即可。exchange.getRequestBody().readAllBytes()读取整个请求体适合 SQL 这种小体积请求。响应头里的charsetutf-8必须显式设置否则浏览器会用默认字符集解码中文会乱码。最外层 try-catch 是必须的。如果不捕获异常HttpServer的 handler 一旦抛出空指针或数组越界连接会被直接挂断浏览器只看到Failed to fetch而后端控制台的堆栈一闪而过排查起来非常痛苦。把异常转换成ERR:开头的结果返回问题才能暴露在页面上。4.2 HTML 页面通过 fetch 发送 SQL 并渲染结果前端页面不需要框架一个textarea输入 SQL一个按钮触发请求一个pre展示结果。为了支持中文输入meta charsetutf-8必须放在head的最前面并且使用 POST 方式提交因为 GET 的 URL 长度有上限一条长 INSERT 很容易被浏览器截断。!doctype html html langzh-cn head meta charsetutf-8 titleMiniDB 管理台/title /head body h3MiniDB SQL 执行器/h3 textarea idsql rows4 cols60SELECT * FROM student/textarea brbr button onclickrunSql()执行/button pre idresult等待执行…/pre script async function runSql() { const sql document.getElementById(sql).value; const resp await fetch(/api/query, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded; charsetutf-8 }, body: sql encodeURIComponent(sql) }); const text await resp.text(); document.getElementById(result).textContent text; } /script /body /htmlencodeURIComponent(sql)是必须的否则 SQL 里的空格、单引号、中文都会被浏览器转义乱掉。后端收到的请求体是一串sqlSELECT...格式的 URL 编码内容因此服务端解析时只做了substring(4)再去执行。这样做的好处是前端不需要知道后端内部结构后端也不关心页面长什么样。页面里的pre标签天然保留换行和空格查询结果按行展示时不会缩成一团。如果要进一步美化可以把结果拆成表格但初版直接用文本输出更利于调试。建议在这个阶段先跑通一条CREATE TABLE再跑一条SELECT确认返回内容不是ERR:开头再考虑渲染优化。4.3 结果集的 JSON 化与前后端字段约定文本响应虽然能跑但不够规范。为了让前端能区分「查询成功但没有数据」和「查询失败」我一般会把execute的结果包装成一个统一的响应结构。常见约定是{ ok: true, rows: [1,张三, 2,李四], message: 2 rows affected }失败时返回{ ok: false, rows: [], message: 表不存在 student }前端拿到ok字段后决定把message显示为提示还是把rows渲染成表格。这个约定很简单但能避免一个常见坑用文本输出时用户可能把ERR:当普通结果复制走而 JSON 结构天然隔离了错误信息。如果把execute改成返回 JSONMiniDBServer里的响应头要改成application/json; charsetutf-8同时Database.execute内部在 catch 里把错误信息填进message字段。这样一个接口既兼顾人眼可读又为后面接自动化测试留好了解析入口。5. 容易翻车的 5 个点简易数据库系统的常见问题与排查5.1 中文乱码明明设置了 UTF-8HTML 里还是问号现象HTML 页面输入INSERT INTO student VALUES (1, 张三)执行完再SELECT返回结果是??。原因乱码通常不在 HTML而在 Java 后端的字符编码链上。HttpServer的请求体和响应体默认按平台字符集解码如果 Windows 中文系统它会用 GBK 读字节流导致张三变成乱码后才存入表数据。解决请求体读取用new String(body, StandardCharsets.UTF_8)响应头显式设置charsetutf-8Table.java里所有字符串拼接不要再用默认编码的而是统一走String.format并依赖 IDE 文件编码为 UTF-8。另外VSCode 打开.java文件时右下角确认是 UTF-8如果是 GBK保存时会把中文重新编码这个问题会二次放大。5.2 SQL 末尾分号一条 INSERT 怎么都解析不过去现象在 HTML 的输入框里复制了一段INSERT INTO student VALUES (1, 张三);后端返回ERR: 未知SQL命令或t.get(3)越界。原因分号被 tokenizer 当成普通字符处理最后拆出的 token 是张三);导致VALUES后面逻辑崩溃。我见过很多人用split(;)去掉分号但这会误伤字符串内部包含分号的情况。解决只在 SQL 首尾处理分号clean sql.trim(); if (clean.endsWith(;))再截断。不要在中间阶段去除非末尾分号也不要用replaceAll(;, )全局替换。这个修复很小但对刚才的 tokenizer 来说是最后一公里。5.3 后端抛异常导致 HTTP 链接直接挂断现象前端fetch返回Failed to fetch浏览器控制台提示 CORS 或网络错误但后端进程还在跑。原因server.createContext的 handler 没有捕获异常一旦db.execute(sql)内部触发空指针HttpServer 会主动断开连接客户端收到的是网络层错误而不是业务层错误。解决在 handler 内层包一个try-catchcatch 到任何Exception都返回 500 和e.getMessage()。这一步除了让错误在页面上可见还能让 HTTP 连接正常关闭避免连接泄漏。我在自己调试时前端看到ERR: table.rows比看Failed to fetch高效十倍。5.4 写入覆盖两个页面同时操作数据丢了一半现象开了两个浏览器标签页同时执行 INSERT程序没报错但重启后只有最后一条数据留下来了。原因两个请求并发进入Database.execute都调用storage.save(table)而存储引擎没有加锁。后写的表对象把先写的行覆盖掉因为读内存里的table.rows是同一份数据写入时互相覆盖。解决给execute方法加上public synchronized String execute(String sql)。这样同一时间只有一个请求能操作数据库虽然牺牲了并发度但保证了「写操作→落盘」的原子性。对简易系统这是最合适的取舍也是面试时能讲清楚的一个并发控制案例。5.5 数值比较错乱你以为在比大小其实在比字典序现象SELECT * FROM student WHERE id2查出了id10的行或者DELETE WHERE id2删掉了不该删的行。原因执行器里用了String.valueOf(row[colIdx]).equals(expect)对INT类型没有做类型转换。字符串比较下10 ! 2是成立的但如果比较条件换成或10 9在字典序里也成立结果完全错乱。解决查询和删除前先查table.types.get(colIdx)如果是INT就Integer.parseInt再比较。这是字节码层面的类型意识也是迷你数据库和普通字符串处理最大的区别。我在 3.3 的代码里已经写了这个分支照着写就不会踩。6. 把简易库做成能写进简历的进阶redo log 和自动化验证6.1 给写操作加一条 append-only 日志当前架构是「内存改完 → 全量写 .tbl 文件」这个模式数据量大了会卡。一个低成本的改良是加 redo log每次写操作先把 SQL 原样 append 到data/redo.log然后再执行并写盘。程序启动时发现.tbl文件损坏可以逐行重放 redo log 恢复最后的状态。日志不需要复杂格式一行一条 SQL 就行成本低且面试效果很好。private void appendLog(String sql) throws IOException { Files.write(Paths.get(dataDir, redo.log), (sql \n).getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); }但要注意redo log 本身也有顺序问题落盘顺序必须和 execute 的同步锁保持一致。我习惯在execute的 synchronized 方法里先appendLog再走原有逻辑日志追加失败时直接抛异常不让半条 SQL 进入内存和表文件。6.2 用自动化验证脚本守住三条基本链路手动在浏览器里点按钮只能验证功能不能验证后续改动没有破坏旧逻辑。写一个几十行的 shell 脚本用 curl 依次执行 CREATE、INSERT、SELECT断言输出里是否包含预期字符串是我在这个项目上最后悔没有早点做的事情。#!/bin/bash basehttp://localhost:8080/api/query pass0 curl -s -X POST -d sqlCREATE TABLE test(id INT) $base curl -s -X POST -d sqlINSERT INTO test VALUES(1) $base result$(curl -s -X POST -d sqlSELECT * FROM test $base) if echo $result | grep -q ^1$; then echo SELECT PASS pass$((pass 1)) else echo SELECT FAIL: $result fi curl -s -X POST -d sqlDROP TABLE test $base /dev/null echo 通过用例: $pass脚本里的grep -q ^1$是精确匹配「单独一行 1」防止10或11误判。这个验证脚本最大的好处是每重构一次解析器或存储引擎跑一遍就能知道三条主链路有没有挂不用手动点页面。第一次写完代码就同步写好这个脚本后面省下的调试时间相当可观。我自己的教训是这个简易数据库系统的价值不在功能多而在核心链路能否稳定跑通。先把 CREATE / INSERT / SELECT / DELETE 收敛成最小语法再把前后端和持久化串起来最后才考虑事务和索引。把这个顺序反过来的人多半会在无关紧要的 UI 美化上耗掉大量时间。这套方案如果让你少走一段弯路希望帮到你。本文还有配套的精品资源点击获取
返回列表