9.1 更新用户
最后更新于:2022-04-01 22:29:48
# 9.1 更新用户
编辑用户信息的方法和创建新用户差不多(参见[第 7 章](chapter7.html#sign-up)),创建新用户的页面在 `new` 动作中处理,而编辑用户的页面在 `edit` 动作中处理;创建用户的过程在 `create` 动作中处理 `POST` 请求,编辑用户要在 `update` 动作中处理 `PATCH` 请求([旁注 3.2](chapter3.html#aside-get-etc))。二者之间最大的区别是,任何人都可以注册,但只有当前用户才能更新自己的信息。我们可以使用[第 8 章](chapter8.html#log-in-log-out)实现的认证机制,通过“事前过滤器”(before filter)实现访问限制。
开始实现之前,我们先切换到 `updating-users` 主题分支:
```
$ git checkout master
$ git checkout -b updating-users
```
## 9.1.1 编辑表单
我们先来创建编辑表单,构思图如[图 9.1](#fig-edit-user-mockup)。[[1](#fn-1)]要把这个构思图转换成可以使用的页面,我们既要编写用户控制器的 `edit` 动作,也要创建编辑用户的视图。我们先来编写 `edit` 动作。在 `edit` 动作中我们要从数据库中读取相应的用户。由[表 7.1](chapter7.html#table-restful-users) 得知,用户的编辑页面地址是 /users/1/edit(假设用户的 ID 是 1)。我们知道用户的 ID 可以使用 `params[:id]` 获取,那么就可以使用[代码清单 9.1](#listing-initial-edit-action) 中的代码查找用户。
##### 代码清单 9.1:用户控制器的 `edit` 动作
app/controllers/users_controller.rb
```
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
end
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
log_in @user
flash[:success] = "Welcome to the Sample App!"
redirect_to @user
else
render 'new'
end
end
def edit
@user = User.find(params[:id]) end
private
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
end
```
![edit user mockup bootstrap](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733305accabe.png)图 9.1:用户编辑页面的构思图
用户编辑页面的视图(要手动创建这个文件)如[代码清单 9.2](#listing-user-edit-view) 所示。注意,这个视图和[代码清单 7.13](chapter7.html#listing-signup-form) 中新建用户的视图很相似,有很多重复的代码,所以可以重构,把共用的代码放到局部视图中,这个任务留作练习([9.6 节](#updating-showing-and-deleting-users-exercises))。
##### 代码清单 9.2:用户编辑页面的视图
app/views/users/edit.html.erb
```
<% provide(:title, "Edit user") %>
```
这里再次用到了 [7.3.3 节](chapter7.html#signup-error-messages)创建的 `error_messages` 局部视图。顺便说一下,修改 Gravatar 头像的链接用到了 `target="_blank"`,目的是在新窗口或选项卡中打开这个网页。链接到第三方网站时一般都会这么做。
[代码清单 9.1](#listing-initial-edit-action) 中定义了 `@user` 实例变量,所以编辑页面可以正确渲染,如[图 9.2](#fig-edit-page) 所示。从“Name”和“Email”字段可以看出,Rails 会自动使用 `@user` 变量的属性值填写相应的字段。
![edit page 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733305b01c14.png)图 9.2:编辑页面初始版本,名字和电子邮件地址自动填入了值
查看用户编辑页面的 HTML 源码,会看到预期的表单标签,如[代码清单 9.3](#listing-edit-form-html) 所示(某些细节可能不同)。
##### 代码清单 9.3:[代码清单 9.2](#listing-user-edit-view) 定义的编辑表单生成的 HTML
```
```
留意一下这个隐藏字段:
```
```
因为浏览器并不支持发送 `PATCH` 请求([表 7.1](chapter7.html#table-restful-users) 中的 REST 动作要用),所以 Rails 在 `POST` 请求中使用这个隐藏字段伪造了一个 `PATCH` 请求。[[2](#fn-2)]
还有一个细节需要注意一下,[代码清单 9.2](#listing-user-edit-view) 和[代码清单 7.13](chapter7.html#listing-signup-form) 都使用了相同的 `form_for(@user)` 来构建表单,那么 Rails 是怎么知道创建新用户要发送 `POST` 请求,而编辑用户时要发送 `PATCH` 请求的呢?这个问题的答案是,通过 Active Record 提供的 `new_record?` 方法检测用户是新创建的还是已经存在于数据库中:
```
$ rails console
>> User.new.new_record?
=> true
>> User.first.new_record?
=> false
```
所以使用 `form_for(@user)` 构建表单时,如果 `@user.new_record?` 返回 `true`,发送 `POST` 请求,否则发送 `PATCH` 请求。
最后,我们要把导航中指向编辑用户页面的链接换成真实的地址。很简单,我们直接使用[表 7.1](chapter7.html#table-restful-users) 中列出的 `edit_user_path` 具名路由,并把参数设为[代码清单 8.36](chapter8.html#listing-persistent-current-user) 中定义的 `current_user` 辅助方法:
```
<%= link_to "Settings", edit_user_path(current_user) %>
```
完整的视图如[代码清单 9.4](#listing-settings-link) 所示。
##### 代码清单 9.4:在网站布局中设置“Settings”链接的地址
app/views/layouts/_header.html.erb
```
```
## 9.1.2 编辑失败
本节我们要处理编辑失败的情况,过程和处理注册失败差不多([7.3 节](chapter7.html#unsuccessful-signups))。我们要先定义 `update` 动作,把提交的 `params` 哈希传给 `update_attributes` 方法([6.1.5 节](chapter6.html#updating-user-objects)),更新用户,如[代码清单 9.5](#listing-user-update-action-unsuccessful) 所示。如果提交的数据无效,更新操作会返回 `false`,由 `else` 分支处理,重新渲染编辑页面。我们之前用过类似的处理方式,代码结构和第一个版本的 `create` 动作类似([代码清单 7.16](chapter7.html#listing-first-create-action))。
##### 代码清单 9.5:`update` 动作初始版本
app/controllers/users_controller.rb
```
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
end
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save log_in @user
flash[:success] = "Welcome to the Sample App!"
redirect_to @user
else
render 'new' end
end
def edit
@user = User.find(params[:id])
end
def update
@user = User.find(params[:id])
if @user.update_attributes(user_params) # 处理更新成功的情况
else
render 'edit' end
end
private
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
end
```
注意在调用 `update_attributes` 方法时指定的 `user_params` 参数,这种用法是“健壮参数”(strong parameter),可以避免批量赋值带来的安全隐患(参见 [7.3.2 节](chapter7.html#strong-parameters))。
因为用户模型中定义了验证规则,而且[代码清单 9.2](#listing-user-edit-view) 中渲染了错误消息局部视图,所以提交无效信息后会显示一些有用的错误消息,如[图 9.3](#fig-buggy-edit-with-invalid-information) 所示。
![edit with invalid information 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733305b1ea67.png)图 9.3:提交编辑表单后显示的错误消息
## 9.1.3 编辑失败的测试
[9.1.2 节](#unsuccessful-edits)结束时编辑表单已经可以使用,按照[旁注 3.3](chapter3.html#aside-when-to-test) 中的测试指导方针,现在我们要编写集成测试捕获回归。和之前一样,首先要生成一个集成测试文件:
```
$ rails generate integration_test users_edit
invoke test_unit
create test/integration/users_edit_test.rb
```
然后为编辑失败编写一个简单的测试,如[代码清单 9.6](#listing-unsuccessful-edit-test) 所示。在这段测试中,我们检查提交无效信息后会重新渲染编辑模板,以此确认表现是否正确。注意,这里使用 `patch` 方法发起 `PATCH` 请求,用法与 `get`、`post` 和 `delete` 类似。
##### 代码清单 9.6:编辑失败的测试 GREEN
test/integration/users_edit_test.rb
```
require 'test_helper'
class UsersEditTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
test "unsuccessful edit" do
get edit_user_path(@user)
patch user_path(@user), user: { name: '',
email: 'foo@invalid',
password: 'foo',
password_confirmation: 'bar' }
assert_template 'users/edit'
end
end
```
此时,测试组件应该可以通过:
##### 代码清单 9.7:**GREEN**
```
$ bundle exec rake test
```
## 9.1.4 编辑成功(使用 TDD)
现在我们要让编辑表单能正常使用。编辑头像的功能已经有了,因为我们把上传头像的操作交由 Gravatar 处理,如需更换头像,点击[图 9.2](#fig-edit-page) 中的“change”链接就可以了,如[图 9.4](#fig-gravatar-cropper) 所示。下面我们来实现编辑其他信息的功能。
![gravatar cropper](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733305b3ae6b.png)图 9.4:Gravatar 的图片剪切界面,上传了一个[帅哥](http://www.michaelhartl.com/)的图片
上手测试后,你可能会发现,编写应用代码之前编写测试比之后再写更有用。针对现在这种情况,我们要编写的是“验收测试”(acceptance test),由测试的结果决定某个功能是否完成。为了演示如何编写验收测试,我们要使用测试驱动开发技术完成用户编辑功能。
我们要编写类似[代码清单 9.6](#listing-unsuccessful-edit-test) 中的测试,确认更新用户的操作表现正确,只不过这一次我们会提交有效的信息。然后检查显示了闪现消息,而且成功重定向到了用户的资料页面,同时还要确认数据库中保存的用户信息也正确更新了。这个测试如[代码清单 9.8](#listing-successful-edit-test) 所示。注意,在[代码清单 9.8](#listing-successful-edit-test) 中,密码和密码确认都为空值,因为修改用户名和电子邮件地址时并不想修改密码。还要注意,我们使用 `@user.reload`([6.1.5 节](chapter6.html#updating-user-objects)首次用到)重新加载数据库中存储的值,以此确认成功更新了信息。(新手很容易忘记这个操作,这就是为什么必须要有一定的经验才能编写有效的验收测试(推及到 TDD)的原因。)
##### 代码清单 9.8:编辑成功的测试 RED
test/integration/users_edit_test.rb
```
require 'test_helper'
class UsersEditTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
.
.
.
test "successful edit" do
get edit_user_path(@user)
name = "Foo Bar"
email = "foo@bar.com"
patch user_path(@user), user: { name: name,
email: email,
password: "",
password_confirmation: "" }
assert_not flash.empty?
assert_redirected_to @user
@user.reload
assert_equal @user.name, name
assert_equal @user.email, email
end
end
```
要让[代码清单 9.8](#listing-successful-edit-test) 中的测试通过,我们可以参照最终版 `create` 动作([代码清单 8.22](chapter8.html#listing-login-upon-signup))来编写 `update` 动作,如[代码清单 9.9](#listing-user-update-action) 所示。
##### 代码清单 9.9:用户控制器的 `update` 动作 RED
app/controllers/users_controller.rb
```
class UsersController < ApplicationController
.
.
.
def update
@user = User.find(params[:id])
if @user.update_attributes(user_params)
flash[:success] = "Profile updated" redirect_to @user else
render 'edit'
end
end
.
.
.
end
```
如[代码清单 9.9](#listing-user-update-action) 的标题所示,测试组件无法通过,因为密码长度验证([代码清单 6.39](chapter6.html#listing-password-implementation))失败了,这是因为[代码清单 9.8](#listing-successful-edit-test) 中密码和密码确认都是空值。为了让测试通过,我们要在密码为空值时特殊处理最短长度验证,方法是把 `allow_nil: true` 参数传给 `validates` 方法,如[代码清单 9.10](#listing-allow-blank-password) 所示。
##### 代码清单 9.10:更新时允许密码为空 GREEN
app/models/user.rb
```
class User < ActiveRecord::Base
attr_accessor :remember_token
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 }
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true .
.
.
end
```
你可能担心这么改用户注册时可以把密码设为空值,其实不然,[6.3.3 节](chapter6.html#minimum-password-standards)说过,创建对象时,`has_secure_password` 会执行存在性验证,捕获密码为 `nil` 的情况。(密码为 `nil` 时能通过存在性验证,可是会被 `has_secure_password` 方法的验证捕获,因此修正了 [7.3.3 节](chapter7.html#signup-error-messages)提到的错误消息重复问题。)
至此,用户编辑页面应该可以正常使用了,如[图 9.5](#fig-edit-form-working) 所示。你也可以运行测试组件确认一下,应该可以通过:
##### 代码清单 9.11:**GREEN**
```
$ bundle exec rake test
```
![edit form working](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733305b5d69f.png)图 9.5:编辑成功后显示的页面
';
Update your profile
<%= form_for(@user) do |f| %>
<%= render 'shared/error_messages' %>
<%= f.label :name %>
<%= f.text_field :name, class: 'form-control' %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :password_confirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: 'form-control' %>
<%= f.submit "Save changes", class: "btn btn-primary" %>
<% end %>
<%= gravatar_for @user %>
change
<%= link_to "sample app", root_path, id: "logo" %>