GO面向对象(做CRUD专家)十:被误解的ORM

46 阅读8分钟

需求场景:

文章表articles:
id(自增)  user_id(用户id)  title(文章标题) content(文章内容) created_at(创建时间)

帖子表posts:
id(自增)  user_id(用户id)  title(帖子标题) content(帖子内容) created_at(创建时间)

评论表comments:
id(自增)  user_id(用户id)  article_id(文章id) content(帖子内容) created_at(创建时间)

用户表users:
id(自增)  name(用户名称)  avatar(用户头像) created_at(创建时间)

文章列表接口: /articles
需求:文章id、文章标题、发表时间、用户名、用户头像
实现(两表关联查询):SELECT a.id, a.user_id, a.title, a.created_at, u.name, u.avatar FROM articles AS a, users AS u WHERE a.user_id=u.id ORDER BY a.created_at DESC

[
    {
        "id": 1,
        "userId": 1,
        "title": "文章标题1",
        "createdAt": "2020-02-03 11:00:00""name": "用户1",
        "avatar": "头像地址1"
    },
    ...
]

帖子列表接口 /posts
需求:帖子id、帖子标题、发表时间、用户名、用户头像
实现(两表关联查询):SELECT p.id, p.user_id, p.title, p.created_at, u.name, u.avatar FROM posts AS p, users AS u WHERE p.user_id=u.id ORDER BY p.created_at DESC

[
    {
        "id": 1,
        "userId": 1,
        "title": "帖子标题1",
        "createdAt": "2020-02-03 11:00:00""name": "用户1",
        "avatar": "头像地址1"
    },
    ...
]

最新文章评论列表接口 /latestComments

需求:按评论时间排序,评论内容、评论时间、评论者名称,评论者头像、文章标题
实现(三表关联查询):SELECT c.id, c.article_id, c.user_id, c.content, c.created_at, u.name, u.avatar, a.title FROM comments AS c, users AS u, articles AS a WHERE c.user_id=u.id AND c.article_id=a.id ORDER BY c.created_at DESC

[
    {
        "id": 1,
        "artileId": 1,
        "userId": 1,
        "content": "评论内容1",
        "createdAt": "2020-02-03 11:00:00",
        "name": "用户1",
        "avatar": "头像地址1",
        'title': "新闻标题1"
    },
    ...
]

客户端对接服务器接口过程(Android举例)

// 文章列表接口:新建Acticle模型
public class Article {
    public Integer id;   
    public Integer userId;   
    public String title;    
    public String name;    
    public String avatar;
    public String createdAt;
}

// 帖子列表接口:新建Post模型
public class Post {
    public Integer id;   
    public Integer userId;   
    public String title;    
    public String name;    
    public String avatar;
    public String createdAt;
}

// 最新评论接口:新建Comment模型
public class Comment {
    public Integer id;   
    public Integer articleId;
    public Integer userId;
    public String content;   
    public String createdAt;
    public String name;  
    public String avatar;
    public String title;
}

新增需求

增加用户等级功能,以上3个接口中增加用户等级字段

服务端程序开发过程:
步骤一:用户表增加level字段
步骤二:3条sql增加用户level字段
SELECT a.id, a.user_id, a.title, a.created_at, u.name, u.avatar, u.level FROM articles AS a, users AS u WHERE a.user_id=u.id ORDER BY a.created_at DESC
SELECT p.id, p.user_id, p.title, p.created_at, u.name, u.avatar, u.level FROM posts AS p, users AS u WHERE p.user_id=u.id ORDER BY p.created_at DESC
SELECT c.id, c.article_id, c.user_id, c.content, c.created_at, u.name, u.avatar, u.level, a.title FROM comments AS c, users AS u, articles AS a WHERE c.user_id=u.id AND c.article_id=a.id ORDER BY c.created_at DESC

客户端开发过程: 找到Article、Post、Comment模型,增加level属性;

思考:users表增加1个level字段导致服务端程序要修改3条sql并且连带客户端要修改3个模型?

正确做法<伪代码>

文章列表接口: /articles
$articles = SELECT * FROM articles ORDER BY created_at DESC;
// 获取文章的用户id
$userIds = [];
for $acticle in $articles {
    $userIds[] = $acticle['user_id']
}
$userIdsIn = implode(',', $userIds)
// 通过用户id查用户表
$users = SELECT * FROM users  WHERE `id` IN ($userIdsIn)

$usersMap = new map;
for $user in $users {
    $usersMap[$user['id']] = $user;
}
// 把用户信息合到对应的文章中
for $acticle in $articles {
    $article['user'] = $usersMap[$acticle['user_id']]
}
这种写法是为了避免嵌套查询
疑惑:费了这么大劲这么多行代码才实现1条联表sql的功能,得不偿失。
// 返回格式
[
    {
        "id":1,
        "userId":1,
        "title":"文章标题1",
        "content":"文章内容1",
        "createdAt":"2020-02-03 11:00:00",
        "user":{
            "id":1,
            "name":"用户1",
            "avatar":"头像地址1",
            "createdAt":"2020-02-03 11:00:00",
        },
    },
    ...
]

帖子列表接口: /posts
[
    {
        "id":1,
        "userId":1,
        "title":"帖子标题1",
        "content":"帖子内容1",
        "createdAt":"2020-02-03 11:00:00",
        "user":{
            "id":1,
            "name":"用户1",
            "avatar":"头像地址1",
            "createdAt":"2020-02-03 11:00:00",
        },
    },
    ...
]

最新文章评论列表接口: /latestComments
[
    {
        "id":1,
        "userId":1,
        "articleId":1,
        "content":"评论1",
        "createdAt":"2020-02-03 11:00:00",
        "user":{
            "id":1,
            "name":"用户1",
            "avatar":"头像地址1",
            "createdAt":"2020-02-03 11:00:00",
        },
        "article":{
            "id":1,
            "userId":1,
            "title":"文章标题1",
            "content":"文章内容1",
            "user":null,
            "createdAt":"2020-02-03 11:00:00",
        },
    },
    ...
]

客户端模型(Android举例)

// User模型
public class User {
    public Integer id;   
    public String name;    
    public String avatar;
}
// Acticle模型
public class Article {
    public Integer id; 
    public String title;    
    public String content;
    public String createdAt;

    public User user;  // 用户模型
}
// Post模型
public class Post {
    public Integer id;   
    public String title;  
    public String content;
    public String createdAt;
    
    public User user;  // 用户模型
}
// Comment模型
public class Comment {
    public Integer id;   
    public Integer userId;
    public String content;   
    public String createdAt;
    
    public User user;        // 用户模型
    public Article article;  // 文章模型
}

实现增加用户等级功能

服务端程序开发过程:
用户表增加level字段,找到User模型,增加level对象属性;

客户端开发过程:
找到User模型,增加level对象属性;

什么是ORM?

对象关系映射(Object Relational Mapping,简称ORM或O/R mapping),是一种程序技术,用于实现面向对象编程里不同类型系统的数据之间的转换。

ORM是一种程序技术用于实现面向对象编程里在不同类型系统的数据转换时保持对象之间的映射关系。

关键词:不同类型系统;数据转换;保持对象之间的映射关系;

很多同学认为ORM就是比如用PHP的EloquentORM、GoLand的GROM、Java的Hibernate等框架实现数据的增删改查操作;根据ORM的定义,ORM是强调在不同类型的系统数据转换时要保持正确的对象映射关系,不同类型的系统并不仅仅是数据库系统的数据和应用程序的数据相互转换,也包括应用程序中的数据和客户端的数据相互转换

上文中把用户的名称、头像放在文章、帖子、评论对象中,就破环了对象之间的映射关系。

大部分人对ORM的理解就是不用自己再手写SQL,通过上面的例子说明,不用ORM框架也能写出符合ORM规范的接口;

思考:为什么非要保持这种对象映射关系?

什么是对象关系?

解释:一对一、一对多、多对多
分析:acticle跟user就是一对一关系,一篇文章对应一个用户;user跟acticle是一对多关系,一个用户可以写多篇文章,网上很多文章解释的很清楚,这里不赘述

// 用户文章列表接口
{
    "id":1,
    "name":"用户1",
    "avatar":"头像地址1",
    "createdAt":"2020-02-03 11:00:00",
    "articles":[
        {
            "id":1,
            "userId":1,
            "title":"文章标题1",
            "content":"文章内容1",
            "user":null,
            "createdAt":"2020-02-03 11:00:00",
        }
        ...
    ],
}

客户端模型
// User模型
public class User {
    public Integer id;   
    public String name;    
    public String avatar;
    
    public ArrayList<Article> articles; // 文章模型数组
}

为什么要使用ORM框架

实现1条联表sql的功能用了十几行代码,最新评论接口3张表关联,写起来更麻烦了,ORM框架可以简化这个过程

// gorm举例
// 文章列表接口 /articles
var articles []model.Article
db.Preload("User").Find(&articles)

// 执行过程 跟刚才手写的过程是一样的,只是封装好了
SELECT * FROM `articles`  WHERE `articles`.`deleted_at` IS NULL
SELECT * FROM `users`  WHERE `users`.`deleted_at` IS NULL AND ((`id` IN (1,2)))

最新文章评论列表接口 /latestComments
var comments []model.Comment
db.Preload("User").Preload("Article").Find(&comments)
// 执行过程
SELECT * FROM `comments`  WHERE `comments`.`deleted_at` IS NULL  
SELECT * FROM `users`  WHERE `users`.`deleted_at` IS NULL AND ((`id` IN (1,2)))  
SELECT * FROM `articles`  WHERE `articles`.`deleted_at` IS NULL AND ((`id` IN (1,2)))  

增加一个稍微复杂的例子加深理解:

// 文章详情接口 /article/:id
var article model.Article
db.Preload("User").Preload("Comments").Preload("Comments.User").Find(&article, 1)
// 执行过程
SELECT * FROM `articles`  WHERE `articles`.`deleted_at` IS NULL AND ((`articles`.`id` = 1))  
SELECT * FROM `users`  WHERE `users`.`deleted_at` IS NULL AND ((`id` IN (1)))  
SELECT * FROM `comments`  WHERE `comments`.`deleted_at` IS NULL AND ((`article_id` IN (1)))  
SELECT * FROM `users`  WHERE `users`.`deleted_at` IS NULL AND ((`id` IN (1)))  

返回格式:
{
    "id":1,
    "userId":1,
    "title":"文章标题1",
    "content":"文章内容1",
    "createdAt":"2020-02-03 11:00:00",
    "user":{
        "id":1,
        "name":"用户1",
        "avatar":"头像地址1",
        "articles":null,
        "createdAt":"2020-02-03 11:00:00",
    },
    "comments":[
        {
            "id":1,
            "userId":1,
            "articleId":1,
            "content":"评论1",
            "article":null,
            "createdAt":"2020-02-03 11:00:00",
            "user":{
                "id":1,
                "name":"用户1",
                "avatar":"头像地址1",
                "articles":null,
                "createdAt":"2020-02-03 11:00:00",
            },
        }
        ...
    ],
}

客户端模型
// Acticle模型
public class Article {
    public Integer id; 
    public String title;    
    public String content;
    public String createdAt;

    public ArrayList<Comment> comments; // 评论模型数组
    public User user;  // 用户模型
}

代码符合ORM规范的优势

一、提高服务器开发效率,使用ORM框架不用手写效率低下SQL语句;
二、提高客户端的对接效率,客户端模型与服务器模型一一对应,不同业务就是不同模型的组合;
三、降低客户端与服务器端人员的沟通成本,都在相同的模型上讨论业务,不容易产生歧义;
四、可以通过服务器模型生成对应的客户端模型文件,参照Apache Thrift和gRPC;
五、使用ORM框架,避免了关联查询,针对单表sql的慢查询优化比多表关联sql要简单;
六、ORM框架都实现了SQL预编译,从而避免了SQL注入;
七、避免关联查询会带来很多好处,参照<<高性能MySQL第36.3.3章节:分解关联查询>>

SELECT * FROM 带来的问题
问题1:不需要大字段的时候也被查出来,比如文章列表接口,content字段为text类型,手写sql的可以不写content,但是ORM框架每次都是全部查出来;
分析:sql规范没有某种语法糖,比如SELECT NOT(content) FROM articles可以把排除content字段的剩余所有字段全部查询出来;
解决:增加articlesContent表,把content放在这张表中,通过article_id关联articles,需要content的时候再load进来;

问题2:有次讨论有人问统计业务怎么用ORM,统计业务要我做我也手写sql,ORM说的是对象映射关系,跟统计业务没关系;