Chương 9: Quan hệ nhiều nhiều và upserts
Trong chương trước, chúng ta đã học về many_to_many
và cách để ánh xạ dữ liệu từ bên ngoài tới các thực thể liên kết bằng hàm Ecto.Changeset.cast_assoc/3
. Để làm chuyện đó, chúng ta phải tuân theo các luật định nghĩa bởi hàm cast_assoc/3
. Điều này đôi khi không phải là mong muốn của lập trình viên, cũng như trong một số tình huống là bất khả thi.
Trong chương này, chúng ta sẽ tìm hiểu về Ecto.Changeset.put_assoc/4
và khám phá một số ví dụ. Chúng ta cũng sẽ nói về tính năng upsert đi kèm với Ecto 2.1.
put_assoc và cast_assoc
Tưởng tượng chúng ta đang xây dựng một ứng dụng blog, mỗi một post có thể có nhiều tag. Không chỉ vậy, một tag có thể thuộc về nhiều post. Đây là tình huống cổ điển để sử dụng liên kết many_to_many
. Chúng ta có thể tổ chức Database như sau:
create table(:posts) do
add :title
add :body
timestamps()
end
create table(:tags) do
add :name
timestamps()
end
create unique_index(:tags, [:name])
create table(:posts_tags, primary_key: false) do
add :post_id, references(:posts)
add :tag_id, references(:tags)
end
Chú ý rằng chúng ta thêm vào một "unique index" cho tag name bởi vì chúng ta không muốn có các tag bị lặp trong Database. Việc thêm index kiểm tra tính duy nhất ở mức Database thay vì ở mức ứng dụng là rất quan trọng, vì sẽ luôn luôn có trường hợp 2 tag với cùng tên sẽ được kiểm tra và cùng được insert vào Database một lúc, việc này sẽ dẫn tới chuyện chúng ta có các thực thể trùng nhau.
Giờ hãy cùng nhau tưởng tượng rằng chúng ta muốn người dùng điền vào các tags như một danh sách các từ được ngăn cách bởi dấu phẩy, ví dụ như: "elixir,erlang,ecto". Một khi dữ liệu này được gửi tới server, chúng ta sẽ chia nhỏ nó thành nhiều tag và liên kết chúng với post hiện tại, đồng thời tạo ra các tag chưa có trong Database.
Trong khi các điều kiện trên khá là hợp lý, nó lại tạo ra các vấn đề khác với cast_assoc/3
. Nhớ rằng hàm cast_assoc/3
được thiết kế để nhận vào các tham số và so sánh chúng với các dữ liệu liên kết với struct của chúng ta. Để làm điều này, Ecto yêu cầu các tag cần phải được gửi như là một list các map. Tuy nhiên ở đây, chúng ta mong muốn tag sẽ được gửi lên như một chuỗi được ngăn cách bởi các dấu phảy.
Hơn thế nữa cast_assoc/3
phụ thuộc vào trường primary key cho mỗi tag để có thể quyết định nó nên insert, update hoặc là delete. Lại một lần nữa, bởi vì người dùng đơn giản chỉ gửi lên một xâu, chúng ta không thể có thông tin về ID.
Khi chúng ta không thể sử dụng cast_assoc/3
, đó chính là lúc sử dụng put_assoc/4
. Với put_assoc/4
, chúng ta đưa cho Ecto các struct hoặc các changeset thay vì các tham số, điều này giúp chúng ta có khả năng kiểm soát dữ liệu theo cách chúng ta muốn. Hãy cùng định nghĩa schema và hàm changeset cho một post để có thể nhận vào các tags như một chuỗi:
defmodule MyApp.Post do
use Ecto.schema
schema "posts" do
add :title
add :body
many_to_many :tags, MyApp.Tag, join_through: "posts_tags"
timestamps()
end
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:title, :body])
|> Ecto.Changeset.put_assoc(:tags, parse_tags(params)
end
defp parse_tags(params) do
(params["tags"] || "")
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(& &1 == "")
|> Enum.map(&get_or_insert_tag/1)
end
defp get_or_insert_tag(name) do
Repo.get_by(MyApp.Tag, name: name) ||
Repo.insert!(MyApp.Tag, %Tag{name: name})
end
end
Trong hàm changeset ở trên, chúng ta đã di chuyển tất cả mọi việc liên quan tới xử lý tags sang một hàm mới parse_tags/1
, hàm này sẽ kiểm tra tham số, chia nhỏ nó thành các phần dựa vào String/split/2
, sau đó loại bỏ đi các dấu cách ở đầu và cuối mỗi phần tử với String.trim/1
, xoá đi các phần tử rỗng và cuối cùng, kiểm tra xem tag có tồn tại trong Database hay không, nếu chưa có thì tạo mới.
Hàm parse_tags/1
sẽ trả về một list các MyApp.Tag
mà sau đó sẽ được truyền cho put_assoc/3
. Bằng việc gọi put_assoc/3
, chúng ta nói với Ecto rằng, list này sẽ là các tags được liên kết với post hiện tại. Trong trường hợp có một tag đã từng được liên kết với post, nhưng không có trong tag hiện tại, Ecto sẽ tự động xoá liên kết của tag đó với post, và xoá tag khỏi Database.
Và đó là tất cả những gì chúng ta cần để sử dụng many_to_many
với put_assoc/3
. put_assoc/3
làm việc với has_many
, belongs_to
và tất cả các kiểu liên kết khác. Tuy nhiên, code của chúng ta chưa sẵn sàng để lên production. Hãy cùng xem vì sao.
Constraints và race coditions
Nhớ rằng, chúng ta đã thêm vào một unique index cho cột :name
khi tạo bảng "tags". Chúng ta làm như vậy để bảo vệ Database khỏi các tag trùng lặp.
Bằng việc thêm unique index và sau đó sử dụng get_by
với insert!
thể lấy ra hoặc tạo mới một tag, chúng ta đã tạo ra một bug tiền tàng trong ứng dụng. Nếu như 2 posts được tạo ra đồng thời với cùng một tags, sẽ có trường hợp chúng ta kiểm tra tag có tồn tại hay không cùng lúc, dẫn đến cả 2 việc kiểm tra này đều nghĩ rằng chưa có tag nào trong Database. Khi điều này xảy ra, chỉ một trong hai quá trình sẽ thành công, trong khi cái còn lại sẽ thất bại. Đó chính là "race condition": code của bạn sẽ sai theo thời gian, khi mà thoả mãn nó thoả mãn một vài điều kiện rất đặc biệt.
Rất nhiều lập trình viên có xu hướng suy nghĩ là những lỗi như vậy sẽ không gặp trong thực tế, hoặc là nó xảy ra, nó cũng không quan trọng lắm. Tuy nhiên trên thực tế, chúng dẫn tới rất nhiều trải nghiệm xấu cho người dùng. Tôi đã từng nghe một ví dụ về một công ty game, trong game, nếu một người chơi có thể chơi nhiều nhiệm vụ, và trong mỗi nhiệm vụ, bạn phải lựa chọn một nhân vật khách từ một người chơi khác từ một danh sách trong nhiệm vụ của bạn. Đến cuối nhiệm vụ, bạn có quyền lựa chọn thêm nhân vật khác như là bạn của mình.
Thông thường, toàn bộ danh sách khách là ngẫu nhiên, nhưng theo thời gian, các người chơi bắt đầu phàn nàn rằng đôi khi những tài khoản cũ, thường xuyên không active, được hiển thị trong danh sách khách mời của họ. Để cải thiện tình hình, các lập trình viên trong game bắt đầu sắp xếp danh sách khách theo thứ tự thời gian active. Điều đó có nghĩa là, nếu bạn mới chơi gần đây, sẽ có khả năng cao là bạn sẽ nằm trong danh sách khách của người khác.
Tuy nhiên, khi họ thay đổi game như vậy, rất nhiều lỗi đã xảy ra, và người chơi, và người chơi trở nên rất giận dữ trong diễn đàn. Đó là bởi vì khi họ sắp xếp danh sách người chơi theo độ active, ngay khi có 2 người chơi cùng đăng nhập, nhân vật của họ có thể xuất hiện trong danh sách của nhau. Nếu 2 người chơi này chọn nhân vật của người kia, người đầu tiên có thể thêm người còn lại là bạn trong danh sách ở cuối nhiệm vụ, nhưng một lỗi sẽ bị hiện ra với người thứ hai khi người này thêm người thứ nhất vào danh sách bạn của mình, vì mối quan hệ giữa hai người đã tồn tại trong Database. Không chỉ vậy, cả quá trình đã hoàn thành của nhiệm vụ cũng bị mất, bởi vì server không thể lưu lại kết quả của nhiệm vụ vào Database. Không ngạc nhiên người chơi bắt đầu phàn nàn.
Nói tóm lại: chúng ta phải để tâm tới race condition.
May mắn thay, Ecto cung cấp một cơ chế để có thể xử lý những lỗi xảy ra từ Database.
Kiểm tra cho constraint errors
Chúng ta hãy viết lại hàm get_or_insert_tag(name)
với việc chú ý tới race condition:
defp get_or_insert_tag(name) do
%MyApp.Tag{}
|> Ecto.Changeset.change(name: name)
|> Ecto.Changeset.unique_constraint(:name)
|> Repo.insert
|> case do
{:ok, tag} -> tag
{:error_, _} -> Repo.get_by!(MyApp.Tag, name: name)
end
end
Thay vì insert tag trực tiếp, chúng ta tạo ra một changeset để cho phép chúng ta sử dụng hàm unique_constraint
. Giờ nếu như hành động Repo.insert
thất bại bởi vì unique index của :name
, Ecto sẽ không raise Exception, mà trả về một tuple {:error, changest}
. Do đó, nếu Repo.insert
thành công thì nghĩa là tag đã được lưu lại, còn nếu không tức là tag đã tồn tại trong Database, lúc này chúng ta sẽ lấy tag ra bằng hàm Repo.get_by!
.
Trong khi cơ chế trên fix được race condition, nó lại là một hành động khá tốn kém: chúng ta cần phải gọi hai câu query cho mỗi tag đã tồn tại trong Database: một câu insert (thất bại), và một câu get. Dựa vào độ phổ biến của các trường hợp, chúng ta có thể viết lại nó như sau:
defp get_or_insert_tag(name) do
Repo.get_by(MyApp.Tag, name: name) || maybe_insert_tag(name)
end
defp maybe_insert(name) do
%MyApp.Tag{}
|> Ecto.Changeset.change(name: name)
|> Ecto.Changeset.unique_constraint(:name)
|> Repo.insert
|> case do
{:ok, tag} -> tag
{:error, _} -> Repo.get_by!(MyApp.Tag, name: name)
end
end
Đoạn code trên sẽ gọi một 1 query cho mỗi tag đã tồn tại, 2 query cho mỗi tag mới, và có thể là 3 query cho trường hợp race condition. Mặc dù cách giải quyết như vậy là tốt hơn so với cách cũ, nhưng Ecto 2.1 còn cung cấp một lựa chọn tốt hơn.
Upserts
Ecto 2.1 hỗ trợ tính năng "upsert" - tức là "update hoặc insert". Ý tưởng là chúng ta sẽ cố gắng insert một bản ghi và trong trường hợp nó bị xung đột với một bản ghi đã tồn tại trong Database, ví dụ vì unique index, chúng ta có thể lựa chọn cách mà chúng ta muốn Database hành động bằng cách hoặc là raise Exception (cách mặc định), hoặc là bỏ qua hành động insert (và không có lỗi), hoặc là cập nhật sự xung đột với các bản ghi trong Database.
upsert
trong Ecto 2.1 được thực hiện với lựa chọn :on_conflict
. Hãy cùng viết lại get_or_insert_tag(name)
một lần nữa, nhưng lần này chung ta sử dụng lựa chọn :on_conflict
. Cũng cần nhớ rằng chức năng "upsert" là một tính năng của PostgreSQL 9.5, vì thế hãy chắc chắn là bạn đã có bản cập nhật này:
defp get_or_insert_tag(name) do
Repo.insert!(%MyApp.Tag{name: name}, on_conflict: :nothing)
end
Chúng ta cố gắng thêm một tag với name
vào Database, và nếu như tag đã tồn tại, chúng ta nói với Ecto rằng đó không phải là lỗi, và trả về tag mà chúng ta đã truyền vào như một tham số nếu như nó đã có trong Database. Trong khi cách làm ở trên là một giải pháp nâng cấp so với những giải pháp trước đây, nhưng nó vẫn thực hiện 1 câu query cho mỗi một tag, nếu có 10 tags được gửi lên, nó sẽ thực hiện 10 câu query. Liệu chúng ta có thể cải tiến nó không?
Upserts và insert_all
Ecto 2.1 không chỉ thêm vào lựa chọn :on_conflict
vào Repo.insert/2
mà còn thêm nó vào cả API Repo.insert_all/3
. Điều đó có nghĩa là chúng ta có thể xây dựng một câu query có thể insert tất cả các tags còn thiếu và một câu query khác lấy ra tất cả những tag cùng một lúc. Hãy cùng xem Post
scheam sẽ như nào với những thay đổi trên:
defmodule MyApp.Post do
use Ecto.Schema
# Schema is the same
schema "posts" do
field :title
field :body
many_to_many :tags, MyApp.Tag, join_through: "posts_tags"
timestamps()
end
# Changeset is the same
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:title, :body])
|> Ecto.Changeset.put_assoc(:tags, parse_tags(params))
end
# Parse tags has slightly changed
defp parse_tags(params) do
(params["tags"] || "")
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(& &1 == "")
|> insert_and_get_all
end
defp insert_and_get_all([]) do
[]
end
defp insert_and_get_all(names) do
maps = Enum.map(names, &%{name: &1})
Repo.insert_all MyApp.Tag, maps, on_conflict: :nothing
Repo.all from t in MyApp.Tag, where: t.name in ^names
end
end
Thay vì cố gắng get và insert từng tag một, đoạn code trên làm việc với tất cả các tags một lúc, đầu tiên nó build một list các maps, sau đó truyền maps này cho insert_all
, cuối cùng lấy ra tất cả các tags đã có trong Database. Bất kể bao nhiêu tag được gửi lên đi nữa, chúng ta cũng chỉ cần thực hiện 2 thao tác (trừ trường hợp không có tag nào được gửi lên, chúng ta sẽ trả về một list rỗng ngay lập tức). Giải pháp này khả thi trong Ecto 2.1 là nhờ có lựa chọn :on_conflict
đã đảm bảo rằng insert_all
không thất bại nếu có một tag đã tồn tại.
Cuối cùng, nhớ rằng chúng ta chưa hề sử dụng transaction trong bất kỳ ví dụ nào trong cuốn sách này. Quyết định này là có lý do. Từ việc lấy ra hoặc insert các tag là một hoạt động không thay đổi (idempotent) theo nghĩa chúng ta có thể lặp lại nó rất nhiều lần, nhưng kết quả vẫn luôn luôn là như nhau. Do đó, nếu chúng ta thất bại trong việc lưu trữ post vào database bởi vì một lỗi ở lúc kiểm trả, người dùng vẫn được tự do để submit lại form của họ, và chúng ta chỉ cần get hoặc insert tag một lần nữa. Bất lợi của cách làm này là tag có thể sẽ được tạo ra mặc dù việc tạo ra post thất bại. Điều này dẫn tới một vài tags sẽ không liên kết với bất cứ post nào. Trong trường hợp bạn không muốn điều này xảy ra, toàn bộ hành động này nên được bao trong một transaction, hoặc là được mô hình hoá với Ecto.Multi
như chúng ta sẽ thảo luận trong chương kế tiếp.
Last updated
Was this helpful?