今天的大多数 Web 应用程序都需要某种形式的数据验证和持久性。 在 Elixir 生态系统中,我们有 Ecto
来实现这一点。 我们先来了解下这个 Ecto。 让我们开始吧!
Phoenix 使用 Ecto,默认就支持下面这些数据库:
- PostgreSQL(postgrex)
- MySQL(myxql)
- MSSQL(tds)
- ETS(etso)
- SQLite3(ecto_sqlite3)
新建的 Phoenix 项目,Ecto 默认使用 PostgreSQL 适配器。可以使用 --database
选项来改变,或者使用 --no-ecto
不使用 Ecto。
其他的数据库是支持的,可以到 Ecto 的官方文档上了解详情。
这个教程使用 PostgreSQL。
使用 schema 和 magration 生成器
如果我们安装并配置好 Ecto 和 PostgreSQL 之后,使用 Ecto 最简单的方式是执行 phx.gen.schema
任务来生成 Ecto schema。Ecto schema 是我们指定 Elixir 数据类型如何映射到外部源(例如数据库表)的一种方式。我们来生成一个 User
schema,包含 name
, email
, bio
和 number_of_pets
字段。
$ mix phx.gen.schema User users name:string email:string \
bio:string number_of_pets:integer
* creating ./lib/hello/user.ex
* creating priv/repo/migrations/20170523151118_create_users.exs
Remember to update your repository by running migrations:
$ mix ecto.migrate
这个任务生成了几个文件。 首先,我们有一个 user.ex
文件,其中包含我们的 Ecto schema以及我们传递过来的字段的定义。 接下来,在 priv/repo/migrations/
中生成了一个 migration 文件,它将创建跟我们的schema匹配的数据库表。
然后根据提示来运行 migration:
$ mix ecto.migrate
Compiling 1 file (.ex)
Generated hello app
[info] == Running Hello.Repo.Migrations.CreateUsers.change/0 forward
[info] create table users
[info] == Migrated in 0.0s
Mix 会默认使用开发模式,除非我们指定 MIX_ENV=prod mix ecto.migrate
。
如果我们登录到我们的数据库服务器,并连接到我们的 hello_dev
数据库,我们应该看到我们的 users
表。 Ecto 假设我们想要一个名为 id
的整数列作为我们的主键,因此我们也应该看到为此生成的序列。
psql -U postgres
Type "help" for help.
postgres=# \connect hello_dev
You are now connected to database "hello_dev" as user "postgres".
hello_dev=# \d
List of relations
Schema | Name | Type | Owner
--------+-------------------+----------+----------
public | schema_migrations | table | postgres
public | users | table | postgres
public | users_id_seq | sequence | postgres
(3 rows)
hello_dev=# \q
看下由 phx.gen.schema
生成的 priv/repo/migrations/
目录下的文件,会看到我们指定的字段,还有时间戳相关的字段 inserted_at
和 updated_at
。由 timestamps/1
函数生成。
defmodule Hello.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :name, :string
add :email, :string
add :bio, :string
add :number_of_pets, :integer
timestamps()
end
end
end
实际生成的 users
表:
$ psql
hello_dev=# \d users
Table "public.users"
Column | Type | Modifiers
---------------+-----------------------------+----------------------------------------------------
id | bigint | not null default nextval('users_id_seq'::regclass)
name | character varying(255) |
email | character varying(255) |
bio | character varying(255) |
number_of_pets | integer |
inserted_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
Indexes:
"users_pkey" PRIMARY KEY, btree (id)
注意,即使我们没有在 migration 文件中指定 id 字段,框架也会默认生成一个 id 字段作为主键。
Repo configuration
我们的 Hello.Repo
模块,在 Phoenix 应用中是和数据库交互的基础,默认生成在文件 lib/hello/repo.ex
中,内容如下:
defmodule Hello.Repo do
use Ecto.Repo,
otp_app: :hello,
adapter: Ecto.Adapters.Postgres
end
在上面例子中,这个模块先是定义了模块名称,然后是配置 otp_app
的名称,然后是 adapter
-Postgres
。
repo 有3个主要任务-从[Ecto.Repo
]引入了所有通用的查询方法,把我们应用的名字设置给 otp_app
,最后是配置数据库的适配器(使用何种数据库)。
当 phx.new
生成应用时,就已经有了一份默认的数据库配置。看下 config/dev.exs
。
...
# Configure your database
config :hello, Hello.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "hello_dev",
show_sensitive_data_on_connection_error: true,
pool_size: 10
...
我们在 config/test.exs
和 config/runtime.exs
(以前的config/prod.secret.exs)中也有类似的配置,都可以按照实际环境去更改。
The schema
Ecto schema 负责将 Elixir 的数据映射到外部数据源,以及将外部数据映射回 Elixir 数据结构。我们还可以在应用程序中定义与其他 schema 的关系。例如,我们的 User
schema 可能有许多 posts ,每个 post 都属于一个user 。Ecto 还使用 changesets 处理数据验证和类型转换,我们将在稍后讨论。
下面是 Phoenix 生成的 User
schema:
defmodule Hello.User do
use Ecto.Schema
import Ecto.Changeset
alias Hello.User
schema "users" do
field :bio, :string
field :email, :string
field :name, :string
field :number_of_pets, :integer
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
end
end
Ecto schema 的核心只是 Elixir 结构体。我们的 schema 块告诉 Ecto 如何将我们的 %User{}
结构体字段和外部的 users
表互相转换。通常,简单地将数据转换能力是不够的,数据验证也是必须的。这就是 Ecto changesets 的用武之地。让我们开始吧!
Changesets and validations
changesets 定义了数据在使用之前需要进行的转换管道。这些转换可能包括类型转换、用户输入验证和过滤掉无关参数等。通常,我们会在将用户输入写入数据库之前使用 changeset 来验证用户输入。Ecto 数据库也是对 changesets 有关联的,这使得它们不仅可以拒绝无效数据,还可以通过检查 changeset 来知道哪些字段发生了更改从而执行尽可能少的数据库更新。
来看下默认的 changeset 函数。
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
end
现在,我们的管道中有两个转换。 在第一次调用中,我们调用 Ecto.Changeset.cast/3
,传入我们的外部参数并标记哪些字段需要验证。
cast/3
首先接受一个结构体,然后是参数(建议的更新),然后最后一个字段是要更新的字段的列表。 cast/3
也只会采用 schema 中存在的字段。
然后, Ecto.Changeset.validate_required/3
会检查 cast/3
中返回的数据,指定的字段是否都存在。默认生成的代码中,所有的字段都是必须的。
我们可以在 IEx
中验证此功能。 让我们通过运行 iex -S mix
在 IEx
中启动我们的应用程序。 为了尽量减少输入并使其更易于阅读,让我们为 Hello.User
结构体使用 alias。
$ iex -S mix
iex> alias Hello.User
Hello.User
然后,用一个空的 User
结构体构建一个 changeset,一个空的 map 作为参数。
iex> changeset = User.changeset(%User{}, %{})
#Ecto.Changeset<
action: nil,
changes: %{},
errors: [
name: {"can't be blank", [validation: :required]},
email: {"can't be blank", [validation: :required]},
bio: {"can't be blank", [validation: :required]},
number_of_pets: {"can't be blank", [validation: :required]}
],
data: #Hello.User<>,
valid?: false
>
有了 changeset,就可以知道它是否通过了校验。
iex> changeset.valid?
false
如果有错误,可以查看有哪些错误。
changeset.errors
[
name: {"can't be blank", [validation: :required]},
email: {"can't be blank", [validation: :required]},
bio: {"can't be blank", [validation: :required]},
number_of_pets: {"can't be blank", [validation: :required]}
]
现在,让 number_of_pets
变成选填的。怎样实现?只需要简单地把 Hello.User
下的 changeset/2
函数里的列表的该字段去除。
|> validate_required([:name, :email, :bio])
现在这个 changeset 就会提示只有 name
,email
和bio
不能为空了。执行 recompile()
重新编译,然后测试下。
iex> recompile()
Compiling 1 file (.ex)
:ok
iex> changeset = User.changeset(%User{}, %{})
#Ecto.Changeset<
action: nil,
changes: %{},
errors: [
name: {"can't be blank", [validation: :required]},
email: {"can't be blank", [validation: :required]},
bio: {"can't be blank", [validation: :required]}
],
data: #Hello.User<>,
valid?: false
>
iex> changeset.errors
[
name: {"can't be blank", [validation: :required]},
email: {"can't be blank", [validation: :required]},
bio: {"can't be blank", [validation: :required]}
]
如果我们传递既未在 schema 中定义也未被要求的键值对,会发生什么情况?
在我们现有的 IEx shell 中,让我们创建一个具有有效值的 params
以及一个额外的 random_key:"random value"
。
iex> params = %{name: "Joe Example", email: "joe@example.com", bio: "An example to all", number_of_pets: 5, random_key: "random value"}
%{
bio: "An example to all",
email: "joe@example.com",
name: "Joe Example",
number_of_pets: 5,
random_key: "random value"
}
然后使用这个 params
来创建另一个 changeset。
iex> changeset = User.changeset(%User{}, params)
#Ecto.Changeset<
action: nil,
changes: %{
bio: "An example to all",
email: "joe@example.com",
name: "Joe Example",
number_of_pets: 5
},
errors: [],
data: #Hello.User<>,
valid?: true
>
新的 changeset 是有效的。
iex> changeset.valid?
true
我们还可以查看 changeset 改变的东西 - 也就是所有转换完成之后返回的 map。
iex> changeset.changes
%{bio: "An example to all", email: "joe@example.com", name: "Joe Example",
number_of_pets: 5}
注意,这个结果里已经没有 random_key
了。Changeset 可以处理外部的数据,比如从 web form来的,或者从 CSV 文件来的。非法的参数会被剔除,无法被处理的数据会被放到 changeset errors中。
我们可以验证的不仅仅是一个字段是否是必需的。 让我们来看看一些更细粒度的验证。
如果我们要求系统中的所有 bio
字段必须至少有两个字符长怎么办? 我们可以通过向我们的 changeset 中的管道添加另一个转换来轻松地做到这一点,以验证 bio
字段的长度。
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
|> validate_length(:bio, min: 2)
end
现在来验证一下,使用 "A"
赋给 bio
字段,来看看验证的结果。
iex> recompile()
iex> changeset = User.changeset(%User{}, %{bio: "A"})
iex> changeset.errors[:bio]
{"should be at least %{count} character(s)",
[count: 2, validation: :length, kind: :min, type: :string]}
如果我们对 bio
的最大长度也有要求,我们可以简单地添加另一个验证。
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
|> validate_length(:bio, min: 2)
|> validate_length(:bio, max: 140)
end
假设我们想要对 email
字段执行一些基本的格式验证。 我们只检查 @
的存在。 Ecto.Changeset.validate_format/3
函数正是我们所需要的。
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
|> validate_length(:bio, min: 2)
|> validate_length(:bio, max: 140)
|> validate_format(:email, ~r/@/)
end
如果我们使用 "example.com"
来验证下,我们将会看到如下的错误信息:
iex> recompile()
iex> changeset = User.changeset(%User{}, %{email: "example.com"})
iex> changeset.errors[:email]
{"has invalid format", [validation: :format]}
我们可以在 changeset 中执行更多的验证和转换。 请参阅 Ecto Changeset 文档以获取更多信息。
Data persistence
我们已经探索了 migrations 和 schemas ,但我们还没有持久化我们的任何 schema 或 changeset。 我们之前简要地查看了 lib/hello/repo.ex
中的存储模块,现在是时候使用它了。
Ecto repo是存储系统的接口,无论是像 PostgreSQL 这样的数据库还是像 RESTful API 这样的外部服务。 Repo
模块的目的是为我们处理数据的持久化和数据查询。 作为调用者,我们只关心获取和存储数据。 Repo
模块负责底层数据库适配器通信、连接池和数据库约束违规的错误转换。
让我们使用 iex -S mix
返回到 IEx 中,并将几个 users 插入到数据库中。
iex> alias Hello.{Repo, User}
[Hello.Repo, Hello.User]
iex> Repo.insert(%User{email: "user1@example.com"})
[debug] QUERY OK db=6.5ms queue=0.5ms idle=1358.3ms
INSERT INTO "users" ("email","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["user1@example.com", ~N[2021-02-25 01:58:55], ~N[2021-02-25 01:58:55]]
{:ok,
%Hello.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
bio: nil,
email: "user1@example.com",
id: 1,
inserted_at: ~N[2021-02-25 01:58:55],
name: nil,
number_of_pets: nil,
updated_at: ~N[2021-02-25 01:58:55]
}}
iex> Repo.insert(%User{email: "user2@example.com"})
[debug] QUERY OK db=1.3ms idle=1402.7ms
INSERT INTO "users" ("email","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["user2@example.com", ~N[2021-02-25 02:03:28], ~N[2021-02-25 02:03:28]]
{:ok,
%Hello.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
bio: nil,
email: "user2@example.com",
id: 2,
inserted_at: ~N[2021-02-25 02:03:28],
name: nil,
number_of_pets: nil,
updated_at: ~N[2021-02-25 02:03:28]
}}
首先,使用别名来简化 User
和 Repo
模块的使用。然后,调用 Repo.insert/2
函数,传递一个 User 结构体。由于是 dev
环境,我们能够看到插入 %User{}
数据之后的日志。我们接收到一个只有两个元素的 tuple,{:ok, %User{}}
,这可以告诉我们插入已经成功了。
我们还可以使用 changeset 来插入数据,如果成功了,可以看到相同的成功返回信息。如果 changeset 是无效的,我们就会得到一个有 :error
的 tuple。
插入了几条数据之后,来看看怎么获取他们。
iex> Repo.all(User)
[debug] QUERY OK source="users" db=5.8ms queue=1.4ms idle=1672.0ms
SELECT u0."id", u0."bio", u0."email", u0."name", u0."number_of_pets", u0."inserted_at", u0."updated_at" FROM "users" AS u0 []
[
%Hello.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
bio: nil,
email: "user1@example.com",
id: 1,
inserted_at: ~N[2021-02-25 01:58:55],
name: nil,
number_of_pets: nil,
updated_at: ~N[2021-02-25 01:58:55]
},
%Hello.User{
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
bio: nil,
email: "user2@example.com",
id: 2,
inserted_at: ~N[2021-02-25 02:03:28],
name: nil,
number_of_pets: nil,
updated_at: ~N[2021-02-25 02:03:28]
}
]
那很简单! Repo.all/1
需要一个数据源的参数,在本例中是我们的 User
schema,然后将其转换为针对我们数据库的 SQL 查询。 获取数据后,Repo 然后使用我们的 Ecto schema(User
schema)将数据库值映射回 Elixir 数据结构。 我们不仅限于简单的基本查询——Ecto 有一套成熟的查询 DSL 来生成高效的 SQL。 除了天然的 Elixir DSL 之外,Ecto 的查询引擎还为我们提供了多项强大的功能,例如 SQL 注入保护和查询的编译时优化。 让我们试试看。
iex> import Ecto.Query
Ecto.Query
iex> Repo.all(from u in User, select: u.email)
[debug] QUERY OK source="users" db=0.8ms queue=0.9ms idle=1634.0ms
SELECT u0."email" FROM "users" AS u0 []
["user1@example.com", "user2@example.com"]
首先,我们导入了 [Ecto.Query
],它导入了 Ecto 的 Query DSL 的 from/2
宏。 接下来,我们构建了一个查询,它选择用户表中的所有电子邮件地址。 让我们试试另一个例子。
iex)> Repo.one(from u in User, where: ilike(u.email, "%1%"),
select: count(u.id))
[debug] QUERY OK source="users" db=1.6ms SELECT count(u0."id") FROM "users" AS u0 WHERE (u0."email" ILIKE '%1%') []
1
现在我们开始体验 Ecto 丰富的查询功能。 我们使用 Repo.one/2
获取电子邮件地址包含 1
的所有用户的计数,并收到返回的预期的计数。 这只是触及了 Ecto 查询接口的表面,还支持更多,例如子查询、区间查询和高级选择语句。 例如,让我们构建一个查询来获取所有用户 ID 与其电子邮件地址组成的 map。
iex> Repo.all(from u in User, select: %{u.id => u.email})
[debug] QUERY OK source="users" db=0.9ms
SELECT u0."id", u0."email" FROM "users" AS u0 []
[
%{1 => "user1@example.com"},
%{2 => "user2@example.com"}
]
那个小小的查询带来了巨大的冲击。 它既从数据库中获取所有用户电子邮件,又在一次操作中高效地构建了结果map。 您应该浏览 Ecto.Query 文档以查看支持的查询功能的广度。
除了插入之外,我们还可以使用 Repo.update/2
和 Repo.delete/2
执行更新和删除,以更新或删除单个数据。 Ecto 还支持 Repo.insert_all/3
、Repo.update_all/3
和 Repo.delete_all/2
函数的批量持久化。
Ecto 能做的还有很多,而我们只是触及了皮毛。 有了坚实的 Ecto 基础,我们现在准备继续构建我们的应用程序,将 Web 应用程序与我们的后端持久化合起来使用。 在此过程中,我们将扩展我们的 Ecto 知识并学习如何正确地将我们的 Web 界面与系统的底层细节隔离开来。 请查看 Ecto 文档以了解故事的其余部分。
在我们的 contexts 指南中,我们将了解如何将我们的 Ecto 访问和业务逻辑包装在一起。 我们将看到 Phoenix 如何帮助我们设计可维护的应用程序,并且我们将在此过程中了解其他简洁的 Ecto 功能。
Using MySQL
这年头,谁还用 MySQL 啊。
其他
虽然 Phoenix 使用 Ecto 项目与数据访问层交互,但还有许多其他数据访问选项,有些甚至内置在 Erlang 标准库中。 ETS——通过 etso 在 Ecto 中可用——和 DETS 是 OTP 中内置的键值数据存储。 OTP 还提供了一个名为 Mnesia 的关系数据库,它有自己的查询语言 QLC。 Elixir 和 Erlang 也有许多库来处理各种流行的数据存储。
数据世界由您掌控,但我们不会在这些指南中介绍这些选项。
完
简单的介绍了 Phoenix 使用的 Ecto,作为 ORM 来与数据库交互。