檢視原始碼 Ecto

需求:本指南預設您已閱讀簡介指南,並且已使 Phoenix 應用程式順利執行

當今大部分的網路應用程式都需要某種形式的資料驗證和持續性。在 Elixir 生態系統中,我們有Ecto 來達成此目的。我們將專注於 Ecto 的詳細資訊,以建立堅固的基礎,在之上建構我們的網路功能,在深入探討建置資料庫後台的網路功能之前,讓我們開始吧!

Phoenix 使用 Ecto 提供下列資料庫的內建支援:

新產生的 Phoenix 專案預設包含 Ecto 和 PostgreSQL 介面。您可以傳遞--database 選項來變更,或傳遞--no-ecto 旗標來排除它。

Ecto 也支援其他資料庫,並提供許多學習資源。請參閱Ecto 的 README 以獲得一般資訊。

本指南假設我們已產生整合 Ecto 的新應用程式,並且我們將使用 PostgreSQL。簡介指南涵蓋如何讓您的第一個應用程式順利執行。若要使用其他資料庫,請參閱使用其他資料庫 部分。

使用 schema 和遷移產生器

在我們安裝和設定 Ecto 和 PostgreSQL 之後,透過phx.gen.schema 任務產生 Ecto schema 是使用 Ecto 最簡單的方式。Ecto schema 是讓我們指定 Elixir 資料類型如何對應至外部來源(例如資料庫表格)並進行反向對應的方法。讓我們使用nameemailbionumber_of_pets 欄位,產生一個User schema。

$ 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 架構的欄位定義架構。接著,會在 priv/repo/migrations/ 內部產生一個遷移檔案,用來建立架構所對應的資料庫表格。

檔案到位後,讓我們按照說明執行遷移

$ 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_ENV=prod mix ecto.migrate 另行告知,否則 Mix 會假設我們處於開發環境。

如果我們登入資料庫伺服器,並連線到我們的 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

如果我們查看 priv/repo/migrations/ 中由 phx.gen.schema 產生的遷移,我們將看到它會新增我們指定的欄位。它還會新增 inserted_atupdated_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)

請注意,即使我們的遷移中未將 id 列為欄位,我們仍會預設取得 id 欄位作為我們的組態檔。

儲存庫組態

我們的 Hello.Repo 模組是我們在 Phoenix 應用程式中與資料庫合作所需要的基礎。Phoenix 已為我們在 lib/hello/repo.ex 中產生,其內容如下。

defmodule Hello.Repo do
  use Ecto.Repo,
    otp_app: :hello,
    adapter: Ecto.Adapters.Postgres
end

它首先定義儲存庫模組。接著,它會組態我們的 otp_app 名稱以及 adapter – 在我們的案例中是 Postgres

我們的儲存庫有三個主要工作:引入 [Ecto.Repo] 中所有的常見查詢函數,將 otp_app 名稱設為與我們的應用程式名稱相同,以及組態我們的資料庫適配器。稍後我們會進一步瞭解如何使用 Hello.Repo

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.exsconfig/runtime.exs (以前的 config/prod.secret.exs) 中有類似的組態,它們也可以變更以符合你的實際憑證。

架構

Ecto 架構負責將 Elixir 值對應到外部資料來源,並將外部資料對應回 Elixir 資料結構中。我們也可以在應用程式中定義其他架構間的關聯性。例如,我們的User 架構可能有許多文章,且每篇文章都屬於某個使用者。此外,Ecto 也可以處理資料驗證及類型轉換,我們待會來討論相關專有名詞。

以下為 Phoenix 為我們產生的User 架構。

defmodule Hello.User do
  use Ecto.Schema
  import Ecto.Changeset

  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 架構僅是 Elixir 結構。我們的schema區段告訴 Ecto 如何將我們的%User{}結構欄位,轉換進出外部users表格。通常僅執行資料進出資料庫的轉換還不夠,需要進行額外資料驗證。這時就要用到 Ecto changeset。讓我們深入了解一下!

Changeset 和驗證

Changesets 定義在資料供應用程式使用前,所需經歷的一系列轉換流程。這些轉換可能包含類型轉換、使用者輸入驗證,以及過濾任何不相干的參數。通常會在將使用者輸入寫入資料庫之前,使用 changeset 來進行驗證。Ecto 儲存庫也與 changeset 相關,這讓它們不僅能拒絕無效資料,還能透過檢查 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

目前,我們的流程中有 2 項轉換。在第一個呼叫中,我們呼叫 Ecto.Changeset.cast/3,傳入外部參數並標記哪些欄位需要驗證。

cast/3 會先取得結構,接著取得參數(建議的更新內容),最後一項欄位是需要更新的欄位清單。cast/3 只會取得架構中存在的欄位。

接下來,Ecto.Changeset.validate_required/3 會檢查cast/3 回傳的 changeset 中,是否有這份欄位清單。預設情況下,使用產生器時,所有欄位都是必要的。

我們可以在IEx中驗證這個功能。讓我們透過執行iex -S mix在 IEx 中啟動應用程式。為了減少輸入並使閱讀更容易,我們將為我們的Hello.User結構取別名。

$ iex -S mix

iex> alias Hello.User
Hello.User

接下來,讓我們使用一個空的User結構和空的參數對應建立一個架構中 changeset。

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

因為這不成立,所以我們可以來問看看它的錯誤是什麼。

iex> 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])

現在,重新套用變更集應告訴我們僅 nameemailbio 不能為空白。我們可以在 IEx 中執行 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]}
]

如果我們傳遞了一個在架構中未定義且不必要的鍵值配對會發生什麼事?

在我們現有的 IEx shell 內,讓我們使用有效值與額外的 random_key: "random value" 建立一個 params 對應。

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 對應來建立另一個變更集。

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
>

我們的變更集現在有效。

iex> changeset.valid?
true

我們也可以查看變更集的變更,也就是在所有轉換完成後得到的對應。

iex(9)> changeset.changes
%{bio: "An example to all", email: "joe@example.com", name: "Joe Example",
  number_of_pets: 5}

請注意,我們的 random_key 鍵與 "random_value" 值已從最後的變更集中移除。變更集可讓我們將外來資料,例如網頁表單上的使用者輸入資料或 CSV 檔案中的資料轉換成系統中的有效資料。無效參數會被移除,而無法依據我們架構進行轉換的不良資料會在變更集誤差中被標示出來。

我們可以驗證的內容不只於欄位是否為必要欄位。讓我們來看看一些更精細的驗證。

假設我們有一個需求,我們系統中的所有傳記都必須至少有兩個字元長。我們可以輕易地透過在驗證 bio 欄位長度的變更集管道中加入另一個轉換來達成。 p

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

現在,如果我們嘗試替我們的使用者的 bio 轉換包含 "A" 值的資料,我們應該會在變更集誤差中看到失敗的驗證。

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]}

如果我們對於傳記能達到的最大長度也有需求,我們可以簡單地加入另一個驗證。

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]}

我們可以在變更集中執行更多驗證與轉換。更多說明請參閱 Ecto 變更集文件

資料持續性

我們已經探討過遷移和結構,但我們尚未保存我們的結構或變更集。稍早我們在 lib/hello/repo.ex 中簡短地觀看了我們的儲存庫模組,而現在是時候利用它了。

Ecto 儲存庫是儲存系統的介面,不論是 PostgreSQL 等的資料庫,還是 RESTful API 等的外部服務。 Repo 模組的目的在於照顧我們在持久和資料查詢中的更多細節。身為呼叫者,我們只需要關注提取和儲存資料。 Repo 模組照顧著基礎的資料庫適配器通訊、連線池和資料庫約束違規的錯誤轉譯。

讓我們透過 iex -S mix 重新前往 IEx,並將幾個使用者插入資料庫中。

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]
 }}

我們從別名開始,分別為方便存取而別名 UserRepo 模組。接下來,我們透過使用者結構呼叫 Repo.insert/2。由於我們在 dev 環境中,我們可以看到儲存庫在插入 underlying %User{} 資料時執行的查詢的除錯日誌。我們收到一個兩元素的組元,包含 {:ok, %User{}},告知我們這個插入成功了。

我們也可以透過傳遞變更集給 Repo.insert/2 來插入一個使用者。如果變更集有經過驗證,儲存庫會使用一個最佳化的資料庫查詢來插入記錄,並返回一個兩元素的組元,如同上面一樣。如果變更集沒有經過驗證,我們會收到一個包含 :error 和無效變更集的兩元素的組元。

在插入幾個使用者之後,讓我們從 repo 中提取它們。

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 結構,並將它轉換成我們資料庫的名下 SQL 查詢。在提取資料之後,Repo 接著使用我們的 Ecto 結構將資料庫的數值依據我們的 User 結構,轉址回 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 對應他們的電子郵件地址的對應。

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"}
]

這個小查詢包含許多功能。它同時從資料庫提取所有使用者電子郵件,並在一開始有效率地建立結果對應。你應該瀏覽 Ecto.Query 文件 以查看支援的查詢功能範圍。

除了插入之外,我們還可以透過 Repo.update/2Repo.delete/2 執行更新和刪除以更新或刪除單一結構。Ecto 也支援透過 Repo.insert_all/3Repo.update_all/3Repo.delete_all/2 函數進行大量持久化。

Ecto 還有更多功能,我們僅略微了解。在建立穩固的 Ecto 基礎後,現在我們準備繼續建立我們的應用程式,並將面向網路的應用程式整合到後端持久性。在此過程中,我們將擴充 Ecto 知識,並學習如何適當地將網路介面與系統的基礎細節隔離。請參閱 Ecto 文件 以了解後續。

在我們的 內容指南 中,我們將找出如何將我們的 Ecto 存取和商業邏輯包裝到包含相關功能的模組後方。我們將看到 Phoenix 如何協助我們設計可維護的應用程式,並且我們將逐步找出其他實用的 Ecto 功能。

使用其他資料庫

Phoenix 應用程式預設設定為使用 PostgreSQL,但如果我們想要使用其他資料庫,例如 MySQL 呢?在此部分,我們將逐步說明如何變更此預設值,無論我們準備建立一個新的應用程式,還是我們有一個已為 PostgreSQL 設定的現有應用程式。

如果我們準備建立一個新的應用程式,要設定應用程式使用 MySQL 很簡單。我們可以簡單地將 --database mysql 旗標傳遞給 phx.new,一切都將正確地設定完成。

$ mix phx.new hello_phoenix --database mysql

這將自動為我們設定所有正確的依存性關係和配置。一旦我們使用 mix deps.get 安裝那些依存性關係後,我們就可以開始在應用程式中使用 Ecto 了。

如果我們有一個現有的應用程式,我們所需要做的只是切換配接器並進行一些小的配置變更。

要切換配接器,我們需要移除 Postgrex 依存性關係並新增一個 MyXQL 關係。

讓我們打開我們的 mix.exs 檔案並現在就這樣做。

defmodule HelloPhoenix.MixProject do
  use Mix.Project

  . . .
  # Specifies your project dependencies.
  #
  # Type `mix help deps` for examples and options.
  defp deps do
    [
      {:phoenix, "~> 1.4.0"},
      {:phoenix_ecto, "~> 4.4"},
      {:ecto_sql, "~> 3.10"},
      {:myxql, ">= 0.0.0"},
      ...
    ]
  end
end

接下來,我們需要進行配置,更新 config/dev.exs,讓我們的配接器使用預設的 MySQL 認證資訊。

config :hello_phoenix, HelloPhoenix.Repo,
  username: "root",
  password: "",
  database: "hello_phoenix_dev"

如果我們有一個現有的 HelloPhoenix.Repo 的配置區塊,我們可以簡單地變更值來和我們的新的值相符。您也需要在 config/test.exsconfig/runtime.exs(以前為 config/prod.secret.exs)檔案中配置正確的值。

最後的變更是要打開 lib/hello_phoenix/repo.ex 並確保將 :adapter 設定為 Ecto.Adapters.MyXQL

現在我們所需要做的就是抓取我們的新的依存性關係,然後我們就可以開始了。

$ mix deps.get

在我們安裝並配置完新的配接器後,我們就可以建立我們的資料庫了。

$ mix ecto.create

HelloPhoenix.Repo 的資料庫已經建立了。我們也準備好執行任何遷移動作,或使用 Ecto 完成我們可以選擇的任何其他事物。

$ mix ecto.migrate
[info] == Running HelloPhoenix.Repo.Migrations.CreateUser.change/0 forward
[info] create table users
[info] == Migrated in 0.2s

其他選項

雖然 Phoenix 使用 Ecto 專案與資料存取層進行互動,但有許多其他資料存取選項,有些甚至內建在 Erlang 標準程式庫中。 ETS - 可以透過 etso 在 Ecto 中使用 - 以及 DETS 是內建在 OTP 中的鍵值資料儲存裝置。OTP 也提供了稱為 Mnesia 的關聯式資料庫,其有自己的稱為 QLC 的查詢語言。Elixir 和 Erlang 也有許多程式庫可以使用範圍廣泛的熱門資料儲存裝置。

資料世界是您的寶庫,但我們將不會在這些指南中涵蓋這些選項。