长文干货 | 手写自定义持久层框架!
Baoxing
读完需要
速读仅需 38 分钟
为何要手写自定义持久层框架?
JDBC 编码的弊端
会造成硬编码问题(无法灵活切换数据库驱动) 频繁创建和释放数据库连接造成系统资源浪费 影响系统性能 sql 语句存在硬编码,造成代码不易维护,实际应用中 sql 变化可能较大,变动 sql 需要改 Java 代码 使用 preparedStatement 向占有位符号传参数存在硬编码, 因 sql 语句的 where 条件不确定甚至没有where条件,修改 sql 还要修改代码 系统不易维护 对结果集解析也存在硬编码, sql变化导致解析代码变化
更有助于读 mybatis 持久层框架源码
JDBC代码
public class jdbcConnection {
private static Connection connection = null;
private static PreparedStatement preparedStatement = null;
private static ResultSet resultSet = null;
public static void main(String[] args) {
try {
// 加载数据库驱动
Class.forName("com.mysql.jdbc.Driver");
// 通过驱动管理类获取数据库连接
connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/huodd", "root", "1234");
// 定义sql语句 ? 表示占位符
String sql = "select id,username from user where id = ?";
// 获取预处理对象 statement
PreparedStatement preparedStatement = (PreparedStatement) connection.prepareStatement(sql);
// 设置参数 第一个参数为 sql 语句中参数的序号(从1开始) 第二个参数为 设置的参数值
preparedStatement.setInt(1, 1);
// 向数据库发出sql执行查询 查询出结果集
resultSet = preparedStatement.executeQuery();
// 遍历查询结果集
while (resultSet.next()) {
int id = resultSet.getInt("id");
String username = resultSet.getString("username");
// 封装对象
User user = new User();
user.setId(id);
user.setUsername(username);
System.out.println(user);
}
} catch (Exception ex) {
ex.printStackTrace();
} finally {
try {
// 释放资源
if (resultSet != null) {
resultSet.close();
}
if (preparedStatement != null) {
preparedStatement.close();
}
if (connection != null) {
connection.close();
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}
解决问题的思路
数据库频繁创建连接、释放资源 -> 连接池 sql语句及参数硬编码 -> 配置文件 手动解析封装结果集 -> 反射、内省
编码前思路整理
创建、读取配置文件
sqlMapConfig.xml 存放数据库配置信息 userMapper.xml :存放sql配置信息 根据配置文件的路径,加载配置文件成字节输入流,存储在内存中Resources#getResourceAsStream(String path) 创建两个JavaBean存储配置文件解析出来的内容 Configuration :核心配置类 ,存放 sqlMapConfig.xml解析出来的内容 MappedStatement:映射配置类:存放mapper.xml解析出来的内容
解析配置文件(使用dom4j)
创建类:SqlSessionFactoryBuilder#build(InputStream in) -> 设计模式之构建者模式 使用dom4j解析配置文件,将解析出来的内容封装到容器对象(JavaBean)中
创建 SqlSessionFactory 接口及实现类DefaultSqlSessionFactory
SqlSessionFactory对象,生产sqlSession会话对象 -> 设计模式之工厂模式
创建 SqlSession接口及实现类DefaultSqlSession
定义对数据库的CRUD操作 selectList() selectOne() update() delete()
创建Executor接口及实现类SimpleExecutor实现类
query(Configuration configuration, MappedStatement mapStatement, Object... orgs) 执行的就是JDBC代码
测试代码
用到的设计模式
构建者模式 工厂模式 代理模式
进入编码
1.创建、读取配置文件
sqlMapConfig.xml 存放数据库配置信息
<configuration>
<dataSource>
<property name="driverClass" value="com.mysql.jdbc.Driver">property>
<property name="jdbcUrl" value="jdbc:mysql:///huodd">property>
<property name="user" value="root">property>
<property name="password" value="1234">property>
dataSource>
<mapper resource="userMapper.xml">mapper>
configuration>
userMapper.xml 存放sql配置信息
<mapper namespace="user">
<select id="selectList" resultType="com.huodd.pojo.User" paramterType="com.huodd.pojo.User">
select * from user
select>
<select id="selectOne" paramterType="com.huodd.pojo.User" resultType="com.huodd.pojo.User">
select * from user where id = #{id} and username =#{username}
select>
mapper>
User.java
public class User {
private Integer id;
private String username;
... 省略getter setter 方法
... 省略 toString 方法
}
pom.xml 中引入依赖
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<version>5.1.17version>
dependency>
<dependency>
<groupId>c3p0groupId>
<artifactId>c3p0artifactId>
<version>0.9.1.2version>
dependency>
<dependency>
<groupId>log4jgroupId>
<artifactId>log4jartifactId>
<version>1.2.12version>
dependency>
<dependency>
<groupId>junitgroupId>
<artifactId>junitartifactId>
<version>4.10version>
dependency>
<dependency>
<groupId>dom4jgroupId>
<artifactId>dom4jartifactId>
<version>1.6.1version>
dependency>
<dependency>
<groupId>jaxengroupId>
<artifactId>jaxenartifactId>
<version>1.1.6version>
dependency>
创建两个JavaBean对象 用于存储解析的配置文件的内容(Configuration.java、MappedStatement.java)
public class Configuration {
// 数据源
private DataSource dataSource;
//map集合 key:statementId value:MappedStatement
private Map mappedStatementMap = new HashMap<>();
... 省略getter setter 方法
}
public class MappedStatement {
// id
private String id;
// sql 语句
private String sql;
// 参数值类型
private Class> paramterType;
// 返回值类型
private Class> resultType;
... 省略getter setter 方法
}
创建Resources工具类 并编写静态方法getResourceAsSteam(String path)
public class Resources {
/**
* 根据配置文件的路径 将配置文件加载成字节输入流 存储在内存中
* @param path
* @return InputStream
*/
public static InputStream getResourceAsStream(String path) {
InputStream resourceAsStream = Resources.class.getClassLoader().getResourceAsStream(path);
return resourceAsStream;
}
}
2.解析配置文件(使用dom4j)
创建 SqlSessionFactoryBuilder类 并添加 build 方法
public class SqlSessionFactoryBuilder {
public SqlSessionFactory build (InputStream in) throws DocumentException, PropertyVetoException, ClassNotFoundException {
// 1. 使用 dom4j 解析配置文件 将解析出来的内容封装到Configuration中
XMLConfigerBuilder xmlConfigerBuilder = new XMLConfigerBuilder();
// configuration 是已经封装好了sql信息和数据库信息的对象
Configuration configuration = xmlConfigerBuilder.parseConfig(in);
// 2. 创建 SqlSessionFactory 对象 工厂类 主要是生产sqlSession会话对象
DefaultSqlSessionFactory defaultSqlSessionFactory = new DefaultSqlSessionFactory(configuration);
return defaultSqlSessionFactory;
}
}
public class XMLConfigerBuilder {
private Configuration configuration;
public XMLConfigerBuilder() {
this.configuration = new Configuration();
}
/**
* 该方法 使用dom4j对配置文件进行解析 封装Configuration
* @param in
* @return
*/
public Configuration parseConfig (InputStream in) throws DocumentException, PropertyVetoException, ClassNotFoundException {
Document document = new SAXReader().read(in);
//
Element rootElement = document.getRootElement();
List propertyElements = rootElement.selectNodes("//property");
Properties properties = new Properties();
for (Element propertyElement : propertyElements) {
properties.setProperty(propertyElement.attributeValue("name"), propertyElement.attributeValue("value"));
}
// 连接池
ComboPooledDataSource comboPooledDataSource = new ComboPooledDataSource();
comboPooledDataSource.setDriverClass(properties.getProperty("driverClass"));
comboPooledDataSource.setJdbcUrl(properties.getProperty("jdbcUrl"));
comboPooledDataSource.setUser(properties.getProperty("user"));
comboPooledDataSource.setPassword(properties.getProperty("password"));
// 填充 configuration
configuration.setDataSource(comboPooledDataSource);
// mapper 部分 拿到路径 -> 字节输入流 -> dom4j进行解析
List mapperElements = rootElement.selectNodes("//mapper");
XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(configuration);
for (Element mapperElement : mapperElements) {
String mapperPath = mapperElement.attributeValue("resource");
InputStream resourceAsStream = Resources.getResourceAsStream(mapperPath);
xmlMapperBuilder.parse(resourceAsStream);
}
return configuration;
}
}
public class XMLMapperBuilder {
private Configuration configuration;
public XMLMapperBuilder(Configuration configuration) {
this.configuration = configuration;
}
public void parse(InputStream inputStream) throws DocumentException, ClassNotFoundException {
Document document = new SAXReader().read(inputStream);
//
Element rootElement = document.getRootElement();
String namespace = rootElement.attributeValue("namespace");
List select = rootElement.selectNodes("//select");
for (Element element : select) {
// 获取 id 的值
String id = element.attributeValue("id");
String paramterType = element.attributeValue("paramterType");
String resultType = element.attributeValue("resultType");
// 输入参数 class
Class> paramterTypeClass = getClassType(paramterType);
// 返回结果 class
Class> resultTypeClass = getClassType(resultType);
// sql 语句
String sqlStr = element.getTextTrim();
// 封装 mappedStatement
MappedStatement mappedStatement = new MappedStatement();
mappedStatement.setId(id);
mappedStatement.setParamterType(paramterTypeClass);
mappedStatement.setResultType(resultTypeClass);
mappedStatement.setSql(sqlStr);
// statementId
String key = namespace + "." + id;
// 填充 configuration
configuration.getMappedStatementMap().put(key, mappedStatement);
}
}
private Class> getClassType(String paramterType) throws ClassNotFoundException {
Class> aClass = Class.forName(paramterType);
return aClass;
}
}
3.创建 SqlSessionFactory 接口及实现类DefaultSqlSessionFactory
public interface SqlSessionFactory {
SqlSession openSession();
}
public class DefaultSqlSessionFactory implements SqlSessionFactory {
private Configuration configuration;
public DefaultSqlSessionFactory(Configuration configuration) {
this.configuration = configuration;
}
@Override
public SqlSession openSession() {
return new DefaultSqlSession(configuration);
}
}
4. 创建 SqlSession接口及实现类DefaultSqlSession
public interface SqlSession {
List selectList(String statementId, Object... param) throws Exception ;
T selectOne(String statementId, Object... params) throws Exception;
void close() throws SQLException;
}
public class DefaultSqlSession implements SqlSession {
private Configuration configuration;
// 处理器对象
private Executor simpleExcutor = new SimpleExecutor();
public DefaultSqlSession(Configuration configuration) {
this.configuration = configuration;
}
@Override
public List selectList(String statementId, Object... param) throws Exception {
// 完成对 simpleExcutor里的query方法的调用
MappedStatement mappedStatement = configuration.getMappedStatementMap().get(statementId);
List list = simpleExcutor.query(configuration, mappedStatement, param);
return list;
}
@Override
public T selectOne(String statementId, Object... params) throws Exception {
List
5.创建Executor接口及实现类SimpleExecutor实现类
public interface Executor {
List query(Configuration configuration, MappedStatement mappedStatement, Object... param) throws Exception ;
void close() throws SQLException;
}
public class SimpleExecutor implements Executor {
private Connection connection = null;
@Override
public List query(Configuration configuration, MappedStatement mappedStatement, Object... param) throws Exception {
// 注册驱动 获取连接
connection = configuration.getDataSource().getConnection();
// select * from user where id = #{id} and username = #{username}
String sql = mappedStatement.getSql();
// 对 sql 进行处理
BoundSql boundSql = getBoundSql(sql);
// select * from where id = ? and username = ?
String finalSql = boundSql.getSqlText();
// 获取传入参数类对象
Class> paramterTypeClass = mappedStatement.getParamterType();
// 获取预处理 preparedStatement 对象
PreparedStatement preparedStatement = connection.prepareStatement(finalSql);
// 设置参数
List parameterMappingList = boundSql.getParameterMappingList();
for (int i = 0; i < parameterMappingList.size(); i++) {
ParameterMapping parameterMapping = parameterMappingList.get(i);
String name = parameterMapping.getContent();
// 反射 获取某一个属性对象
Field declaredField = paramterTypeClass.getDeclaredField(name);
// 设置暴力访问
declaredField.setAccessible(true);
// 参数传递的值
Object o = declaredField.get(param[0]);
// 给占位符赋值
preparedStatement.setObject(i + 1, o);
}
// 执行sql
ResultSet resultSet = preparedStatement.executeQuery();
// 封装返回结果集
// 获取返回参数类对象
Class> resultTypeClass = mappedStatement.getResultType();
ArrayList results = new ArrayList<>();
while (resultSet.next()) {
// 取出 resultSet的元数据
ResultSetMetaData metaData = resultSet.getMetaData();
E o = (E) resultTypeClass.newInstance();
int columnCount = metaData.getColumnCount();
for (int i = 1; i <= columnCount; i++) {
// 属性名/字段名
String columnName = metaData.getColumnName(i);
// 属性值/字段值
Object value = resultSet.getObject(columnName);
// 使用反射或者内省 根据数据库表和实体的对应关系 完成封装
// 创建属性描述器 为属性生成读写方法
PropertyDescriptor propertyDescriptor = new PropertyDescriptor(columnName, resultTypeClass);
// 获取写方法
Method writeMethod = propertyDescriptor.getWriteMethod();
// 向类中写入值
writeMethod.invoke(o, value);
}
results.add(o);
}
return results;
}
/**
* 转换sql语句 完成对 #{} 的解析工作
* 1. 将 #{} 使用?进行代替
* 2. 解析出 #{} 里面的值进行存储
*
* @param sql 转换前的原sql
* @return
*/
private BoundSql getBoundSql(String sql) {
// 标记处理类: 主要是配合通用解析器 GenericTokenParser 类完成对配置文件等的解析工作 其中TokenHandler 主要完成处理
ParameterMappingTokenHandler parameterMappingTokenHandler = new ParameterMappingTokenHandler();
// GenericTokenParser: 通用的标记解析器 完成了代码片段中的占位符的解析 然后根据给定的标记处理器( TokenHandler ) 来进行表达式的处理
// 三个参数: 分别为 openToken (开始标记)、 closeToken (结束标记)、 handler (标记处理器)
GenericTokenParser genericTokenParse = new GenericTokenParser("#{", "}", parameterMappingTokenHandler);
// 解析出来的sql
String parseSql = genericTokenParse.parse(sql);
// #{} 里面解析出来的参数名称
List parameterMappings = parameterMappingTokenHandler.getParameterMappings();
BoundSql boundSql = new BoundSql(parseSql, parameterMappings);
return boundSql;
}
@Override
public void close() throws SQLException {
connection.close();
}
}
public class BoundSql {
// 解析过后的 sql 语句
private String sqlText;
// 解析出来的参数
private List parameterMappingList = new ArrayList<>();
// 有参构造方便创建时赋值
public BoundSql(String sqlText, List parameterMappingList) {
this.sqlText = sqlText;
this.parameterMappingList = parameterMappingList;
}
... 省略getter setter 方法
}
6.测试代码
public class IPersistenceTest {
@Test
public void test () throws Exception {
InputStream resourceAsStream = Resources.getResourceAsStream("sqlMapConfig.xml");
SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);
SqlSession sqlSession = sessionFactory.openSession();
User user = new User();
user.setId(1);
user.setUsername("bd2star");
User res = sqlSession.selectOne("user.selectOne", user);
System.out.println(res);
// 关闭资源
sqlSession.close()
}
}
运行结果如下
User{id=1, username='bd2star'}
测试通过 调整代码
创建 接口 Dao及实现类
public interface IUserDao {
// 查询所有用户
public List selectList() throws Exception ;
// 根据条件进行用户查询
public User selectOne(User user) throws Exception;
}
public class UserDaoImpl implements IUserDao {
@Override
public List findAll() throws Exception {
InputStream resourceAsStream = Resources.getResourceAsStream("sqlMapConfig.xml");
SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);
SqlSession sqlSession = sessionFactory.openSession();
List res = sqlSession.selectList("user.selectList");
sqlSession.close();
return res;
}
@Override
public User findByCondition(User user) throws Exception {
InputStream resourceAsStream = Resources.getResourceAsStream("sqlMapConfig.xml");
SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);
SqlSession sqlSession = sessionFactory.openSession();
User res = sqlSession.selectOne("user.selectOne", user);
sqlSession.close();
return res;
}
}
调整测试方法
public class IPersistenceTest {
@Test
public void test () throws Exception {
User user = new User();
user.setId(1);
user.setUsername("bd2star");
IUserDao userDao = new UserDaoImpl();
User res = userDao.findByCondition(user);
System.out.println(res);
}
}
运行结果如下
User{id=1, username='bd2star'}
测试通过
7.补充
huodd.sql
--新建数据库
CREATE DATABASE huodd;
--使用数据库
use huodd;
--创建表
CREATE TABLE `user` (
`id` int(11) NOT NULL,
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Compact;
-- 插入测试数据
INSERT INTO `user` VALUES (1, 'bd2star');
INSERT INTO `user` VALUES (2, 'bd3star');
用到的工具类
GenericTokenParser.java
public class GenericTokenParser {
private final String openToken; //开始标记
private final String closeToken; //结束标记
private final TokenHandler handler; //标记处理器
public GenericTokenParser(String openToken, String closeToken, TokenHandler handler) {
this.openToken = openToken;
this.closeToken = closeToken;
this.handler = handler;
}
/**
* 解析${}和#{}
* @param text
* @return
* 该方法主要实现了配置文件、脚本等片段中占位符的解析、处理工作,并返回最终需要的数据。
* 其中,解析工作由该方法完成,处理工作是由处理器handler的handleToken()方法来实现
*/
public String parse(String text) {
// 验证参数问题,如果是null,就返回空字符串。
if (text == null || text.isEmpty()) {
return "";
}
// 下面继续验证是否包含开始标签,如果不包含,默认不是占位符,直接原样返回即可,否则继续执行。
int start = text.indexOf(openToken, 0);
if (start == -1) {
return text;
}
// 把text转成字符数组src,并且定义默认偏移量offset=0、存储最终需要返回字符串的变量builder,
// text变量中占位符对应的变量名expression。判断start是否大于-1(即text中是否存在openToken),如果存在就执行下面代码
char[] src = text.toCharArray();
int offset = 0;
final StringBuilder builder = new StringBuilder();
StringBuilder expression = null;
while (start > -1) {
// 判断如果开始标记前如果有转义字符,就不作为openToken进行处理,否则继续处理
if (start > 0 && src[start - 1] == '\\') {
builder.append(src, offset, start - offset - 1).append(openToken);
offset = start + openToken.length();
} else {
//重置expression变量,避免空指针或者老数据干扰。
if (expression == null) {
expression = new StringBuilder();
} else {
expression.setLength(0);
}
builder.append(src, offset, start - offset);
offset = start + openToken.length();
int end = text.indexOf(closeToken, offset);
while (end > -1) {////存在结束标记时
if (end > offset && src[end - 1] == '\\') {//如果结束标记前面有转义字符时
// this close token is escaped. remove the backslash and continue.
expression.append(src, offset, end - offset - 1).append(closeToken);
offset = end + closeToken.length();
end = text.indexOf(closeToken, offset);
} else {//不存在转义字符,即需要作为参数进行处理
expression.append(src, offset, end - offset);
offset = end + closeToken.length();
break;
}
}
if (end == -1) {
// close token was not found.
builder.append(src, start, src.length - start);
offset = src.length;
} else {
//首先根据参数的key(即expression)进行参数处理,返回?作为占位符
builder.append(handler.handleToken(expression.toString()));
offset = end + closeToken.length();
}
}
start = text.indexOf(openToken, offset);
}
if (offset < src.length) {
builder.append(src, offset, src.length - offset);
}
return builder.toString();
}
}
ParameterMapping.java
public class ParameterMapping {
private String content;
public ParameterMapping(String content) {
this.content = content;
}
... 省略getter setter 方法
}
ParameterMappingTokenHandler.java
public class ParameterMappingTokenHandler implements TokenHandler {
private List parameterMappings = new ArrayList();
// context是参数名称 #{id} #{username}
public String handleToken(String content) {
parameterMappings.add(buildParameterMapping(content));
return "?";
}
private ParameterMapping buildParameterMapping(String content) {
ParameterMapping parameterMapping = new ParameterMapping(content);
return parameterMapping;
}
public List getParameterMappings() {
return parameterMappings;
}
public void setParameterMappings(List parameterMappings) {
this.parameterMappings = parameterMappings;
}
}
TokenHandler.java
public interface TokenHandler {
String handleToken(String content);
}
继续优化自定义框架
通过上述自定义框架,我们解决了JDBC操作数据库带来的一些问题,例如频繁创建释放数据库连接,硬编码,手动封装返回结果等问题
但从测试类可以发现新的问题
dao 的实现类存在重复代码 整个操作的过程模板重复 (如创建 SqlSession 调用 SqlSession方法 关闭 SqlSession) dao 的实现类中存在硬编码,如调用 sqlSession 方法时 参数 statementId 的硬编码
解决方案
通过代码模式来创建接口的代理对象
1.添加getMapper方法
删除dao的实现类 UserDaoImpl.java 我们通过代码来实现原来由实现类执行的逻辑
在 SqlSession 中添加 getMapper 方法
public interface SqlSession {
T getMapper(Class> mapperClass);
}
2. 实现类实现方法
DefaultSqlSession 类中实现 getMapper 方法
@Override
public T getMapper(Class> mapperClass) {
// 使用 JDK 动态代理 来为 Dao 接口生成代理对象 并返回
Object proxyInstance = Proxy.newProxyInstance(DefaultSqlSession.class.getClassLoader(), new Class[]{mapperClass}, new InvocationHandler() {
/**
*
* @param proxy 当前代理对象的引用
* @param method 当前被调用方法的引用
* @param args 传递的参数
* @return
* @throws Throwable
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 底层都还是去执行 JDBC 代码 -> 根据不同情况 调用 selectList() 或者 selectOne()
// 准备参数 1. statmentId sql语句的唯一标识 namespace.id = 接口全限定名.方法名
// 2. params -> args
// 拿到的是方法名 findAll
String methodName = method.getName();
// 拿到该类的全限定类名 com.huodd.dao.IUserDao
String className = method.getDeclaringClass().getName();
String statmentId = className + "." + methodName;
// 获取被调用方法的返回值类型
Type genericReturnType = method.getGenericReturnType();
// 判断是否进行了 泛型类型参数化
if (genericReturnType instanceof ParameterizedType) {
List
3.调整mapper.xml配置文件
这里要注意两点
namespace 与 dao 接口的全限定类名保持一致 id 与 dao 接口中定义的方法名保持一致
"com.huodd.dao.IUserDao">
4. 进入测试
public class IPersistenceTest {
@Test
public void test () throws Exception {
InputStream resourceAsStream = Resources.getResourceAsStream("sqlMapConfig.xml");
SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);
SqlSession sqlSession = sessionFactory.openSession();
User user = new User();
user.setId(1);
user.setUsername("bd2star");
// 此时返回的 userDao 就是代理对象 所以它的类型就是 Proxy
IUserDao userDao = sqlSession.getMapper(IUserDao.class);
// userDao 是代理对象 调用了接口中的 findAll() 代理对象调用接口中任意方法 都会执行 invoke()
List users = userDao.findAll();
System.out.println(users);
User res = userDao.findByCondition(user);
System.out.println(res);
}
}
运行结果如下
[User{id=1, username='bd2star'}, User{id=2, username='bd3star'}]
User{id=1, username='bd2star'}
目录结构调整
将代码分为两个模块
提供端(自定义持久层框架-本质就是对JDBC代码的封装) 使用端 (引用持久层框架的jar ) 包含数据库配置信息 包含sql配置信息 包含sql语句 参数类型 返回值类型
项目目录结构最终为
提供端
使用端
源码地址
https://gitee.com/bx2star/mybatis-learning.git
—————END—————
推荐阅读:
最近面试BAT,整理一份面试资料《Java面试BAT通关手册》,覆盖了Java核心技术、JVM、Java并发、SSM、微服务、数据库、数据结构等等。
获取方式:点“在看”,关注公众号并回复 666 领取,更多内容陆续奉上。
明天见(。・ω・。)ノ♡