提交 0152fe97 编写于 作者: J José Valim

Merge branch 'gzip-index' which contains two features:

1) Adding gzip to pages cache, closes #4124

2) Allow scaffold/model/migration generators to accept a "index" and "uniq"
modifiers, as in: "tracking_id:integer:uniq" in order to generate (unique)
indexes. Some types also accept custom options, for instance, you can
specify the precision and scale for decimals as "price:decimal{7,2}".
This feature closes #2555.
......@@ -35,7 +35,7 @@ module Rendering
include AbstractController::ViewPaths
included do
config_accessor :protected_instance_variables, :instance_reader => false
class_attribute :protected_instance_variables
self.protected_instance_variables = []
end
......@@ -59,12 +59,6 @@ def view_context_class
attr_internal_writer :view_context_class
# Explicitly define protected_instance_variables so it can be
# inherited and overwritten by other modules if needed.
def protected_instance_variables
config.protected_instance_variables
end
def view_context_class
@_view_context_class ||= self.class.view_context_class
end
......
......@@ -16,7 +16,7 @@ module Caching
# caches_page :show, :new
# end
#
# This will generate cache files such as <tt>weblog/show/5.html</tt> and <tt>weblog/new.html</tt>, which match the URLs used
# This will generate cache files such as <tt>weblog/show/5.html</tt> and <tt>weblog/new.html</tt>, which match the URLs used
# that would normally trigger dynamic page generation. Page caching works by configuring a web server to first check for the
# existence of files on disk, and to serve them directly when found, without passing the request through to Action Pack.
# This is much faster than handling the full dynamic request in the usual way.
......@@ -38,23 +38,25 @@ module Pages
extend ActiveSupport::Concern
included do
##
# :singleton-method:
# The cache directory should be the document root for the web server and is set using <tt>Base.page_cache_directory = "/document/root"</tt>.
# For Rails, this directory has already been set to Rails.public_path (which is usually set to <tt>Rails.root + "/public"</tt>). Changing
# this setting can be useful to avoid naming conflicts with files in <tt>public/</tt>, but doing so will likely require configuring your
# web server to look in the new location for cached files.
config_accessor :page_cache_directory
class_attribute :page_cache_directory
self.page_cache_directory ||= ''
##
# :singleton-method:
# Most Rails requests do not have an extension, such as <tt>/weblog/new</tt>. In these cases, the page caching mechanism will add one in
# order to make it easy for the cached files to be picked up properly by the web server. By default, this cache extension is <tt>.html</tt>.
# If you want something else, like <tt>.php</tt> or <tt>.shtml</tt>, just set Base.page_cache_extension. In cases where a request already has an
# extension, such as <tt>.xml</tt> or <tt>.rss</tt>, page caching will not add an extension. This allows it to work well with RESTful apps.
config_accessor :page_cache_extension
class_attribute :page_cache_extension
self.page_cache_extension ||= '.html'
# The compression used for gzip. If false (default), the page is not compressed.
# If can be a symbol showing the ZLib compression method, for example, :best_compression
# or :best_speed or an integer configuring the compression level.
class_attribute :page_cache_compression
self.page_cache_compression ||= false
end
module ClassMethods
......@@ -66,24 +68,31 @@ def expire_page(path)
instrument_page_cache :expire_page, path do
File.delete(path) if File.exist?(path)
File.delete(path + '.gz') if File.exist?(path + '.gz')
end
end
# Manually cache the +content+ in the key determined by +path+. Example:
# cache_page "I'm the cached content", "/lists/show"
def cache_page(content, path, extension = nil)
def cache_page(content, path, extension = nil, gzip = Zlib::BEST_COMPRESSION)
return unless perform_caching
path = page_cache_path(path, extension)
instrument_page_cache :write_page, path do
FileUtils.makedirs(File.dirname(path))
File.open(path, "wb+") { |f| f.write(content) }
if gzip
Zlib::GzipWriter.open(path + '.gz', gzip) { |f| f.write(content) }
end
end
end
# Caches the +actions+ using the page-caching approach that'll store the cache in a path within the page_cache_directory that
# Caches the +actions+ using the page-caching approach that'll store
# the cache in a path within the page_cache_directory that
# matches the triggering url.
#
# You can also pass a :gzip option to override the class configuration one.
#
# Usage:
#
# # cache the index action
......@@ -91,10 +100,28 @@ def cache_page(content, path, extension = nil)
#
# # cache the index action except for JSON requests
# caches_page :index, :if => Proc.new { |c| !c.request.format.json? }
#
# # don't gzip images
# caches_page :image, :gzip => false
def caches_page(*actions)
return unless perform_caching
options = actions.extract_options!
after_filter({:only => actions}.merge(options)) { |c| c.cache_page }
gzip_level = options.fetch(:gzip, page_cache_compression)
gzip_level = case gzip_level
when Symbol
Zlib.const_get(gzip_level.to_s.upcase)
when Fixnum
gzip_level
when false
nil
else
Zlib::BEST_COMPRESSION
end
after_filter({:only => actions}.merge(options)) do |c|
c.cache_page(nil, nil, gzip_level)
end
end
private
......@@ -136,7 +163,7 @@ def expire_page(options = {})
# Manually cache the +content+ in the key determined by +options+. If no content is provided, the contents of response.body is used.
# If no options are provided, the url of the current request being handled is used. Example:
# cache_page "I'm the cached content", :controller => "lists", :action => "show"
def cache_page(content = nil, options = nil)
def cache_page(content = nil, options = nil, gzip = Zlib::BEST_COMPRESSION)
return unless self.class.perform_caching && caching_allowed?
path = case options
......@@ -152,7 +179,7 @@ def cache_page(content = nil, options = nil)
extension = ".#{type_symbol}"
end
self.class.cache_page(content || response.body, path, extension)
self.class.cache_page(content || response.body, path, extension, gzip)
end
end
......
......@@ -56,7 +56,7 @@ module Helpers
include AbstractController::Helpers
included do
config_accessor :helpers_path, :include_all_helpers
class_attribute :helpers_path, :include_all_helpers
self.helpers_path ||= []
self.include_all_helpers = true
end
......
......@@ -14,10 +14,14 @@ class CachingController < ActionController::Base
end
class PageCachingTestController < CachingController
self.page_cache_compression = :best_compression
caches_page :ok, :no_content, :if => Proc.new { |c| !c.request.format.json? }
caches_page :found, :not_found
caches_page :about_me
caches_page :default_gzip
caches_page :no_gzip, :gzip => false
caches_page :gzip_level, :gzip => :best_speed
def ok
head :ok
......@@ -40,6 +44,18 @@ def custom_path
cache_page("Super soaker", "/index.html")
end
def default_gzip
render :text => "Text"
end
def no_gzip
render :text => "PNG"
end
def gzip_level
render :text => "Big text"
end
def expire_custom_path
expire_page("/index.html")
head :ok
......@@ -115,6 +131,30 @@ def test_should_expire_cache_with_custom_path
assert !File.exist?("#{FILE_STORE_PATH}/index.html")
end
def test_should_gzip_cache
get :custom_path
assert File.exist?("#{FILE_STORE_PATH}/index.html.gz")
get :expire_custom_path
assert !File.exist?("#{FILE_STORE_PATH}/index.html.gz")
end
def test_should_allow_to_disable_gzip
get :no_gzip
assert File.exist?("#{FILE_STORE_PATH}/page_caching_test/no_gzip.html")
assert !File.exist?("#{FILE_STORE_PATH}/page_caching_test/no_gzip.html.gz")
end
def test_should_use_config_gzip_by_default
@controller.expects(:cache_page).with(nil, nil, Zlib::BEST_COMPRESSION)
get :default_gzip
end
def test_should_set_gzip_level
@controller.expects(:cache_page).with(nil, nil, Zlib::BEST_SPEED)
get :gzip_level
end
def test_should_cache_without_trailing_slash_on_url
@controller.class.cache_page 'cached content', '/page_caching_test/trailing_slash'
assert File.exist?("#{FILE_STORE_PATH}/page_caching_test/trailing_slash.html")
......@@ -194,7 +234,7 @@ class ActionCachingTestController < CachingController
caches_action :show, :cache_path => 'http://test.host/custom/show'
caches_action :edit, :cache_path => Proc.new { |c| c.params[:id] ? "http://test.host/#{c.params[:id]};edit" : "http://test.host/edit" }
caches_action :with_layout
caches_action :with_format_and_http_param, :cache_path => Proc.new { |c| { :key => 'value' } }
caches_action :with_format_and_http_param, :cache_path => Proc.new { |c| { :key => 'value' } }
caches_action :layout_false, :layout => false
caches_action :record_not_found, :four_oh_four, :simple_runtime_error
......@@ -224,7 +264,7 @@ def with_format_and_http_param
@cache_this = MockTime.now.to_f.to_s
render :text => @cache_this
end
def record_not_found
raise ActiveRecord::RecordNotFound, "oops!"
end
......
......@@ -3,7 +3,7 @@
module ActiveRecord
module Generators
class MigrationGenerator < Base
argument :attributes, :type => :array, :default => [], :banner => "field:type field:type"
argument :attributes, :type => :array, :default => [], :banner => "field[:type][:index] field[:type][:index]"
def create_migration_file
set_local_assigns!
......
......@@ -2,14 +2,20 @@ class <%= migration_class_name %> < ActiveRecord::Migration
<%- if migration_action == 'add' -%>
def change
<% attributes.each do |attribute| -%>
add_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %>
add_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %><%= attribute.inject_options %>
<%- if attribute.has_index? -%>
add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %>
<%- end %>
<%- end -%>
end
<%- else -%>
def up
<% attributes.each do |attribute| -%>
<%- if migration_action -%>
<%= migration_action %>_column :<%= table_name %>, :<%= attribute.name %><% if migration_action == 'add' %>, :<%= attribute.type %><% end %>
<%= migration_action %>_column :<%= table_name %>, :<%= attribute.name %><% if migration_action == 'add' %>, :<%= attribute.type %><%= attribute.inject_options %><% end %>
<% if attribute.has_index? && migration_action == 'add' %>
add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %>
<% end -%>
<%- end -%>
<%- end -%>
end
......@@ -17,7 +23,7 @@ def up
def down
<% attributes.reverse.each do |attribute| -%>
<%- if migration_action -%>
<%= migration_action == 'add' ? 'remove' : 'add' %>_column :<%= table_name %>, :<%= attribute.name %><% if migration_action == 'remove' %>, :<%= attribute.type %><% end %>
<%= migration_action == 'add' ? 'remove' : 'add' %>_column :<%= table_name %>, :<%= attribute.name %><% if migration_action == 'remove' %>, :<%= attribute.type %><%= attribute.inject_options %><% end %>
<%- end -%>
<%- end -%>
end
......
......@@ -3,7 +3,7 @@
module ActiveRecord
module Generators
class ModelGenerator < Base
argument :attributes, :type => :array, :default => [], :banner => "field:type field:type"
argument :attributes, :type => :array, :default => [], :banner => "field[:type][:index] field[:type][:index]"
check_class_collision
......@@ -26,6 +26,10 @@ def create_module_file
template 'module.rb', File.join('app/models', "#{class_path.join('/')}.rb") if behavior == :invoke
end
def attributes_with_index
attributes.select { |a| a.has_index? || (a.reference? && options[:indexes]) }
end
hook_for :test_framework
protected
......
......@@ -2,16 +2,14 @@ class <%= migration_class_name %> < ActiveRecord::Migration
def change
create_table :<%= table_name %> do |t|
<% attributes.each do |attribute| -%>
t.<%= attribute.type %> :<%= attribute.name %>
t.<%= attribute.type %> :<%= attribute.name %><%= attribute.inject_options %>
<% end -%>
<% if options[:timestamps] %>
t.timestamps
<% end -%>
end
<% if options[:indexes] -%>
<% attributes.select {|attr| attr.reference? }.each do |attribute| -%>
add_index :<%= table_name %>, :<%= attribute.name %>_id
<% end -%>
<% attributes_with_index.each do |attribute| -%>
add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %>
<% end -%>
end
end
......@@ -64,6 +64,28 @@ end
If you want a more complicated expiration scheme, you can use cache sweepers to expire cached objects when things change. This is covered in the section on Sweepers.
By default, page caching automatically gzips file (for example, to +products.html.gz+ if user requests +/products+) to reduce size of transmitted data (web servers are typically configured to use a moderate compression ratio as a compromise, but since precompilation happens once, compression ration is maximum).
Nginx is able to serve compressed content directly from disk by enabling +gzip_static+:
<plain>
location / {
gzip_static on; # to serve pre-gzipped version
}
</plain>
You can disable gzipping by setting +:gzip+ option to false (for example, if action returns image):
<ruby>
caches_page :image, :gzip => false
</ruby>
Or, you can set custom gzip compression level (level names are taken from +Zlib+ constants):
<ruby>
caches_page :image, :gzip => :best_speed
</ruby>
NOTE: Page caching ignores all parameters. For example +/products?page=1+ will be written out to the filesystem as +products.html+ with no reference to the +page+ parameter. Thus, if someone requests +/products?page=2+ later, they will get the cached first page. A workaround for this limitation is to include the parameters in the page's path, e.g. +/productions/page/1+.
INFO: Page caching runs in an after filter. Thus, invalid requests won't generate spurious cache entries as long as you halt them. Typically, a redirection in some before filter that checks request preconditions does the job.
......
require 'active_support/time'
require 'active_support/core_ext/object/inclusion'
require 'active_support/core_ext/object/blank'
module Rails
module Generators
class GeneratedAttribute
attr_accessor :name, :type
attr_reader :attr_options
def initialize(name, type)
type = :string if type.blank?
@name, @type = name, type.to_sym
class << self
def parse(column_definition)
name, type, has_index = column_definition.split(':')
# if user provided "name:index" instead of "name:string:index"
# type should be set blank so GeneratedAttribute's constructor
# could set it to :string
has_index, type = type, nil if %w(index uniq).include?(type)
type, attr_options = *parse_type_and_options(type)
new(name, type, has_index, attr_options)
end
private
# parse possible attribute options like :limit for string/text/binary/integer or :precision/:scale for decimals
# when declaring options curly brackets should be used
def parse_type_and_options(type)
case type
when /(string|text|binary|integer){(\d+)}/
return $1, :limit => $2.to_i
when /decimal{(\d+),(\d+)}/
return :decimal, :precision => $1.to_i, :scale => $2.to_i
else
return type, {}
end
end
end
def initialize(name, type=nil, index_type=false, attr_options={})
@name = name
@type = (type.presence || :string).to_sym
@has_index = %w(index uniq).include?(index_type)
@has_uniq_index = %w(uniq).include?(index_type)
@attr_options = attr_options
end
def field_type
......@@ -45,9 +79,29 @@ def human_name
name.to_s.humanize
end
def index_name
reference? ? "#{name}_id" : name
end
def reference?
self.type.in?([:references, :belongs_to])
end
def has_index?
@has_index
end
def has_uniq_index?
@has_uniq_index
end
def inject_options
@attr_options.blank? ? '' : ", #{@attr_options.to_s.gsub(/[{}]/, '')}"
end
def inject_index_options
has_uniq_index? ? ", :unique => true" : ''
end
end
end
end
......@@ -150,9 +150,8 @@ def assign_names!(name) #:nodoc:
# Convert attributes array into GeneratedAttribute objects.
def parse_attributes! #:nodoc:
self.attributes = (attributes || []).map do |key_value|
name, type = key_value.split(':')
Rails::Generators::GeneratedAttribute.new(name, type)
self.attributes = (attributes || []).map do |attr|
Rails::Generators::GeneratedAttribute.parse(attr)
end
end
......
module Rails
module Generators
class MigrationGenerator < NamedBase #metagenerator
argument :attributes, :type => :array, :default => [], :banner => "field:type field:type"
argument :attributes, :type => :array, :default => [], :banner => "field[:type][:index] field[:type][:index]"
hook_for :orm, :required => true
end
end
......
module Rails
module Generators
class ModelGenerator < NamedBase #metagenerator
argument :attributes, :type => :array, :default => [], :banner => "field:type field:type"
argument :attributes, :type => :array, :default => [], :banner => "field[:type][:index] field[:type][:index]"
hook_for :orm, :required => true
end
end
......
......@@ -7,23 +7,29 @@ Description:
under_scored, as the first argument, and an optional list of attribute
pairs.
Attribute pairs are field:type arguments specifying the
model's attributes. Timestamps are added by default, so you don't have to
specify them by hand as 'created_at:datetime updated_at:datetime'.
Attributes are field arguments specifying the model's attributes. You can
optionally pass the type and an index to each field. For instance:
"title body:text tracking_id:integer:uniq" will generate a title field of
string type, a body with text type and a tracking_id as an integer with an
unique index. "index" could also be given instead of "uniq" if one desires
a non unique index.
Timestamps are added by default, so you don't have to specify them by hand
as 'created_at:datetime updated_at:datetime'.
You don't have to think up every attribute up front, but it helps to
sketch out a few so you can start working with the resource immediately.
For example, 'scaffold post title:string body:text published:boolean'
gives you a model with those three attributes, a controller that handles
For example, 'scaffold post title body:text published:boolean' gives
you a model with those three attributes, a controller that handles
the create/show/update/destroy, forms to create and edit your posts, and
an index that lists them all, as well as a resources :posts
declaration in config/routes.rb.
an index that lists them all, as well as a resources :posts declaration
in config/routes.rb.
If you want to remove all the generated files, run
'rails destroy scaffold ModelName'.
Examples:
`rails generate scaffold post`
`rails generate scaffold post title:string body:text published:boolean`
`rails generate scaffold purchase order_id:integer amount:decimal`
`rails generate scaffold post title body:text published:boolean`
`rails generate scaffold purchase amount:decimal tracking_id:integer:uniq`
......@@ -218,8 +218,8 @@ def generator(args=self.default_arguments, options={}, config={})
#
# create_generated_attribute(:string, 'name')
#
def create_generated_attribute(attribute_type, name = 'test')
Rails::Generators::GeneratedAttribute.new(name, attribute_type.to_s)
def create_generated_attribute(attribute_type, name = 'test', index = nil)
Rails::Generators::GeneratedAttribute.parse([name, attribute_type, index].compact.join(':'))
end
protected
......
......@@ -69,7 +69,7 @@ def test_default_value_is_string
end
def test_default_value_for_type
att = Rails::Generators::GeneratedAttribute.new("type", "string")
att = Rails::Generators::GeneratedAttribute.parse("type:string")
assert_equal("", att.default)
end
......@@ -122,4 +122,9 @@ def test_blank_type_defaults_to_string_raises_exception
assert_equal :string, create_generated_attribute(nil, 'title').type
assert_equal :string, create_generated_attribute("", 'title').type
end
def test_handles_index_names_for_references
assert_equal "post", create_generated_attribute('string', 'post').index_name
assert_equal "post_id", create_generated_attribute('references', 'post').index_name
end
end
......@@ -58,6 +58,68 @@ def test_remove_migration_with_attributes
end
end
def test_add_migration_with_attributes_and_indices
migration = "add_title_with_index_and_body_to_posts"
run_generator [migration, "title:string:index", "body:text", "user_id:integer:uniq"]
assert_migration "db/migrate/#{migration}.rb" do |content|
assert_method :change, content do |up|
assert_match(/add_column :posts, :title, :string/, up)
assert_match(/add_column :posts, :body, :text/, up)
assert_match(/add_column :posts, :user_id, :integer/, up)
end
assert_match(/add_index :posts, :title/, content)
assert_match(/add_index :posts, :user_id, :unique => true/, content)
end
end
def test_add_migration_with_attributes_and_wrong_index_declaration
migration = "add_title_and_content_to_books"
run_generator [migration, "title:string:inex", "content:text", "user_id:integer:unik"]
assert_migration "db/migrate/#{migration}.rb" do |content|
assert_method :change, content do |up|
assert_match(/add_column :books, :title, :string/, up)
assert_match(/add_column :books, :content, :text/, up)
assert_match(/add_column :books, :user_id, :integer/, up)
end
assert_not_match(/add_index :books, :title/, content)
assert_not_match(/add_index :books, :user_id/, content)
end
end
def test_add_migration_with_attributes_without_type_and_index
migration = "add_title_with_index_and_body_to_posts"
run_generator [migration, "title:index", "body:text", "user_uuid:uniq"]
assert_migration "db/migrate/#{migration}.rb" do |content|
assert_method :change, content do |up|
assert_match(/add_column :posts, :title, :string/, up)
assert_match(/add_column :posts, :body, :text/, up)
assert_match(/add_column :posts, :user_uuid, :string/, up)
end
assert_match(/add_index :posts, :title/, content)
assert_match(/add_index :posts, :user_uuid, :unique => true/, content)
end
end
def test_add_migration_with_attributes_index_declaration_and_attribute_options
migration = "add_title_and_content_to_books"
run_generator [migration, "title:string{40}:index", "content:string{255}", "price:decimal{5,2}:index", "discount:decimal{3,2}:uniq"]
assert_migration "db/migrate/#{migration}.rb" do |content|
assert_method :change, content do |up|
assert_match(/add_column :books, :title, :string, :limit=>40/, up)
assert_match(/add_column :books, :content, :string, :limit=>255/, up)
assert_match(/add_column :books, :price, :decimal, :precision=>5, :scale=>2/, up)
assert_match(/add_column :books, :discount, :decimal, :precision=>3, :scale=>2/, up)
end
assert_match(/add_index :books, :title/, content)
assert_match(/add_index :books, :price/, content)
assert_match(/add_index :books, :discount, :unique => true/, content)
end
end
def test_should_create_empty_migrations_if_name_not_start_with_add_or_remove
migration = "create_books"
run_generator [migration, "title:string", "content:text"]
......
......@@ -113,6 +113,74 @@ def test_migration_with_attributes
end
end
def test_migration_with_attributes_and_with_index
run_generator ["product", "name:string:index", "supplier_id:integer:index", "user_id:integer:uniq", "order_id:uniq"]
assert_migration "db/migrate/create_products.rb" do |m|
assert_method :change, m do |up|
assert_match(/create_table :products/, up)
assert_match(/t\.string :name/, up)
assert_match(/t\.integer :supplier_id/, up)
assert_match(/t\.integer :user_id/, up)
assert_match(/t\.string :order_id/, up)
assert_match(/add_index :products, :name/, up)
assert_match(/add_index :products, :supplier_id/, up)
assert_match(/add_index :products, :user_id, :unique => true/, up)
assert_match(/add_index :products, :order_id, :unique => true/, up)
end
end
end
def test_migration_with_attributes_and_with_wrong_index_declaration
run_generator ["product", "name:string", "supplier_id:integer:inex", "user_id:integer:unqu"]
assert_migration "db/migrate/create_products.rb" do |m|
assert_method :change, m do |up|
assert_match(/create_table :products/, up)
assert_match(/t\.string :name/, up)
assert_match(/t\.integer :supplier_id/, up)
assert_match(/t\.integer :user_id/, up)
assert_not_match(/add_index :products, :name/, up)
assert_not_match(/add_index :products, :supplier_id/, up)
assert_not_match(/add_index :products, :user_id/, up)
end
end
end
def test_migration_with_missing_attribute_type_and_with_index
run_generator ["product", "name:index", "supplier_id:integer:index", "year:integer"]
assert_migration "db/migrate/create_products.rb" do |m|
assert_method :change, m do |up|
assert_match(/create_table :products/, up)
assert_match(/t\.string :name/, up)
assert_match(/t\.integer :supplier_id/, up)
assert_match(/add_index :products, :name/, up)
assert_match(/add_index :products, :supplier_id/, up)
assert_not_match(/add_index :products, :year/, up)
end
end
end
def test_add_migration_with_attributes_index_declaration_and_attribute_options
run_generator ["product", "title:string{40}:index", "content:string{255}", "price:decimal{5,2}:index", "discount:decimal{5,2}:uniq"]
assert_migration "db/migrate/create_products.rb" do |content|
assert_method :change, content do |up|
assert_match(/create_table :products/, up)
assert_match(/t.string :title, :limit=>40/, up)
assert_match(/t.string :content, :limit=>255/, up)
assert_match(/t.decimal :price, :precision=>5, :scale=>2/, up)
end
assert_match(/add_index :products, :title/, content)
assert_match(/add_index :products, :price/, content)
assert_match(/add_index :products, :discount, :unique => true/, content)
end
end
def test_migration_without_timestamps
ActiveRecord::Base.timestamped_migrations = false
run_generator ["account"]
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册