如果你尝试过用Phoenix进行Web开发,你肯定会遇到Ecto。它是Elixir程序员的首选数据库库。不幸的是,Phoenix教程通常对Ecto的介绍不够深入,无法让人对事情的发展产生直觉。
_(1).png)
在这篇文章中,我们将尝试理解Ecto是如何工作的。首先,我们将使用Ecto来玩弄一个基本的博客数据库。之后,我们将深入了解Ecto的不同模块和它们所提供的功能。
如果这是你第一次接触Elixir(或Phoenix),我建议你慢慢开始,看看我们对Elixir和Phoenix的指南。
什么是Ecto?

Ecto是Elixir生态系统中常用的数据库工具包,通常用于与Postgres和MySQL等SQL数据库进行交互。它非常强大,可用于你需要的所有与数据库的交互,如插入、验证、改变和查询数据。
它有四个主要组成部分:
- 模式。模式是从数据库表到Elixir结构的映射;该模块提供了你需要的一切来创建它们。
- 变更集。Changesets帮助验证你想插入数据库或修改的数据。
- Repo。这是一个主要的点,通过它可以调用与数据库的任何互动。
- 查询。一个以宏为动力的DSL,用类似Elixir的语法进行可组合的查询。
你可以根据你的要求,在你的应用程序中自由地使用这些模块的任何组合。没有必要全部使用它们。
使用Ecto的好处是什么?
最有可能的是,使用Ecto的选择将是你所做的其他选择的结果。如果你正在使用Phoenix和Elixir,你有可能会使用Ecto。🙃
一般来说,Ecto提供了与Elixir生态系统中其他事物相同的好处:我们关注模块化和明确性,当然它也有宏。
明确性。 Ecto对正在发生的事情是明确的:不像其他库那样有那么多幕后的魔法。例如,如果你不预装相关的项目,框架就不会为你获取它们。虽然这在开始时可能会造成一些麻烦,但它可以防止你犯一些错误,当你的项目在Product Hunt上传播时,你会永远记得。
可配置。 Ecto是非常灵活的。例如,在默认情况下,Ecto是用来连接MySQL和PostgreSQL等SQL数据库的。但从技术上讲,你可以用它来连接你心中喜欢的任何数据源,如MongoDB或ETS等。
挑选和选择。 虽然Ecto经常被称为一个框架,但它由一些模块组成,其中大部分模块你可以使用或不使用,这取决于你的喜好。无模式查询,纯SQL而不是Ecto.Query ,只使用变化集进行数据验证--你想这样做,你就可以这样做。我不做评判。
Ecto教程:为一个博客创建一个数据库
好了,现在你对Ecto是什么以及它的用途有了一定的了解,让我们马上深入了解一下吧
在本教程中,我们将使用Ecto为一个凤凰城项目建立一个基本的博客数据库。它将包含用户、帖子和评论。
设置项目
首先,让我们创建一个空白的Phoenix项目。
mix phx.new blog
之后,cd blog ,并运行mix ecto.create 。这将为该项目创建数据库。
设计数据库
在我们的数据库中,我们将有三个表。
- 用户。 它将有一个用户名字段。
- 帖子。 它将有一个关于帖子文本的字段。它也将有一个外键来引用一个用户。
- 评论。 它将有一个评论文本字段。它也将有一个引用用户的外键和一个引用帖子的外键。
为了简单起见,我们将跳过类别、标签、标题、元标题和其他琐事。
创建一个数据库迁移
首先,我们需要生成一个空白的迁移文件。
mix ecto.gen.migration initial
之后,用代码编辑器打开迁移文件。你可以在priv/repo/migrations 中找到它。
让我们在初始迁移中填入我们想要的表。
defmodule Blog.Repo.Migrations.Initial do
use Ecto.Migration
def change do
create table ("users") do
add :username, :string
timestamps()
end
create table ("posts") do
add :user_id, references (:users)
add :post_text, :text
timestamps()
end
create table ("comments") do
add :user_id, references (:users)
add :post_id, references (:posts)
add :comment_text, :text
timestamps()
end
end
end
这个迁移文件复制了我们想要的数据库结构,并且还为每个条目添加了时间戳。你可以在文档中阅读更多关于迁移的内容。
创建完迁移后,我们需要通过mix ecto.migrate 来运行它。
创建模式
之后,我们需要创建模式(这里和下面,我指的是Ecto模式,而不是SQL模式),这将帮助Ecto理解数据库中的内容。
我们不会为模式的位置困扰那么多。在Phoenix,通常的架构使用上下文,这不在本文的讨论范围之内。
创建一个lib/blog/schemas ,并在其中创建以下模式。
user.ex
defmodule Blog.Schemas.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :username, :string
has_many :posts, Blog.Schemas.Post
has_many :comments, Blog.Schemas.Comment
timestamps()
end
def changeset(user, params \\ %{}) do
user
|> cast(params, [:username])
|> validate_required([:username])
end
end
post.ex
defmodule Blog.Schemas.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :post_text, :string
has_many :comments, Blog.Schemas.Comment
belongs_to :user, Blog.Schemas.User
timestamps()
end
def changeset(post, params \\ %{}) do
post
|> cast(params, [:post_text])
|> validate_required([:post_text])
end
end
comment.ex
defmodule Blog.Schemas.Comment do
use Ecto.Schema
import Ecto.Changeset
schema "comments" do
field :comment_text, :string
belongs_to :post, Blog.Schemas.Post
belongs_to :user, Blog.Schemas.User
timestamps()
end
def changeset(comment, params \\ %{}) do
comment
|> cast(params, [:comment_text])
|> validate_required([:comment_text])
end
end
迁移与模式
在某些时候,你可能会开始怀疑为什么你在做本质上相同的事情两次。🤔
迁移负责给我们提供数据库表,而模式则负责Ecto如何查看这些表。例如,你可以为一个表拥有多个Ecto模式,这取决于你想如何访问它。你甚至可以拥有没有匹配表的模式。
尝试与数据库一起工作
现在,我们可以玩一下数据库了。
让我们通过iex -S mix 来运行这个项目。
首先,进行一些别名的准备工作。
iex(1)> alias Blog.Repo
Blog.Repo
iex(2)> alias Blog.Schemas.{User, Post, Comment}
[Blog.Schemas.User, Blog.Schemas.Post, Blog.Schemas.Comment]
之后,我们可以插入一个有帖子的用户。
iex(3)> {:ok, user} = Repo.insert(%User{username: "dostoevsky007"})
iex(4)> {:ok, post} = Repo.insert(%Post{post_text: "ALEXEY Fyodorovitch Karamazov was the third son of Fyodor Pavlovitch Karamazov, a landowner well known in our district in his own day, and still remembered among us owing to his gloomy and tragic death, which happened thirteen years ago, and which I shall describe in its proper place.", user: user})
之后,让我们插入一个带有帖子评论的新用户。
iex(5)> {:ok, user2} = Repo.insert(%User{username: "tolstoy1828"})
iex(6)> {:ok, comment} = Repo.insert(%Comment{comment_text: "Happy families are all alike; every unhappy family is unhappy in its own way.", post: post, user: user2})
现在,让我们试着从数据库中获取一个帖子。
iex(7)> post = Repo.get(Post, 1)
让我们假设我们想阅读这个帖子的评论。如果我们试图访问这个帖子的评论,会发生一些奇怪的事情。
iex(8)> post.comments
#Ecto.Association.NotLoaded<association :comments is not loaded>
经典的Ecto。它不会获取评论,除非有人告诉它这样做,也不会获取我们想看到的幕后评论。如果我们想获取评论,我们必须明确地告诉它用Repo.preload 来获取这些评论。
post = Repo.get(Post, 1) |> Repo.preload [:comments]
现在,我们也有了评论。
iex(9)> post.comments
[
%Blog.Schemas.Comment{
__meta__: #Ecto.Schema.Metadata<:loaded, "comments">,
comment_text: "Happy families are all alike; every unhappy family is unhappy in its own way.",
id: 1,
inserted_at: ~N[2021-11-08 22:45:18],
post: #Ecto.Association.NotLoaded<association :post is not loaded>,
post_id: 1,
updated_at: ~N[2021-11-08 22:45:18],
user: #Ecto.Association.NotLoaded<association :user is not loaded>,
user_id: 2
}
]
一如既往的伟大见解,tolstoy1828。
写出操作数据库记录的函数
之后,我们可以创建一个单独的模块,由我们所谓的前端来调用。
defmodule Blog.App do
alias Blog.Schemas.{User, Post, Comment}
alias Blog.Repo
alias Ecto.Changeset
import Ecto.Query
def create_post(params, user_id) do
user = Repo.get(User, user_id)
%Post{}
|> Post.changeset(params)
|> Changeset.put_assoc(:user, user)
|> Repo.insert()
end
def delete_post(id) do
Post
|> Repo.get(id)
|> Repo.delete()
end
def create_user(params) do
%User{}
|> User.changeset(params)
|> Repo.insert()
end
def delete_user(id) do
user = Repo.get(User, id)
Repo.delete(user)
end
def create_comment(params, user_id, post_id) do
user = Repo.get(User, user_id)
post = Repo.get(Post, post_id)
%Comment{}
|> Comment.changeset(params)
|> Changeset.put_assoc(:user, user)
|> Changeset.put_assoc(:post, post)
|> Repo.insert()
end
def delete_comment(id) do
Comment
|> Repo.get(id)
|> Repo.delete()
end
def display_post(id) do
Post
|> Repo.get(id)
|> Repo.preload([:user, comments: :user])
end
def list_posts() do
query = from p in Post,
order_by: [desc: p.id],
preload: :user
Repo.all(query)
end
end
在这里,我们只有一些无聊的函数来添加和删除数据库中的东西,以及获取整个用户的帖子列表(用于主页面)和显示帖子(用于帖子页面)的函数。
这里有两件事我们还没有涉及:
-
Changeset.put_assoc把相关的记录放在我们正在建立的记录里面。你可以在AppSignal的博客上阅读一篇关于Ecto关联的优秀文章。 -
list_posts()使用Ecto的查询语言,按照记录的降序获取记录 ,并预装用户。你可以在id文档中阅读更多关于查询语言的内容。
Ecto是如何工作的?
现在我们已经对Ecto有了一些实践经验,让我们更深入地了解它的一些部分(模块)是如何工作的。
模式
模式将数据从你的数据库中的表映射到Elixir结构。每一个,最终都会产生一个Elixir结构,其名称为模式模块。
iex(10)> %Blog.Schemas.User{}
%Blog.Schemas.User{
__meta__: #Ecto.Schema.Metadata<:built, "users">,
comments: #Ecto.Association.NotLoaded<association :comments is not loaded>,
id: nil,
inserted_at: nil,
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>,
updated_at: nil,
username: nil
}
你可以使用Elixir提供的所有结构操作来处理这些结构。
iex(11)> user = %User{}
iex(12)> %User{user | username: "soulofgogol"}
%Blog.Schemas.User{
__meta__: #Ecto.Schema.Metadata<:built, "users">,
comments: #Ecto.Association.NotLoaded<association :comments is not loaded>,
id: nil,
inserted_at: nil,
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>,
updated_at: nil,
username: "soulofgogol"
}
如果我们想插入的数据可能是无效的或有问题的,一个好的做法是使用变化集进行验证。
变更集
变更集是一种数据结构,它告诉Ecto如何改变你的数据。它的对应模块--Ecto.Changeset --包含了创建和操作这些数据结构的函数。
下面是一个空的变化集的样子。
%Ecto.Changeset<action: nil, changes: %{}, errors: [], data: nil, valid?: false>
有两种方法来制作一个变化集:change 和cast 。
这两个函数都是用一个结构和一个带有一些数据的地图来制作一个变化集。
不同的是,cast 有一个参数是可以改变的参数列表,因此在处理外部数据时,或者当你有不想意外改变的字段时,最好使用。
iex(13)> changeset = Ecto.Changeset.cast(%User{}, %{username: "puhskinnotification"}, [:username])
#Ecto.Changeset<
action: nil,
changes: %{username: "puhskinnotification"},
errors: [],
data: #Blog.Schemas.User<>,
valid?: true
>
iex(14)> changeset = Ecto.Changeset.cast(%User{}, %{username: "puhskinnotification"}, [])
#Ecto.Changeset<action: nil, changes: %{}, errors: [],
data: #Blog.Schemas.User<>, valid?: true>
如果我们没有列出一个与我们相关的参数,它就不会被包括在变化集中。
验证和约束
在创建一个变化集后,你可以验证它,并根据约束条件来验证它。
这些是相当类似的。不同的是,验证是由Ecto执行的,而约束是由数据库执行的。
例如,在不与数据库交互的情况下,没有办法验证唯一性,所以你需要通过unique_constraint() 来传递你的记录。对于每个约束,在数据库中也需要有一个匹配的约束。在这个例子中,你的数据库表需要有一个唯一索引。否则,就不会出现错误。
但是,回到验证问题。验证是非常有用的,应该是你验证进入数据库的东西的首选方法。
下面是一个通过验证传递变化集的例子。
iex(15)> changeset = Ecto.Changeset.cast(%User{}, %{username: "chekhovitout"}, [:username])
#Ecto.Changeset<
action: nil,
changes: %{username: "chekhovitout"},
errors: [],
data: #Blog.Schemas.User<>,
valid?: true
>
iex(16)> changeset = Ecto.Changeset.validate_required(changeset, [:username])
#Ecto.Changeset<
action: nil,
changes: %{username: "chekhovitout"},
errors: [],
data: #Blog.Schemas.User<>,
valid?: true
>
如果我们没有一个用户名,我们就会得到一个无效的变化集,错误被添加到changeset.errors 。
iex(17)> changeset = Ecto.Changeset.cast(%User{}, %{}, [])
#Ecto.Changeset<action: nil, changes: %{}, errors: [],
data: #Blog.Schemas.User<>, valid?: true>
iex(18)> changeset = Ecto.Changeset.validate_required(changeset, [:username])
#Ecto.Changeset<
action: nil,
changes: %{},
errors: [username: {"can't be blank", [validation: :required]}],
data: #Blog.Schemas.User<>,
valid?: false
>
目前有很多现成的验证方法,但你也可以编写自己的自定义验证函数,用 [validate_change](https://hexdocs.pm/ecto/Ecto.Changeset-function-validate_change.html).
在哪里定义变更集?
变更集通常和它们使用的模式一起被定义在模块中。
defmodule Blog.Schemas.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :post_text, :string
has_many :comments, Blog.Schemas.Comment
belongs_to :user, Blog.Schemas.User
timestamps()
end
def changeset(post, params \\ %{}) do
post
|> cast(params, [:post_text])
|> validate_required([:post_text])
end
end
如果有必要,你可以创建一个以上的变化集来覆盖多个插入/更改的背景。
Repo
当像Ruby的ActiveRecord这样的OOP语言库作用于(或创造出它们作用于)对象的假象--例如,你会写Post.find(1) --Ecto使用Repo模块作为你和数据库之间的中介,你向它提交你希望得到执行的命令。
Repo.get(Post, 1)
如果你需要什么,你就问Repo。如果你想插入什么,你就把它交给Repo。如果你不问,你就不会收到。所有与数据库的通信都通过Repo进行。
Ecto查询语言
虽然Repo模块有一些对数据库的基本调用,但有可能你会需要更多的东西。在Ecto.Query模块中,你会发现用Ecto DSL创建自定义、可组合的查询工具。
例如,之前我们需要按降序获得所有的帖子,并预加载它们的用户。
我们是这样写查询的。
query = from p in Post,
order_by: [desc: p.id],
preload: :user
如果我们想甩掉无用的数据,我们可以写一个连接。
query = from p in Post,
join: u in User, on: p.user_id == u.id,
order_by: [desc: p.id],
select: %{post_text: p.post_text, username: u.username}
我不会深入研究DSL的语法;基本信息在Ecto文档中已经涵盖得非常清楚了。
使用SQL片段
当然,如果你发现查询语言的可表达性不够,或者你还不习惯使用它,也可以使用SQL片段。
虽然这对我们的示例数据库没有用,但这里是官方文档中的一个例子。
from p in Post,
where: is_nil(p.published_at) and
fragment("lower(?)", p.title) == ^title
你也可以在迁移中使用这些片段。
add :timestamp, :utc_datetime, default: fragment("(now() AT TIME ZONE 'utc')"), null: false