Skip to main content

· 8 min read
Lê Sĩ Bích

Overview

Vào ngày 28/5/2021, trên blog của Rails có thông báo về 1 tính năng mới đó là ActiveRecord Encryption.

ActiveRecord Encryption giúp encrypt attribute nào đó của model, và lưu vào DB dưới dạng mã hoá. Tính năng này được extract ra từ dự án HEY của Basecamp.

Hãy thử tìm hiểu xem tính năng này có gì thú vị.

Usage

Theo guide, đầu tiên chúng ta sẽ cần phải chạy câu lệnh sau

rails db:encryption:init

Rails sẽ cho ta 1 gợi ý là copy đống sau vào credentials. Ừ EDITOR=vim rails credentials:edit rồi copy paste thôi.

active_record_encryption:
primary_key: EGY8WhulUOXixybod7ZWwMIL68R9o5kC
deterministic_key: aPA5XyALhf75NNnMzaspW7akTfZp0lPY
key_derivation_salt: xEY0dt6TZcAMg52K7O84wYzkjvbA62Hz

Trong model, ta define attribute cần mã hoá như sau:

class Article < ApplicationRecord
encrypts :title
end

Rồi create/update model như bình thường

article = Article.create(title: "Encrypt it all!")

Và bùm, magic...

INSERT INTO `articles` (`title`) VALUES ('{\"p\":\"n7J0/ol+a7DRMeaE\",\"h\":{\"iv\":\"DXZMDWUKfp3bg/Yu\",\"at\":\"X1/YjMHbHD4talgF9dt61A==\"}}')

Vô hạn bối rối...

AES

Trước tiên ta hãy tìm hiểu về đống key mà Rails đã gen cho ta lúc đầu

active_record_encryption:
primary_key: EGY8WhulUOXixybod7ZWwMIL68R9o5kC
deterministic_key: aPA5XyALhf75NNnMzaspW7akTfZp0lPY
key_derivation_salt: xEY0dt6TZcAMg52K7O84wYzkjvbA62Hz

Rails gen ra đống trên như nào vậy?

puts <<~MSG
Add this entry to the credentials of the target environment:#{' '}

active_record_encryption:
primary_key: #{SecureRandom.alphanumeric(32)}
deterministic_key: #{SecureRandom.alphanumeric(32)}
key_derivation_salt: #{SecureRandom.alphanumeric(32)}
MSG

Nếu ai chưa biết thì đây là standard lib của Ruby, đơn thuần là... random mà thôi =))

Thế đống key này để làm gì? Ở mục Setup của guide, Rails cũng đã gợi ý cho ta, đống này dùng cho AES.

this will be used to derive the AES 32 bytes key

Vậy AES là cái khỉ mốc gì? Theo wiki, AES là viết tắt của Advanced Encryption Standard. Hay còn được biết đến với cái tên Rijndael. AES là 1 chuẩn mã hoá, sử dụng symmetric encryption. AES rất phổ biến ngày nay.

Thế bất nào mà lại đẻ ra thêm nhiều thuật ngữ hại não hơn vậy =)) Cứ từ từ rồi khoai sẽ nhừ, chúng ta sẽ tìm hiểu tiếp.

Trước hết thì, thế nào là mã hoá? Ví dụ như ở 1 diễn đàn nào đó người ta share nhau hoàng thuỳ link bằng đống kí tự này

68747470733A2F2F7777772E676F6F676C652E636F6D2E766E2F

thay vì huỵch toẹt ra là một đường link đồi truỵ nào đó. Nhưng do việc giải mã quá đơn giản, cụ thể là dùng mã HEX, nên đây chỉ là encode/decode sang 1 dạng dữ liệu khác mà thôi, và việc chuyển đổi thường khá nhanh. Các bạn có thể vào đây đây để decode chuỗi huyền bí trên.

Còn encrypt và decrypt dùng trong cryptography nó ở 1 tầm cao khác. Thông thường sẽ có thêm 1 key (hoặc 1 cặp key public/private). Khi đó input sẽ là key + data. Và output sẽ là 1 chuỗi nào đó, chuỗi này cực kì khó để giải mã nếu không nắm trong tay key. Thông thường thì phải cho chạy thuật toán vét cạn - tức là thử từng key một, và việc này cần đến hàng triệu năm đối với cả siêu máy tính.

  • Với thuật toán dùng 1 key cho cả việc encrypt lẫn decrypt thì sẽ được gọi là symmetric encryption

symmetric

  • Thuật toán dùng 1 cặp key, trong đó public key để mã hoá, còn private key để giải mã, được gọi là asymmetric encryption

asymmetric

Image source

Quay lại với AES, nó dùng symmetric, nên sẽ dùng 1 key cho cả việc mã hoá lẫn giải mã. AES key có độ dài là 128, 192, hoặc 256 bit. Tuỳ vào độ dài key nó sẽ có tên khác nhau AES-128, AES-192, AES-256. Key càng dài thì việc xử lý càng lâu, nhưng đổi lại càng bảo mật hơn. Mặc định Rails sử dụng key có độ dài 256 bit (32 bytes)

CIPHER_TYPE = "aes-256-gcm"

Về việc làm thế nào để AES mã hoá/giải mã thì mình sẽ không đề cập tại đây vì mình cũng không có hiểu mấy =)), nếu ai muốn tìm hiểu thêm có thể xem link này, hoặc gúc gồ ~~. Ở dưới là bản preview, trông cái thuật toán nó nôm na như sau

AES sẽ chia nhỏ input thành từng block để xử lý

block-cipher

flow

round

Image Source

Cơ mà chỉ quan tâm là một chuỗi đầu vào + 1 key, đi qua đống black magic rồi trở thành 1 chuỗi bảo mật siêu hạng là quá đủ cho một cuộc tình rồi.

À mà nhìn cái constant Rails define ở trên kia, lại có thêm cái gì ở cuối chuỗi thế nhỉ. gcm???? Bối rối again.

GCM

GCM là viết tắt của Galois/Counter Mode... Nghe xong hiểu chết liền .__. Mò thử xem nào!

Như tên gọi của nó, GCM là một mode trong symmetric encryption, kết hợp của Counter ModeGalois Mode, dùng cho mã hoá dạng khối (block). Vậy thì AES đi với GCM là chuẩn bài rồi nhỉ.

Ngoài GCM ra còn các mode khác như CCM, SIV, ...vân vân mây mây. Các bạn có thể xem thêm tại đây

Trước tiên ta hãy bắt đầu từ mode, từ này nằm trong Mode of Operation. Các mode sẽ được apply trên từng block data (như đã nói ở trên thì AES chia data thành các block nhỏ và mã hoá), giúp cho output của việc mã hoá block bảo mật hơn.

Counter mode sử dụng 1 số integer làm counter, qua từng block counter sẽ tăng lên 1. Ở mỗi block, số này sẽ được mã hoá cùng với data để có được output bảo mật hơn.

counter-mode

Galois Mode là một authentication mode. Galois Mode giúp đảm bảo rằng cipher output của ta không bị chỉnh sửa bởi một bên thứ 3. Hoạt động như nào thì mình chịu à =))

Kết hợp 2 mode trên và ta có GCM, giúp thuật toán mã hoá của ta bảo mật hơn, đồng thời đảm bảo được tính toàn vẹn của dữ liệu.

gcm-mode

Bên trên là giải thích sơ qua về thuật toán mã hoá mà Rails sử dụng, nếu có sai sót thì cũng đừng gạch đá tội mình =))

AES-256-GCM meets Rails

Encryption Key

active_record_encryption:
primary_key: EGY8WhulUOXixybod7ZWwMIL68R9o5kC
deterministic_key: aPA5XyALhf75NNnMzaspW7akTfZp0lPY
key_derivation_salt: xEY0dt6TZcAMg52K7O84wYzkjvbA62Hz

Quay trở lại với đống key, hãy cùng mò code đáy bể xem chúng dẫn ta tới đâu :v

# https://github.com/rails/rails/blob/9c091b4fd378df515c4c31b85bb6a968463a1d82/activerecord/lib/active_record/railtie.rb#L313-L317
ActiveRecord::Encryption.configure \
primary_key: app.credentials.dig(:active_record_encryption, :primary_key),
deterministic_key: app.credentials.dig(:active_record_encryption, :deterministic_key),
key_derivation_salt: app.credentials.dig(:active_record_encryption, :key_derivation_salt),
**config.active_record.encryption

# https://github.com/rails/rails/blob/9c091b4fd378df515c4c31b85bb6a968463a1d82/activerecord/lib/active_record/encryption/configurable.rb#L20-L33
def configure(primary_key:, deterministic_key:, key_derivation_salt:, **properties)
config.primary_key = primary_key
config.deterministic_key = deterministic_key
config.key_derivation_salt = key_derivation_salt

context.key_provider = ActiveRecord::Encryption::DerivedSecretKeyProvider.new(primary_key)
end

# https://github.com/rails/rails/blob/9c091b4fd378df515c4c31b85bb6a968463a1d82/activerecord/lib/active_record/encryption/derived_secret_key_provider.rb#L7-L9
def initialize(passwords)
super(Array(passwords).collect { |password| Key.derive_from(password) })
end

Xem tiếp class Key có gì nào.

# https://github.com/rails/rails/blob/9c091b4fd378df515c4c31b85bb6a968463a1d82/activerecord/lib/active_record/encryption/key.rb#L10-L26
class Key
def initialize(secret)
@secret = secret
@public_tags = Properties.new
end

def self.derive_from(password)
secret = ActiveRecord::Encryption.key_generator.derive_key_from(password)
ActiveRecord::Encryption::Key.new(secret)
end
end

# https://github.com/rails/rails/blob/9c091b4fd378df515c4c31b85bb6a968463a1d82/activerecord/lib/active_record/encryption/key_generator.rb#L32-L34
def derive_key_from(password, length: key_length)
ActiveSupport::KeyGenerator.new(password).generate_key(ActiveRecord::Encryption.config.key_derivation_salt, length)
end

Như vậy, 2 trong số 3 thanh niên của ta đã bị lộ mặt:

  • primary_key đóng vai trò là password param của ActiveSupport::KeyGenerator.new
  • key_derivation_salt đóng vai trò là tham số thứ nhất của ActiveSupport::KeyGenerator#generate_key

Hãy check tiếp ActiveSupport::KeyGenerator

# https://github.com/rails/rails/blob/9c091b4fd378df515c4c31b85bb6a968463a1d82/activesupport/lib/active_support/key_generator.rb#L26-L41
def initialize(secret, options = {})
@secret = secret
@iterations = options[:iterations] || 2**16
@hash_digest_class = options[:hash_digest_class] || self.class.hash_digest_class
end

def generate_key(salt, key_size = 64)
OpenSSL::PKCS5.pbkdf2_hmac(@secret, salt, @iterations, key_size, @hash_digest_class.new)
end

Qua hết wrapper của Rails rồi, giờ ta phải mò vào docs của Ruby thôi. Theo docs, docs lại bảo hàm này giờ đổi tên thành OpenSSL::KDF.pbkdf2_hmac mất rồi... Ờ thì lại mò vào xem. Nôm na hàm này sẽ tạo ra 1 cipher key dùng cho việc mã hoá.

Tại đây, ta có thể kết luận key mã hoá mà Rails dùng có:

  • passphrase: primary_key
  • salt: key_derivation_salt
  • iterations: 2^16
  • key_len: độ dài key 32 bytes
  • digest: hash algorithm SHA-1

Hãy cùng test qua cái key này xem

require 'openssl'

cipher = OpenSSL::Cipher.new('aes-256-gcm')
cipher.encrypt
iv = cipher.random_iv

pwd = 'EGY8WhulUOXixybod7ZWwMIL68R9o5kC'
salt = 'xEY0dt6TZcAMg52K7O84wYzkjvbA62Hz'
iter = 2**16
key_len = OpenSSL::Cipher.new("aes-256-gcm").key_len
digest = OpenSSL::Digest.new('SHA1')

key = OpenSSL::PKCS5.pbkdf2_hmac(pwd, salt, iter, key_len, digest)
cipher.key = key

encrypted = cipher.update "hello"
encrypted << cipher.final # => "\xB2#\x10\xE7n"

cipher.decrypt
cipher.iv = iv
decrypted = cipher.update(encrypted) # => "hello"

Sau bao nhiêu vất vả thì cũng tìm được cái key =)) Vậy quá trình mã hoá như thế nào? Lại mò tiếp .__.

Encryption Process

Hãy cùng bắt đầu từ việc khai báo trong model

class Article < ApplicationRecord
encrypts :title
end

Rồi cùng tìm xem ApplicationRecord.encrypts nó làm trò gì nào

Tham khảo

· 3 min read
Lê Sĩ Bích

WebSocket là gì

Cũng giống như HTTP, WebSocket là 1 protocol, hoạt động trên mô hình client/server, và sử dụng TCP connection. Là một protocol, có nghĩa là bất kể việc implement ra sao, nhưng server cứ lắng nghe 1 TCP port, và giao tiếp giữa client/server tuân theo đúng spec mà WebSocket đề ra là được.

WebSocket cho phép trao đổi dữ liệu 2 chiều giữa client và server. Tuy nhiên chắc sẽ có nhiều người thắc mắc

Sao không dùng HTTP rồi set interval request lên, hay là server push cho đơn giản. Đẻ ra lắm protocol làm gì đau đầu?

Nếu sử dụng HTTP, mỗi lần cập nhật data mới thì client lại phải tạo request HTTP, và sẽ có rất nhiều header dư thừa, gây lãng phí băng thông, và việc khởi tạo request cũng mất thêm 1 chút thời gian. Trong khi đó WebSocket, sau khi client và server say hello với nhau, TCP connection sẽ vẫn alive, và chúng sẽ giao lưu kết hợp với nhau qua connection đó mà không cần thêm những thông tin dư thừa như header. Và có lẽ còn nhiều lợi ích nữa, nhưng mình không biết =))

Khen thế đủ rồi, hãy thử tìm hiểu xem WebSocket ngang dọc ra sao.

Cách hoạt động của WebSocket

Tiền đề

Ví dụ với ứng dụng mạng xã hội, ta có 1 tính năng chat. Mỗi khi bật chat với 1 người, 1 WebSocket tới webserver của ta sẽ được khởi tạo. Như vậy, ta đang có 1 webserver, và đang cần 1 websocket connection tới webserver đó. Mặt khác việc sử dụng raw TCP trên client cũng rất hạn chế. Chính vì vậy, WebSocket đã tận dụng luôn port 80/443 của HTTP(S) để khởi tạo connection.

Handshaking

Khi khởi tạo connection, client sẽ tạo 1 HTTP request để thực hiện handshake với server

handshake

  • Trước tiên client sẽ gửi request tới endpoint mà websocket server đang lắng nghe. ví dụ như /chat. Request sẽ kèm theo 1 vài header mà WebSocket protocol quy định: Sec-WebSocket-Key, Sec-WebSocket-Version, Upgrade, Connection, ... và những header cơ bản của 1 HTTP request khác nữa.

  • WebSocket server sẽ tiếp nhận request, authen (nếu có), nếu không hợp lệ sẽ trả về 400 Bad Request và terminate connection đó. Ngược lại nếu OK, server sẽ trả về 101 Switching Protocols cùng với header Sec-WebSocket-Accept. Connection đó sẽ được giữ, và sau đó client và server bắt đầu trao đổi thông tin qua lại với nhau.

Giá trị của Sec-WebSocket-Accept được quy định như sau:

Sec-WebSocket-Accept = Base64( SHA1 ( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) )

258EAFA5-E914-47DA-95CA-C5AB0DC85B11 còn được gọi là magic string

Trao đổi dữ liệu

Việc trao đổi dữ liệu giữa client và server khá là hại não, cụ thể việc decode message, hay format message có thể xem thêm tại đây. Giữ chỗ để sau này nếu không lười bận quay lại tìm hiểu sau =))

Trong quá trình connection được keep alive, sẽ có rất nhiều request ping giữa client/server để kiểm tra xem phía bên kia còn active hay không. Bên gửi ping, bên nhận pong, vẫn còn sống thì ta lại giao lưu kết hợp tiếp. Còn không thì server/client sẽ biết để terminate inactive connection đó đi.

Tham khảo

· 11 min read
Lê Sĩ Bích

1. Mở đầu

Chắc hẳn Puma không phải một cái tên xa lạ đối với mỗi Rails developer, một phần cũng vì đây là appserver mặc định khi tạo mới một project Rails.

Hãy cùng nhìn qua những config cơ bản khi chạy 1 ứng dụng Rails với Puma server. link

max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count

worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"

port ENV.fetch("PORT") { 3000 }

environment ENV.fetch("RAILS_ENV") { "development" }

pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }

workers ENV.fetch("WEB_CONCURRENCY") { 2 }

preload_app!

plugin :tmp_restart

Đối với những người mới bắt đầu, khi deploy puma, có lẽ sẽ từ chối hiểu đống config trên và set một số biến môi trường như sau:

  • WEB_CONCURRENCY: bằng với số lượng vcore/process của máy
  • RAILS_MAX_THREADS: ờ thì nhiều RAM thì set 16, 32 cho oách, không thì thôi dùng mặc định 5 và mặc do dòng đời xô đẩy :v

Hãy cùng tìm hiểu thêm về Puma để góp phần làm chủ từng dòng code trong app của chúng ta.

2. Multi-processing và Multi-threading

2.1. Puma dùng loại nào?

  • Khi chạy 1 Rails app mới tạo, hãy để ý lúc boot server, bạn sẽ thấy:
Puma starting in single mode...
* Puma version: 5.3.1 (ruby 3.0.1-p64) ("Sweetnighter")
* Min threads: 5
* Max threads: 5
* Environment: development
* PID: 1
* Listening on http://0.0.0.0:3000
Use Ctrl-C to stop

Đây là dấu hiệu cho ta thấy, Rails đang chạy ở chế độ single process (1 process), và multi-thread (cụ thể là 5 thread)

  • Còn khi config deploy, có thể bạn sẽ được 1 người có kinh nghiệm hơn bảo rằng "Bỏ comment cái dòng workers ENV.fetch("WEB_CONCURRENCY") { 2 } đi em êi"

Và đây là kết quả khi ta làm như vậy

[1] Puma starting in cluster mode...
[1] * Puma version: 5.3.1 (ruby 3.0.1-p64) ("Sweetnighter")
[1] * Min threads: 5
[1] * Max threads: 5
[1] * Environment: development
[1] * Master PID: 1
[1] * Workers: 2
[1] * Restarts: (���) hot (���) phased
[1] * Preloading application
[1] * Listening on htp://0.0.0.0:3000
[1] Use Ctrl-C to stop
[1] - Worker 0 (PID: 16) booted in 0.02s, phase: 0
[1] - Worker 1 (PID: 17) booted in 0.05s, phase: 0

Puma đang chạy đồng thời multi-process (2 process) và multi-thread (5 thread)

Multi-process, multi-thread là cái của nợ gì vậy?

Chúng ta sẽ tìm hiểu về nó ngay sau đây. Tuy nhiên chúng ta sẽ focus vào cluster mode của Puma nhé.

2.2. Multi-threading

Trước tiên ta hãy nhớ lại định nghĩa về process - tiến trình, mỗi khi ta chạy 1 command nào đó (ví dụ như ruby xxx.rb hay bật chrome chẳng hạn), OS sẽ tạo 1 process để xử lý command của ta.

Mỗi process có thể tạo ra nhiều thread để xử lý task (ví dụ mỗi tab chrome được handle bởi 1 thread). Các thread được tạo bởi 1 process sẽ share nhau 1 vùng nhớ (memory), trong shared memory này, mỗi thread sẽ có stack, register (google để biết thêm đống này là gì =)) ) riêng của mình. Tuy nhiên, việc chung đụng memory như trên sẽ dẫn đến 1 vấn đề là nhiều thread cùng chọc tới 1 resource nào đó, dẫn tới conflict về data, hay còn được biết đến với cái tên nguy hiểm hơn là race condition. Code của ta sẽ cần thread-safe (các bạn có thể google thêm :v)

Đối với ứng dụng Ruby thì khi chạy ở môi trường MRI... À đấy lại nhắc tới MRI, chắc nhiều người sẽ thắc mắc liệu đó là gì. Đây là tên của 1 Ruby Runtime. Ruby 1 chuẩn spec, implement kiểu gì cũng được, miễn là đáp ứng được spec đó thì đều là Ruby. Có thể kể đến các Runtime phổ biến sau

  • MRI - aka CRuby: Matz’s Ruby Interpreter (Matz - hay Matsumoto Yukihiro) là người tạo ra runtime này, được viết bằng C, nên còn gọi là CRuby

    Đa số là dùng MRI

  • JRuby: Ruby implement bằng Java

  • Rubinius

  • mruby

  • ...

MRI có 1 cơ chế là Global Interpreter Lock (GIL), khi chạy multi-thread, nó chỉ cho phép 1 thread chạy source code Ruby tại 1 thời điểm, nên là ta có nhiều thread đi chăng nữa thì cũng chỉ có 1 thread được chạy mà thôi.

Tuy nhiên với các thao tác IO như đọc DB, request external resource, đọc ghi file, ... thì GIL không block. Puma đã tận dụng điều này, khi 1 thread đang xử lý IO, nó sẽ quay trở lại xử lý ở process, nhận thêm các request khác để xử lý.

Chính vì vậy, ngay cả khi chỉ chạy ở single mode, Puma vẫn có thể handle được concurrent request. Tuy nhiên với các request cần thời gian dài để xử lý, do chỉ có 1 thread được chạy, nên request sau sẽ phải chờ request trước xử lý xong, dẫn đến Puma bị thọt trong trường hợp này.

Ta có thể kiểm tra về trạng thái các thread của Puma bằng đoạn code sau:

class HomesController < ApplicationController
def show
Thread.list.select { |t| t.name&.match?(/puma threadpool \d+/) }.each do |t|
Rails.logger.info("Thread #{t.name}: #{t.status}, alive: #{t.alive?}, current: #{t == Thread.current}")
end
head :ok
end
end

Vì sao lại là /puma threadpool \d+/? Các bạn có thể xem tại đây.

Rails.application.routes.draw do
resource :home
end
max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count }
threads min_threads_count, max_threads_count
worker_timeout 3600 if ENV.fetch('RAILS_ENV', 'development') == 'development'
port ENV.fetch('PORT', 3000)
environment ENV.fetch('RAILS_ENV', 'development')
pidfile ENV.fetch('PIDFILE', 'tmp/pids/server.pid')
plugin :tmp_restart
curl http://localhost:3000/home
tail -n 50 log/development.log

Và đây là output:

Thread puma threadpool 001: sleep, alive: true, current: false
Thread puma threadpool 002: sleep, alive: true, current: false
Thread puma threadpool 003: sleep, alive: true, current: false
Thread puma threadpool 004: sleep, alive: true, current: false
Thread puma threadpool 005: run, alive: true, current: true

Đó chính là 5 thread của chúng ta, như ví dụ là thread 5 đang tiến hành xử lý request, còn những thread khác nếu rảnh nó sẽ ở trạng thái sleep. Ở đây có 1 điểm đáng chú ý là sau khi kết thúc request, thread không bị kill mà chỉ về trạng thái sleep, do vậy nếu ta tuỳ tiện modify biến global, nó sẽ ảnh hưởng tới request tiếp theo mà thread đó handle.

Ví dụ:

before_action :set_locale

def set_locale
I18n.locale = params[:locale] || I18n.default_locale
end

Không cần biết code cái gì, nhưng modify 1 biến global tại runtime như trên là đã thấy nguy hiểm rồi. Và ta hãy tiến hành test xem

class HomesController < ApplicationController
def show
Thread.current.tap { |t| Rails.logger.info("Current thread #{t.name}: #{t.status}, alive: #{t.alive?}") }
Rails.logger.info("Before: #{I18n.locale}")
I18n.locale = params[:locale]
Rails.logger.info("After: #{I18n.locale}")
head :ok
end
end

rồi sau đó spam khoảng chục cái request

curl http://localhost:3000/home?locale=vi

và ta sẽ thấy kết quả như sau

Current thread puma threadpool 003: run, alive: true
Before: en
After: vi

Current thread puma threadpool 003: run, alive: true
Before: vi
After: vi

Có thể dễ dàng nhận ra rằng việc set I18n.locale = "vi" ở request trước đã bị leak sang request sau. Nếu ở tất cả các request, trước khi xử lý ta đều set I18n.locale thì việc leak trên sẽ ít ảnh hưởng hơn.

Tuy nhiên hãy thử tưởng tượng khi app của bạn có chứa cả code admin và api, bên admin có thể đổi ngôn ngữ, còn api thì không, thì kết quả sẽ ra sao. Khi admin đổi ngôn ngữ, tình cờ, 1 user vô phúc nào đó request tới trúng cái thread vừa handle việc admin đổi ngôn ngữ, và API sẽ trả về locale của ông admin kia :v

Chính vì vậy, ở docs của Rails cũng có recommend chúng ta xử lý chuyển locale bằng I18n.with_locale, các bạn có thể xem tại đây

around_action :switch_locale

def switch_locale(&action)
locale = params[:locale] || I18n.default_locale
I18n.with_locale(locale, &action)
end

Túm váy lại là multi-threading hỗ trợ concurrent rất tốt, tuy nhiên cũng ẩn chứa 1 vài vấn đề. Nên sẽ cần chú ý hơn khi code.

2.3. Multi-processing

Máy tính hiện nay đa số đều có khá nhiều core, và đều hỗ trợ đa nhiệm để tối ưu hoá việc xử lý song song. Vậy với webserver thì sao? Có cần chứ, multi-processing giúp ta có thể xử lý thêm nhiều request đồng thời hơn nữa. Trừ khi server của ta quá yếu, chứ không thì tội gì, nhà chả có gì ngoài core mà lại chạy đơn nhân thì phí của giời quá :v

Mặc định Puma sẽ chạy ở single mode, khi đó chỉ có 1 process, process này sẽ đảm nhận hết từ việc lưu code của app, tiếp nhận request, ... sau đó sẽ đẩy request sang cho các thread xử lý như đã mô tả ở trên.

Còn đối với cluster mode, trước hết Puma sẽ tạo ra một master process. Từ process này, dựa vào giá trị của config dưới, Puma sẽ fork để tạo ra số process tương ứng, hay còn gọi là worker.

workers ENV.fetch("WEB_CONCURRENCY") { 2 }

Lại nói về fork, đây là quá trình mà 1 process tạo ra 1 process mới, gọi là child process. Mỗi child process đều có process id (PID) riêng biệt, và một môi trường riêng tách biệt hoàn toàn với parent process (source code, memory, stack, ...). Không như thread vẫn share code với process, nhưng có stack, register riêng. Do vậy, có thể nói forking an toàn và bảo mật hơn so với multi-thread.

Quay trở lại với Puma, ở cluster mode, master process chỉ đảm nhận việc tiếp nhận request, sau đó sẽ bắn sang các worker để chúng tự xử với các thread mà chúng spawn. Các worker của Puma đều có 1 bản copy source code app riêng, nên khi chạy nhiều worker, hãy chắc chắn là server của bạn có đủ RAM :v

Ở Ruby, có thể kiểm tra xem app của ta đang chạy ở process nào bằng cách sử dụng

Process.pid  # Current child process
Process.ppid # Parent process

Hãy thử ở app Rails của ta xem

class HomesController < ApplicationController
def show
Rails.logger.info("Current: #{Process.pid}. Parent: #{Process.ppid}")
head :ok
end
end
# ...
workers 2
Processing by HomesController#show as */*
Current: 128. Parent: 1

Processing by HomesController#show as */*
Current: 129. Parent: 1
ps aux
# USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
# root 1 0.5 7.1 236000 145864 pts/0 Ssl+ 03:28 0:04 puma 5.3.1 (tcp://0.0.0.0:3000) [app]
# root 7 0.0 0.1 3544 3192 pts/1 Ss 03:29 0:00 zsh
# root 128 0.0 6.9 288680 142008 pts/0 Sl+ 03:29 0:00 puma: cluster worker 0: 1 [app]
# root 129 0.0 6.9 288720 142116 pts/0 Sl+ 03:29 0:00 puma: cluster worker 1: 1 [app]
# root 219 0.0 0.0 1640 856 pts/1 R+ 03:40 0:00 ps aux

3. Kết luận

Cả multi-threading và multi-processing đều quan trọng, chúng góp phần giúp app ta xử lý đc concurrent request. Hy vọng bài viết có thể giúp ích cho các bạn ít nhiều.

Ngoài ra, về vấn đề set số worker và thread ở phần đầu đã nói, tốt nhất chắc vẫn là:

  • WEB_CONCURRENCY = vcore, ngoài ra còn phụ thuộc vào RAM nữa
  • RAILS_MAX_THREADS không có con số cụ thể, phụ thuộc vào RAM và CPU, nhiều RAM, CPU khoẻ thì set được càng nhiều, nhưng cũng khá là hên xui, phải tiến hành test rồi mới căn chỉnh con số phù hợp được =))

Nếu có sai sót gì các bạn cứ gạch đá thoải mái à :v chi tiết có thể tham khảo ở đây

4. Tham khảo

· 14 min read
Lê Sĩ Bích

Do dòng đời đưa đẩy và khách thì tiền ít nhưng lại thích hít hàng thơm (toàn đòi 80~100 coverage -_-) nên mình cũng đã kinh qua react-native-testing-library một thời gian kha khá, và cũng nghịch thử Detox, nhưng chưa thằng nào làm mình hài lòng:

  • Testing-library: Khi test e2e thì phải mock quá nhiều, khá khó để mock component sao cho hoạt động giống như component thật. Có những lúc mình chỉ viết để cho % coverage lên chứ các fn của test đó bị mock hết, thành ra chả có ý nghĩa gì nhiều. Các thành phần UI bị mock hết, nên test pass mà vẫn chết là điều bình thường như cân đường hộp sữa.
  • Detox: chơi với mỗi simulator, device thật ko rõ tới giờ đã được đánh điện tử chưa.

Và gần đây thì mình đọc lại docs của React Native có recommend thêm 1 thằng - Appium. Appium sử dụng driver như XCUITest, UIAutomator, Espresso, ... Được các teito (tay to :v) native ở công ty bảo mấy thằng driver trên toàn là hàng hịn bên native dùng để chạy test, nên thử nghịch luôn. Sau một thời gian mày mò thì khá hài lòng.

Tại sao lại dùng Appium mà ko chạy cơm cho khỏe?

Nói thế chứ thực ra thì chạy cơm không hề khỏe tí nào đâu các bạn.

Khi ta phát triển 1 feature mới cho app, ta phải test cho cả những tính năng đã có, ảnh hưởng tới chúng là điều khó tránh khỏi.

Thông thường dev chúng ta sẽ nghĩ là "Có tester rồi xoắn gì" đúng không nào? Tuy nhiên phận dev quèn thì cũng vẫn phải check qua những case cơ bản, (test thêm case dị càng tốt) trước khi chuyển qua cho tester. Nhưng không lẽ cứ viết một feature là ngồi bật app lên bấm từng nút, chưa kể xe đạp mới ( mình có một người bạn rất hay nhầm newbie và newbike =)) ) newbie không nắm rõ hệ thống, lack case là điều tất nhiên, hơn nữa con người chúng ta không giỏi trong những việc nhàm chán có tính lặp lại cao như test app, vì vậy dù có là bô lão thì cũng sẽ có lúc nhầm lẫn.

Ngoài ra, ở cuốn "The Effective Engineer" của Edmond Lau (nếu ko có gì sai sót thì là trang 159) có viết:

If you have to do something manually more than twice, then write a tool for the third time

Vậy thì sao chúng ta không để máy làm những công việc nhàm chán như test, và focus vào những việc khiến ta hứng thú hơn. Hãy cùng học viết test tự động cho ứng dụng React Native của chúng ta thôi nào!

Một vài kiến thức cần có

  • React Native, Babel (không cần quá mát tơ)
  • Nắm được cơ bản về viết unit test (với jest, hay gì đó tương tự)
  • Typescript (optional): do ở bài viết sẽ dùng Typescript

Hardware

  • Máy Mac/Hackintosh để build iOS hoặc máy Win (chỉ chạy được Android thôi)
  • 1 con điện thoại iOS hoặc Android - nói không với integration test trên simulator :v

2. Chạy thử ví dụ nhỏ

Tự dưng nhồi một đống lý thuyết suông, trong khi không biết mình sẽ làm gì có phải rất chán không nào? Vì vậy trước tiên ta hãy chạy thử một ví dụ nhỏ sau trước đã: Đăng kí account

Ví dụ sau dùng Typescript + WebdriverIO, và có cấu trúc tương đương với ví dụ trên trang chủ của Appium link (Các bạn cũng có thể vào link trên bằng truy cập trang chủ Appium rồi bấm vào nút Examples), tất nhiên để go pro thì ta sẽ ko code như vậy, mình sẽ thực hiện refactor và chuyển đổi môi trường chạy test ở các bài sau ( nếu có =)) ).

Sau khi setup Babel cùng với Jest (do Jest luôn đi kèm với React Native nên mình dùng luôn để demo cho thân thiện), hãy viết 1 đoạn test đơn giản cho quá trình sign up của ta. Hiện tại mình chỉ fill những field text input, vì field select, date picker viết khá dài, nên mình sẽ đề cập sau.

Nói nhiều quá, code đâu?

Vâng, code đây (ở dưới có giải thích code)

import { BrowserObject, remote, RemoteOptions } from "webdriverio";

const iOS: RemoteOptions = {
path: "/wd/hub",
host: "localhost",
port: 4723,
capabilities: {
platformName: "iOS",
automationName: "XCUITest",
deviceName: "iPhone (2)",
platformVersion: "14.1",
app: "org.reactjs.native.example.LearnRnE2eTest",
udid: process.env.IOS_DEVICE_UUID,
xcodeOrgId: "xxx",
xcodeSigningId: "Apple Development",
},
};

const android: RemoteOptions = {
path: "/wd/hub",
host: "localhost",
port: 4723,
capabilities: {
automationName: "UiAutomator2",
platformName: "android",
platformVersion: "8.0.0",
deviceName: "BH9057609A",
appPackage: "com.learnrne2etest",
appActivity: ".MainActivity",
},
};

let client: BrowserObject;

beforeEach(async () => {
client = await remote(iOS);
console.error(client);
});

afterEach(async () => {
if (client) {
await client.deleteSession();
}
});

const scrollTo = async (a11yId: string) => {
if (client.options.capabilities.platformName === "android") {
await client.execute("mobile: scroll", {
strategy: "accessibility id",
selector: a11yId,
});
}
};

it("sign up the user", async () => {
const toRegistrationScreenButton = await client.$(
"~login/toRegistrationScreenButton"
);
await toRegistrationScreenButton.click();

const registerButton = await client.$("~registration/registerButton");
await scrollTo("registration/toLoginScreenButton");
await registerButton.click();

await scrollTo("registration/usernameInput-error");
let usernameError = await client.$("~registration/usernameInput-error");
expect(await usernameError.getText()).toMatch("Please enter username");

const passwordError = await client.$("~registration/passwordInput-error");
expect(await passwordError.getText()).toMatch("Please enter password");

let passwordConfirmationError = await client.$(
"~registration/passwordConfirmationInput-error"
);
expect(await passwordConfirmationError.getText()).toMatch(
"Please confirm your password"
);

const fullNameError = await client.$("~registration/fullNameInput-error");
expect(await fullNameError.getText()).toMatch("Please enter your full name");

const usernameInput = await client.$("~registration/usernameInput");
await usernameInput.setValue("user..01");
usernameError = await client.$("~registration/usernameInput-error");
expect(await usernameError.getText()).toMatch(
"Username must be alphabet and numbers"
);
await usernameInput.setValue("user");

const passwordInput = await client.$("~registration/passwordInput");
await passwordInput.setValue("password");

const passwordConfirmationInput = await client.$(
"~registration/passwordConfirmationInput"
);
await passwordConfirmationInput.setValue("password123");
passwordConfirmationError = await client.$(
"~registration/passwordConfirmationInput-error"
);
expect(await passwordConfirmationError.getText()).toMatch(
"Password confirmation must match your password"
);
await passwordConfirmationInput.setValue("password");

const fullNameInput = await client.$("~registration/fullNameInput");
await fullNameInput.setValue("Test");

await client.hideKeyboard();

await scrollTo("registration/toLoginScreenButton");
await registerButton.click();

await client.waitUntil(
() =>
client
.$("~registrationCompleted/toLoginScreenButton")
.then((element) => element.isDisplayed()),
{ timeout: 10000 }
);
});

Chi tiết source code có thể xem tại đây

Source code của các màn hình có thể xem tại đây:

  • Login screen link
  • Registration screen link

Và đây là thành phẩm:

$ yarn test

PASS __tests__/sign-up.spec.ts (18.817 s)
✓ sign up the user (17179 ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 18.869 s, estimated 29 s
Ran all test suites.
Done in 21.80s.
iOSAndroid

Ví dụ trên làm gì?

  1. Từ màn Login, bấm vào button 「Create an account」 login/toRegistrationScreenButton để vào màn Registration

  2. Tại màn Registration

    1.1.Bấm vào nút 「Register」registration/registerButton, do máy Android của mình màn hơi ngắn, nên trước khi click phải scroll tới element đó trước.

    1.2. Do chưa fill gì nên expect error message hiển thị tương ứng

    1.3. Fill 「user..01」vào input username, do field này chỉ nhận alphabet và số, nên giá trị đó ko hợp lệ, expect hiển thị error message

    1.4. Fill username 1 giá trị hợp lệ 「user」

    1.5. Fill password 「password」

    1.6. Fill password confirmation「password123」, do ko match password đã nhập nên sẽ hiển thị error

    1.7. Fill password confirmation hợp lệ 「password」

    1.8. Fill full name

    1.9. Bấm vào nút 「Register」

  3. User được chuyển qua màn RegistrationCompleted, nên ta expect button ở màn này được hiển thị.

Phân tích một vài note về cú pháp

Cú pháp của WebdriverIO nhìn khá trong sáng và dễ hiểu, nên mình sẽ không giải thích nhiều, tuy nhiên có một vài chú ý dưới đây cho các bạn.

  • Khi tìm element, mình đã dùng ~, đây là shorthand để tìm element theo accessibility id của WebdriverIO, tuỳ theo OS mà ta sẽ phải gán prop khác nhau để có thể dùng được strategy tìm theo accessibility id này link, chỉ vì cái này mà mình mất vài tiếng, ko hiểu tại sao ko tìm thấy element =))

    • Ở bên iOS ta phải dùng testID, và tránh gán accessibilityLabelaccessibilityHint cho component.

    • Ở bên Android, hãy dùng accessibilityLabel

    • Các bạn có thể tham khảo các strategy khác ở docs của webdriverio link
  • Tại sao lại dùng accessibility id mà không dùng content của nó?

    • Đúng ra là khi bấm button hay thao tác trên màn hình, chúng ta nên dùng text của nó, để khi text (requirement) thay đổi thì test cũng oẳng. Nhưng mà cơ bản là do hơi bận lười =)) và trường hợp accessibility id đúng nhưng text sai thì phải chấp nhận thôi à =))
  • Do API của WebdriverIO toàn trả về Promise, nên hầu như câu lệnh nào cũng cần phải dùng async/await, các bạn có thể sử dụng kèm package @wdio/sync để API của WebdriverIO trở nên đồng bộ.

  • Các bạn có để ý đoạn code ở beforeEachafterEach không? Mỗi test, chúng ta đang tạo ra một session mới, và xoá session đó sau mỗi test. Nếu dùng CLI của wdio thì sẽ thuận tiện hơn.

  • Options xcodeOrgIdxcodeSigningId bên trong capabilities của RemoteOptions iOS

    • Để chạy test trên thiết bị iOS, Appium sẽ cài một phần mềm tên là WebDriverAgent lên thiết bị, và để cài đặt được, ta phải cung cấp developer team và signing certificate.

      Các bạn có thể xem thêm tại đây link

  • Đối với thiết bị thật thì ta cần thêm UUID, có thể xem cách tìm UUID tại đây

  • capabilities của Android

    • Để lấy deviceName, hãy chạy adb devices

    • com.learnrne2etest.MainActivity có thể lấy từ android/app/src/main/AndroidManifest.xml

      <manifest package="com.learnrne2etest">
      <!-- ... -->
      <activity android:name=".MainActivity">
      <!-- ... -->
      </activity>
      </manifest>

      Hoặc tham khảo bài sau link

Cưỡi ngựa xem hoa như vậy đủ rồi, chúng ta hãy cùng tìm hiểu thêm về cách hoạt động của Appium

3. Kiến trúc, flow của Appium

Appium dựa trên kiến trúc client-server, bản thân Appium là 1 server, có thể dễ dàng nhận thấy điều này ở đoạn config.

const iOS: RemoteOptions = {
path: "/wd/hub",
host: "localhost",
port: 4723,
// ...
};

3.1. Test library + WebDriver client

Khi viết test, ta sẽ dành phần lớn thời gian để làm việc với chúng, như ví dụ ở phần 2, ta đã sử dụng Jest (với các câu lệnh it, describe, afterEach, afterAll) là công cụ để chạy test, và WebdriverIO (với $, click(), setValue(value)) để giao tiếp với Appium server.

Ngoài bộ đôi này, chúng ta có thể sử dụng bất cứ ngôn ngữ nào mà ta thích (miễn là Appium nó support :v), từ Ruby, Python cho đến PHP, C#, có thể xem list tại đây

Nhưng mà mình recommend combo sau:

  • Ngôn ngữ: JS/TS thân thiện với React Native dev

  • WebDriver client: WebdriverIO vì trong đám client, thằng này nhiều star trên github nhất =))

  • Test library: jasmine
    • Vì nó có cú pháp giống Jest, mà anh em code React Native thì quá quen thuộc với Jest rồi
    • Là 1 trong 3 test framework được WebdriverIO support sẵn để go pro =)) (ngoài ra còn có mocha và cucumber), dùng Jest nếu lỗi thì phải tự mày mò thôi :v

Như đã nói ở trên, Appium áp dụng kiến trúc client-server. Khi ta viết những câu lệnh sau

// ./__tests__/sign-up.spec.ts
await passwordConfirmationInput.setValue("password123");

nếu để ý log ta có thể thấy những dòng log dưới đây, webdriverio chỉ đơn thuần gửi 1 HTTP request tới server, server làm gì thì chúng ta sẽ tìm hiểu sau :v

  • iOS
[HTTP] --> POST /wd/hub/session/b891507e-4d84-4e62-a5b2-e9110c529c9e/element/6F000000-0000-0000-E00E-000000000000/value
[HTTP] {"text":"password123"}
[debug] [W3C (b891507e)] Calling AppiumDriver.setValue() with args: ["password123","6F000000-0000-0000-E00E-000000000000","b891507e-4d84-4e62-a5b2-e9110c529c9e"]
[debug] [XCUITest] Executing command 'setValue'
[debug] [WD Proxy] Matched '/element/6F000000-0000-0000-E00E-000000000000/value' to command name 'setValue'
[debug] [Protocol Converter] Added 'text' property "password123" to 'setValue' request body
[debug] [WD Proxy] Proxying [POST /element/6F000000-0000-0000-E00E-000000000000/value] to [POST http://127.0.0.1:8100/session/01BFCDD1-27D7-4904-A95A-C75086994546/element/6F000000-0000-0000-E00E-000000000000/value] with body: {"value":["p","a","s","s","w","o","r","d","1","2","3"],"text":"password123"}
[debug] [WD Proxy] Got response with status 200: {"value":null,"sessionId":"01BFCDD1-27D7-4904-A95A-C75086994546"}
[debug] [W3C (b891507e)] Responding to client with driver.setValue() result: null
[HTTP] <-- POST /wd/hub/session/b891507e-4d84-4e62-a5b2-e9110c529c9e/element/6F000000-0000-0000-E00E-000000000000/value 200 821 ms - 14
  • Android
console.info
2020-12-31T02:12:23.288Z INFO webdriver: COMMAND findElement("accessibility id", "login/toRegistrationScreenButton")

at node_modules/@wdio/logger/build/node.js:76:9

console.info
2020-12-31T02:12:23.289Z INFO webdriver: [POST] http://localhost:4723/wd/hub/session/7fc4b801-deae-49dc-947d-292d67ba5467/element

at node_modules/@wdio/logger/build/node.js:76:9

console.info
2020-12-31T02:12:23.289Z INFO webdriver: DATA {
using: 'accessibility id',
value: 'login/toRegistrationScreenButton'
}

at node_modules/@wdio/logger/build/node.js:76:9

console.info
2020-12-31T02:12:23.414Z INFO webdriver: RESULT {
'element-6066-11e4-a52e-4f735466cecf': 'bb82f194-16d1-4ba0-a520-33f47e117211',
ELEMENT: 'bb82f194-16d1-4ba0-a520-33f47e117211'
}

at node_modules/@wdio/logger/build/node.js:76:9

3.2. Appium server, Automation tool, Devices

Đây là phần xương sống trong kiến trúc của Appium, nó đảm nhận việc handle request từ client, giao tiếp với native automation tool để thực thi những command mà ta cần.

Appium server expose ra API theo chuẩn JSON Wire Protocol (WebDriver Protocol). Tuỳ vào target là iOS hay Android, nó sẽ có cách hoạt động riêng để phù hợp với nền tảng đó.

3.2.1. Appium meets iOS

Quay trở lại đoạn log ở phần 3.1, nó đã ít nhiều gợi ý cho ta cách hoạt động của Appium

[HTTP] --> POST /wd/hub/session/b891507e-4d84-4e62-a5b2-e9110c529c9e/element/6F000000-0000-0000-E00E-000000000000/value
[debug] [WD Proxy] Proxying [POST /element/6F000000-0000-0000-E00E-000000000000/value] to [POST http://127.0.0.1:8100/session/01BFCDD1-27D7-4904-A95A-C75086994546/element/6F000000-0000-0000-E00E-000000000000/value]

Có thể dễ thấy Appium server đơn giản chỉ proxy request của ta tới 1 server khác chạy ở port 8100 http://127.0.0.1:8100.

Oát dờ phước? Thằng nào đang chạy ở port 8100 vậy?

Đó là WebDriverAgent server, ban đầu được phát triển bởi ông lớn Facebook, appium đã fork về thêm mắm thêm muối gì đó vào.

WDA giúp giao tiếp với XCUITest để có thể điều khiển device/simulator iOS từ xa. Bản thân nó cũng đã support sẵn Webdriver Protocol, vậy nên chắc hẳn dev Appium đã nghĩ như sau

Tội gì mà phải code lại, có sẵn hàng ngon rồi thì dùng luôn thôi =))

WDA làm gì với device thì mình xin skip, vì cũng ko biết =)) Các bạn có thể tự mình tìm hiểu sâu hơn nếu có hứng thú với nó.

3.2.2. Appium meets Android

Với Android, ta có ít gợi ý hơn

2020-12-31T02:12:23.289Z INFO webdriver: [POST] http://localhost:4723/wd/hub/session/7fc4b801-deae-49dc-947d-292d67ba5467/element
2020-12-31T02:12:23.414Z INFO webdriver: RESULT {
'element-6066-11e4-a52e-4f735466cecf': 'bb82f194-16d1-4ba0-a520-33f47e117211',
ELEMENT: 'bb82f194-16d1-4ba0-a520-33f47e117211'
}

Không thấy proxy gì đó phải không nào? Đúng vậy, vì nó có proxy đâu :v

Ở version mới nhất, Appium sử dụng appium-android-driver để giao tiếp với UIAutomator2. Vậy giao tiếp thế nào?

Mỗi khi bắt đầu chạy test, Appium sẽ cài app Appium Settings trên device của ta, thằng này sẽ mở port 4724 trên device của ta và expose ra một vài API hệ thống. Appium server abc với app đã cài trên (qua HTTP), và nó sẽ xyz với UIAutomator2 để thực hiện các command.

4. Kết luận

Là một dev, chúng ta không được chủ quan, lệ thuộc vào bên tester, mà hãy tự mình kiểm thử trước khi chuyển qua cho họ. Test chạy cơm là một công việc nhàm chán, vậy thì tại sao chúng ta không làm cho nó thú vị hơn bằng cách automate nó. Tuy sẽ có lúc cần một số thủ thuật, hay là phải dùng nhiều command khá lằng nhằng ( như cái datepicker chẳng hạn =)) ), nhưng một khi chạy được nhất định nó sẽ đem lại cho các bạn một cảm giác phê như con tê tê =))

Code e2e của bài viết chỉ là những đoạn code tạm bợ, các bạn muốn go pro có thể tham khảo appium-boilerplate của webdriverio về cách chia module, cũng như setup project. Nếu có thời gian mình sẽ viết thêm về việc refactor đống code tạm bợ trên để go pro =)) Giờ thì xin tạm biệt các bạn.

· 11 min read
Lê Sĩ Bích

Ngày nay, container đã là 1 thứ gì đó quá phổ biến, những nền tảng cloud của Amazon, Google, hay Microsoft, v.v... đều đã hỗ trợ deploy container. Và ở local, hay những server test, mọi người cũng đang dần chuẩn sang container vì sự tiện lợi của chúng.

Tuy nhiên khi số lượng container lớn dần, chúng ngày càng trở nên rắc rối, việc control resource, network, volume của một số lượng lớn container ngày càng khó. Chính vì lý do đó, một số nền tảng quản lý container đã ra đời như Docker Swarm, Kubernetes, ...

Với một hệ thống nhỏ, ta có thể sử dụng Docker Swarm, sử dụng nó rất dễ và đơn giản. Kubernetes cho phép ta customize nhiều thứ trong hệ thống hơn, tuy nhiên cái giá của nó là khó sử dụng hơn nhiều Docker Swarm.

Note: Ta không bao giờ so sánh Kubernetes với Docker, vì k8s thứ là management tool, còn docker là container runtime.

2. Kiến trúc hạ tầng của Kubernetes

2.1 Master components

Những components trên master node sẽ đóng vai trò là control plane của cluster. VD như: scheduling các pod, ...

2.1.1 etcd

Đây là database phân tán, sử dụng Raft làm cơ chế đồng thuận.

State của cluster (config, node info, IP addresses, ...) đều được lưu trữ tại đây.

2.1.2 kube-scheduler

Có nhiệm vụ schedule pod tới những node có đủ resource, và đảm bảo pod đạt được expected status.

Có thể hiểu đơn giản đây là một vòng lặp vô tận, đưa những pod mới được tạo vào 1 queue, từng item trong đó sẽ được schedule tới node thỏa mãn.

2.1.3 kube-apiserver

API Server đơn thuần là 1 REST API của Kubernetes cluster.

Khi muốn tạo, cập nhật, hay xóa những resource của hệ thống như pod, ingress, ... thì đều cần thông qua API Server, chứ không call thẳng tới kube-scheduler, etcd, ...

2.1.4 kube-controller-manager

Chạy nhiều controller process. Mỗi controller có nhiệm vụ khác nhau như:

  • Node Controller
  • Replication Controller
  • ...

2.2 Node components

Là những component được cài đặt trên tất cả các node, giúp quản lý container trên node, logging, quản lý network, ...

2.2.1 kubelet

Có nhiệm vụ quản lý container, đảm bảo container chạy chính xác, kubelet chỉ quản lý container do k8s tạo.

2.2.2 kube-proxy

Là một network proxy, dùng để forward request tới hạ tầng của k8s.

2.2.3 Container runtime

Chắn chắn ta không thể thiếu runtime cho container, nó có thể là rkt, Docker, ... hay bất cứ thứ gì khác.

2.3 Hoạt động

Chúng ta sẽ cùng xem những component trên sẽ kết hợp với nhau hoạt động ra sao, thông qua việc tạo 1 Pod qua kubectl CLI.

Pod ở đây có thể coi là 1 máy ảo, hay 1 container như Docker cho dễ hình dung.

  • Trước tiên ta sử dụng kubectl để yêu cầu tạo 1 Pod. Bản chất của command này là sẽ thực hiện 1 HTTP request tới apiserver của cluster.
  • apiserver sẽ thực hiện ghi lại current state, và desired state của cluster lại vào etcd (asynchronous)
  • etcd sẽ notify lại apiserver khi entry được tạo thành công
  • Tiếp theo kube-scheduler sẽ thực hiện việc schedule Pod. Khi tìm thấy node thích hợp, nó sẽ báo cho apiserver là Pod đó đã được bind vào node nào. apiserver sẽ lại update vào etcd
  • kubelet tại node đó sẽ thực hiện việc theo dõi container tạo bởi container runtime (Docker), xem chúng có chạy đúng với PodSpec không. kubelet sẽ gọi tới apiserver để update state của Pod đó thường xuyên.

3. Kiến trúc của ứng dụng trên Kubernetes

Ta sẽ chỉ tập trung vào 3 yếu tố chính để một hệ thống có thể hoạt động được:

  • Ứng dụng
  • Network
  • Storage

3.1 Pod

Pod là gì

Pod là đơn vị nhỏ nhất trong cluster. Mỗi Pod có IP riêng, và có thể chứa nhiều container, những container trong cùng 1 Pod có thể giao tiếp với nhau qua localhost. Vì vậy, có thể coi Pod là một máy ảo cho dễ hình dung.

Tuy nhiên để đơn giản, người ta thường chỉ chạy 1 container trên mỗi Pod, khi đó ta sẽ làm việc với Pod thay vì với từng container riêng lẻ.

Init container

Pod có hỗ trợ init container (1 hoặc nhiều đều được). Trước khi app container (container chính - ví dụ container với command rails s) chạy, tất cả init container sẽ được chạy lần lượt, cái trước thành công rồi đến cái sau chạy.

Ví dụ như 1 ứng dụng Rails, ta có thể settings rails s làm app container, còn rails db:create db:migrate sẽ làm init container.

Controller

Controller là 1 concept trong Kubernetes, nó dùng để theo dõi 1 loại Resource nào đó. kube-controller-manager có nhiệm vụ quản lý những controller này.

Về mặt kỹ thuật thì đây đơn thuần là 1 vòng lặp vô hạn (control loop) để có thể điều chỉnh state của cluster sao cho nó đạt tới được desired state.

Tuy nhiên đang trong mục Pod nên ta sẽ chỉ đề cập tới controller theo dõi Pod trong phần này.

  • Deployment:

    Ta sẽ sử dụng Deployment khi cần deploy 1 hoặc nhiều replica (thường là stateless).

    Thứ tự khởi động, identify của những replica (Pod) là hoàn toàn ngẫu nhiên.

  • StatefulSet:

    Nếu app của chúng ta là stateful (ví dụ như 1 set MySQL với 1 master và 2 slave), khi đó việc có 1 stable identity là rất quan trọng. Ngoài ra với mỗi replica trong StatefulSet, Kubernetes sẽ cung cấp cho nó một storage riêng.

    StatefulSet thêm prefix 0, 1, 2, ... là thứ tự khởi động của pod vào tên pod.

    Khi đó network identity, cũng như storage của Pod sẽ trở nên stable. Nếu mysql-0 của ta là master, nó sẽ luôn là master, và nó cũng luôn request tới storage của master.

  • DaemonSet:

    Ta dùng DaemonSet trong trường hợp muốn tất cả các node đều phải chạy 1 Pod nào đó.

    Ví dụ như storage cluster như glusterd, hay log agent như fluentd, ...

  • Job:

    Khi ta cần chạy one-off Pod. Ví dụ như việc import master data.

  • CronJob:

    Như tên gọi của nó, cứ định kì nó sẽ tạo Pod để thực hiện job.

3.2 Networking

3.2.1 Service

Trên môi trường production, rất hiếm khi ta thấy 1 service chỉ chạy trên 1 server. Thay vì thế, thường sẽ có 2 hoặc nhiều hơn server cùng chạy, khi server này chết, ta có thể forward request tới những server heo-thì (healthy) còn lại (trong trường hợp có set load balancer và healthcheck), không làm cho service của ta ngỏm luôn.

Tuy nhiên IP của Pod trong cluster luôn thay đổi, vậy thì ta biết phải setup cho load balancer như thế nào? Và đây chính là đất diễn của Service.

Service là một tập hợp các Pod, thường sẽ dùng label để group các Pod.

Thông thường, mỗi Service sẽ được gán cho 1 cluster IP, Kubernetes sẽ cung cấp DNS name, và đồng thời cũng load-balance cho các Pod của Service.

Ta cũng có thể sử dụng Service cho external endpoint.

Ví dụ khi hệ thống cần call đến https://google.com.vn. Tuy nhiên ta lại không muốn call trực tiếp, hay hardcode trong code, mà muốn endpoint này cũng trở thành 1 phần của cluster, để dễ quản lý. Khi đó ta có thể thay đổi endpoint này mà không phải đụng đến code, hay biến ENV gì đó. Khi đó, ta sẽ define 1 Service, và set Endpoint thủ công cho service này, thay vì sử dụng label.

Ngoài ra còn có Headless Service, loại service này sẽ không được gán cluster IP. Ta sẽ sử dụng khi ta muốn sử dụng cách load balance riêng, không phụ thuộc Kubernetes.

3.2.2 Load balancer

Khi tạo Service, nếu đây là service cho phép traffic từ bên ngoài Internet, ta hoàn toàn có thể sử dụng Load Balancer của các nhà cung cấp dịch vụ cloud (thường là L3 Load Balancer).

Khi call tới Load Balancer nói trên, nó sẽ trực tiếp forward traffic tới service trong cluster, thuật toán load-balance sẽ do Load Balancer này quyết định.

3.2.3 Ingress

Ingress hoạt động giống 1 Reverse Proxy hay Layer 7 Load Balancer. Nó cũng có nét tương đồng với API Gateway.

Ingress có thể điều hướng cả internal traffic lẫn external traffic dựa vào URL, đồng thời cung cấp thêm cả load balancing giữa những pod của service. Một điểm đáng lưu ý là Ingress sẽ load balance trực tiếp các backend Pod mà không thông qua service, do đó, ta có thể tùy chỉnh được thuật toán, ... liên quan tới LB cho toàn bộ Ingress của cluster.

Ngoài ra, người ta cũng dùng Ingress để terminate TLS session.

Kubernetes sau khi init mặc định sẽ không có ingress controller. Nếu ta chỉ tạo Ingress thì sẽ không có ý nghĩa gì. Để sử dụng Ingress ta sẽ phải setup thủ công Ingress Controller. Tuy nhiên việc này khá đơn giản, do nhiều hãng đã build sẵn mọi thứ, ta chỉ cần lấy file manifest của họ về rồi chạy là được.

3.2.4 Practice

  • Traffic từ phía client sẽ được tập trung tại Layer 4 Load Balancer.
  • L4 LB sẽ phân bố traffic tới các Node trong cluster (thường qua NodePort).
  • Ingress được map với NodePort đó sẽ terminate SSL, routing, và load balance traffic tới những Pod thích hợp.
    • Traffic tới /web sẽ được điều hướng tới Frontend Pods
    • Traffic tới /api sẽ được điều hướng tới Backend Pods

3.3 Storage - Volume

Một hệ thống khó có thể hoạt động chỉ với stateless service.

Với một số service như database, hay như tính năng upload file, ... Nếu chúng ta không sử dụng external service, thì service của ta sẽ trở thành stateful service. Nó đòi hỏi dữ liệu trong quá trình xử lý phải được lưu lại ngay cả khi bị crash. Ngoài ra có những lúc ta sẽ cần share dữ liệu giữa những container trong cùng 1 Pod. Những lúc như vậy, ta sẽ cần đến Volume.

Khi define Pod, ta sẽ chỉ rõ Pod này cần những volume nào, sau đó map những volume đó cho container.

PersistentVolume

Kubernetes sử dụng PersistentVolume để tạo nên 1 lớp abstract với những hệ thống storage thật phía sau. PV cũng có life-cycle giống như Pod.

Khi ta sử dụng volume, ta sẽ không cần quan tâm hệ thống đằng sau ngang dọc ra sao, thứ duy nhất ta cần quan tâm là API của PersistentVolume.

Trong Kubernetes, PV có thể được tạo bằng 2 cách:

  • Dynamic Provisioning
  • Static Provisioning
Static Provisioning

Admin cluster sẽ tạo ra 1 số PV trước, sau đó cung cấp cho bên Dev/Ops. Những PV này đã được trỏ tới hệ thống storage thật đang hoạt động đằng sau.

Có thể xem danh sách driver mà Kubernetes hỗ trợ tại đây.

Ví dụ: Ta có 1 cluster Glusterfs với 3 node, tổng dung lượng là 3TB. Khi cần 10GB cho việc lưu trữ share resource, ta sẽ tạo PersistentVolume với dung lượng 10GB, trỏ tới cluster nói trên. Sau đó ta sẽ sử dụng volume trên tương tự như các volume khác trong hệ thống.

Dynamic Provisioning

PV sẽ được tạo 1 cách động. Tức là khi nào Pod của ta cần volume, thì PV sẽ được tạo 1 cách tự động, nhờ vào StorageClass (sẽ đề cập sau).

Cách làm này đòi hỏi hệ thống storage của ta cũng phải support dynamic provisioning.

PersistentVolumeClaim

Để có thể sử dụng được PV, ta cần tạo thêm PersistentVolumeClaim (PVC).

Ta có thể sử dụng Claim trên cho config, hay volume, ... của Pod.

4. High Availability Setup

5. Reference

kubernetes.io - Kubernetes Official Documentation

youtube.com - June 2018 Online Meetup: Kubernetes Networking Master Class

x-team.com - INTRODUCTION TO KUBERNETES ARCHITECTURE

ovh.com - Getting external traffic into Kubernetes – ClusterIp, NodePort, LoadBalancer, and Ingress

medium.com - Kubernetes Master Components: Etcd, API Server, Controller Manager, and Scheduler

medium.com - Kubernetes NodePort vs LoadBalancer vs Ingress? When should I use what?

· 9 min read
Lê Sĩ Bích

Có 2 hướng tối ưu Database chính:

1. Tối ưu tầng DB

Cấu trúc DB:
  • Đọc nhiều => ít table, nhiều column
  • Ghi nhiều => nhiều table, ít column
Index đã ổn chưa?
Engine cho table
  • MyISAM: Table-locking, phù hợp với table đọc nhiều, ghi ít
  • InnoDB: Row-locking, đáp ứng được việc ghi nhiều.
Caching

2. Tối ưu phần cứng

Disk seek:
  • Thời gian HDD quay khi tìm dữ liệu, SDD không cần quay để tìm dữ liệu.
  • Phân tán dữ liệu để tìm trên nhiều ổ đĩa cùng lúc.
Disk R/W:
  • Khi tìm được dữ liệu thì sẽ cần đọc dữ liệu vào RAM.
  • Phân tán để đọc/ghi nhiều trên nhiều ổ đĩa cùng lúc.
CPU cycle
  • Khi đã load được vào RAM rồi, thì sẽ tới CPU xử lý những dữ liệu đó.
Memory bandwidth:
  • Khi CPU cần nhiều dữ liệu, nhưng cache lại không đủ, sẽ cần đến memory bandwith.

2. Tối ưu câu lệnh WHERE

Ngoài SELECT thì còn áp dụng được cho cả UPDATE, DELETE, v.v.
MySQL sẽ tự động tối ưu những trường hợp sau:
  • Loại bỏ những dấu ngoặc thừa

    ((a AND b) AND c OR (((a AND b) AND (c AND d))))
    => (a AND b AND c) OR (a AND b AND c AND d)
  • Thay thế biến bởi hằng số

    (a<b AND b=c) AND a=5
    => b>5 AND b=c AND a=5
  • Loại bỏ những điều kiện hằng

    (b>=5 AND b=5) OR (b=6 AND 5=5) OR (b=7 AND 5=6)
    => b=5 OR b=6
  • Biểu thức hằng (constant expression) sẽ chỉ được tính toán 1 lần duy nhất.

    SELECT *
    FROM t
    WHERE id = POW(1,2);

    SELECT *
    FROM t
    WHERE id = FLOOR(1 + RAND() * 49);
  • HAVING sẽ được gộp với WHERE khi không sử dụng GROUP BY hay những function như COUNT(), MIN(), MAX(), ...

  • Đối với MyISAM hay MEMORY table, kết quả của COUNT(*) (không có điều kiện WHERE sẽ được lấy trực tiếp từ information_schema.

=> Ta có thể ngừng việc tối ưu lại, và viết những câu lệnh SQL dễ hiểu, dễ bảo trì.

3. Range optimization

  • range là một access method
  • range sử dụng index để lấy ra những subset của kết quả cuối cùng.
  • range support cả single-part lẫn multiple-part index.

Những trường hợp sau, MySQL sẽ coi như là range condition:

  • Mệnh đề WHERE đối với những trường được đánh index (sử dụng BTree hoặc Hash), mà sử dụng những toán tử sau: =, <=>, IN(), IS NULL, hay IS NOT NULL

    Đối với, BTree, ngoài những phép toán trên, còn support thêm >, <, <=, >=, BETWEEN, !=, hay <> với constant value, hoặc LIKE cũng là range condition. Chú ý khi sử dụng LIKE, vế phải phải là 1 string hằng, và không bắt đầu bởi wildcard như % hay _.

    SELECT *
    FROM t1
    WHERE key_col LIKE 'ab%';
  • Kết hợp nhiều range condition sử dụng AND hoặc OR, ta vẫn thu được range condition.

Constant value

Là giá trị được tính toán trước thời điểm runtime, khi runtime, giá trị của nó sẽ không thay đổi
  • Hằng số truyền thẳng vào tham số của câu truy vấn
  • Một cột của const hay system table
    • const table là bảng chỉ có tối đa 1 row (hay 0 hoặc 1 row).
    • const table có thể là kết quả của 1 câu truy vấn chứa mệnh đề WHERE đối với 1 field unique, not null, có dạng column = constant. Truy vấn này luôn trả về 1 kết quả duy nhất.
    • table có 1 row thì gọi là system table

BTree, Hash index

BTree:
  • Self-balancing tree, cấu trúc dạng cây, có thể tự cân bằng nhằm giữ chiều cao của cây thấp nhất có thể.

  • Tránh nhầm với Binary Tree (cây nhị phân)

  • Thời gian tìm kiếm O(logn)

  • Phù hợp với đa dạng các phép toán: =, <=>, IN(), IS NULL, IS NOT NULL, >, <, <=, >=, BETWEEN, !=, <>, hay thậm chí cả LIKE

Hash:
  • Bảng băm, là cấu trúc dữ liệu lưu theo key-value

  • Tìm kiếm theo key rất nhanh - O(1)

  • Phù hợp với những phép toán: =, <=>, IN(), IS NULL, hay IS NOT NULL.

3.1. Single-part index

Single-part index là những index được đánh riêng lẻ cho 1 field.
CREATE INDEX index_name ON t1 (key_col);

Khi thực hiện truy vấn với những index loại này, với mỗi possible key (có thể sử dụng EXPLAIN để check), MySQL sẽ tiến hành extract range condition. Những điều kiện không thể cấu thành range condition sẽ bị loại bỏ, những điều kiện có thể bị overlap sẽ được gộp với nhau.

Sau khi extract, MySQL sẽ áp dụng những điều kiện đó để tận dụng được tối đa index của table, sau đó kết hợp thêm với những điều kiện còn lại để lọc tiếp.

MySQL chỉ bỏ qua index, nếu như nó tin rằng việc duyệt full table tối ưu hơn, hoặc dùng FORCE INDEX.

VD:

SELECT *
FROM t1
WHERE (
(key1 < 'abc' AND (key1 LIKE 'abcde%' OR key1 LIKE '%b'))
OR (key1 < 'bar' AND nonkey = 4)
OR (key1 < 'uux' AND key1 > 'z')
);

=> (key1 < 'abc' AND (key1 LIKE 'abcde%' OR TRUE)) OR
(key1 < 'bar' AND TRUE) OR
(key1 < 'uux' AND key1 > 'z')

=> (key1 < 'abc' AND TRUE) OR (key1 < 'bar' AND TRUE) OR (FALSE)

=> key1 < 'abc' OR key1 < 'bar'

=> key1 < 'bar'

3.2. Multiple-part index

Multiple-Part Index là những index được đánh cho nhiều trường cùng lúc
CREATE INDEX index_name ON t1 (key_part1, key_part2)

Range condition sẽ sử dụng key tuple intervals để tìm kiếm. Key tuple intervals được định nghĩa bởi những key tuples, có thứ tự.

Tuple là 1 cặp giá trị. VD: (1, 2, 3), (1, 'a', 3), ...

Lấy ví dụ về 1 multiple-part index key1(key_part1, key_part2, key_part3), và những tuples ứng với key tuple (key_part1, key_part2, key_part3) sau:

key_part1  key_part2  key_part3
NULL 1 'abc'
NULL 1 'xyz'
NULL 2 'foo'
1 1 'abc'
1 1 'xyz'
1 2 'abc'
2 1 'aaa'

key_part1 = 1 define interval sau:

(1,-inf,-inf) <= (key_part1,key_part2,key_part3) < (1,+inf,+inf)

Interval trên sẽ được sử dụng bởi range access method.

Bên cạnh đó, key_part3 = 'abc' không tạo ra interval nào (giá trị liền mạch nhau), vì thế sẽ không thể sử dụng bởi range

Chú ý khi sử dụng:

  • Với Hash index, nếu index có N part, thì condition của ta phải có format sau

        key_part1 cmp const1
    AND key_part2 cmp const2
    AND ...
    AND key_partN cmp constN;

    cmp là một trong những toán tử =, <=>, hoặc IS NULL

  • Với BTree index, 1 interval có thể sử dụng cho những điều kiện kết hợp bởi AND, trong đó mỗi điều kiện so sánh 1 key part với 1 hằng số, sử dụng =, <=>, IS NULL, >, <, >=, <=, !=, <>.

    Optimizer trong khi tính toán interval, sẽ sử dụng thêm key part nếu toán tử là =, <=> hay IS NULL. Còn nếu là >, <, >=, <=, !=, <>, BETWEEN hay LIKE thì sẽ không lấy thêm key part nữa.

    VD1:

    key_part1 = 'foo' AND key_part2 >= 10 AND key_part3 > 10

    sẽ sử dụng interval:

    ('foo',10,-inf) < (key_part1,key_part2,key_part3) < ('foo',+inf,+inf)

    VD2:

    (key_part1 = 1 AND key_part2 < 2) OR (key_part1 > 5)

    sẽ sử dụng 2 interval:

    (1,-inf) < (key_part1,key_part2) < (1,2)
    (5,-inf) < (key_part1,key_part2)

    Trong ví dụ này, interval thứ nhất sử dụng 1 key part ở vế trái, vế phải sử dụng 2 key part.

    Interval thứ 2 chỉ sử dụng 1 key part.

    Giá trị key_len khi thực hiện EXPLAIN sẽ trả về độ dài tối đa của key prefix được sử dụng.

Index Dive

Index dive được thực hiện trong khi optimizing để tính toán estimate (số row thỏa mãn 1 condition), để quyết định xem có nên dùng index hay không. Có thể skip bằng cách dùng FORCE INDEX

Nếu điều kiện phức tạp thì index dive sẽ mất nhiều thời gian.

Ngoài index dive, MySQL cũng có thể sử dụng index statistics để estimate, tuy nhiên độ chính xác thấp hơn, có thể dùng ANALYZE TABLE để update lại index statistics, tăng cường độ chính xác.

Index dive bị skip trong trường hợp thỏa mãn tất cả điều kiện sau (chỉ áp dụng cho single table query):

  • FORCE INDEX
  • Nonunique index, và không phải FULLTEXT index.
  • Không có subquery
  • Không có DISTINCT, GROUP BY, hoặc ORDER BY.
Equality Range Optimization of Many-Valued Comparisons
col_name IN(val1, ..., valN)
col_name = val1 OR ... OR col_name = valN

Những biểu thức như trên, với col_name được đánh index, và so sánh với nhiều giá trị, được gọi là range comparisions (mỗi range là 1 giá trị). Optimizer sẽ estimate cost của mỗi range như sau:

  • Nếu index là unique, cost = 1
  • Nếu không unique, sẽ cần sử dụng index dive hoặc index statistics để estimate.

Ứng với mỗi range là 2 lần dive (1 cho điểm bắt đầu, 1 cho điểm kết thúc) của range (interval).

· 11 min read
Lê Sĩ Bích

Rails 6 đã bước vào giai đoạn beta với phiên bản v6.0.0.rc1. RC là viết tắt của Release Candidate, đây là phiên bản mà có xác suất trở thành final release rất cao. Ta có thể áp dụng nó vào dự án ngay kể từ bây giờ mà không cần lo sợ có những breaking change.

Nếu bạn sử dụng Docker, mình khuyên nên sử dụng ruby 2.6.3, vì trước đây mình đã dùng thử ruby:2.7.0-preview1-alpine3.10 thì khá nhiều lỗi khi up server, mà chả biết phải fix sao :sad:

Ở Rails 6, có khá nhiều tính năng mới hay ho:

  • Action Text
  • Parallel Testing
  • Action Model custom error message format.
  • vân vân mây mây...

Ở bài này, mình sẽ chỉ tập trung đề cập tới Action Text. Đây là một package giúp ta xây dựng WYSIWYG editor một cách nhanh chóng, hơn nữa, việc upload cũng được integrate với Active Storage.

Chú ý: Bài khá dài và embed nhiều code :v

Cách dùng

Cài đặt và sử dụng

Vì là Rails nên cách sử dụng rất đơn giản, nhưng ta sẽ chả biết nó chạy cái gì đằng sau cả =))

Cài đặt Action Text bằng CLI command sau:

rails action_text:install

Nhớ hãy chạy thêm cả command install Active Storage nữa.

rails active_storage:install

Để áp dụng cho field content của model Post, ta sẽ dùng tới macro has_rich_text

class CreatePosts < ActiveRecord::Migration[6.0]
def change
create_table :posts do |t|
t.string :content
t.string :category

t.timestamps
end
end
end

content ở đây mình dùng string, nhưng khuyến khích dùng text hơn.

class Post < ApplicationRecord
has_rich_text :content
end

Trên form, ta chỉ việc dùng rich_text_area

<%= form_with(model: @post) do |form| %>
<div class="field">
<%= form.label :content %>
<%= form.rich_text_area :content %>
</div>
<% end %>

Khi permit params, ta cũng làm như bao field khác


class PostsController < ApplicationController
def post_params
params.require(:post).permit(:category, :content)
end
end

Cuối cùng ta hiển thị dữ liệu như sau:

<%= @post.content %>

Câu lệnh trên thực chất đang ngầm gọi tới @post.content.to_s

Đây là sản phẩm của ta:

Form tạo post với WYSIWYG editor - Sử dụng Trix editor, do Basecamp phát triển, cây nhà lá vườn luôn.

Có thể kéo thả file vào, file đó sẽ được upload bằng Active Storage.

Hiển thị dữ liệu ở màn hình detail

Các bạn thấy sao? Cá nhân mình thấy xấu như cờ hó vậy =)) Tuy nhiên ta có thể styling cho nó.

Styling

Rails đang có sẵn một vài style cơ bản cho nó trong file app/assets/stylesheets/actiontext.scss, ta có thể sửa nó để cho đẹp hơn.

Với đống tool của editor thì có thể dùng selector .trix-button-row

Với template attachment (như cái monaco.ttf ở trên), ta cũng có thể custom lại, Rails cung cấp sẵn template mặc định ở file app/views/active_storage/blobs/_blob.html.erb

Tìm hiểu sự ma giáo

Rồi ta đã lướt qua cách sử dụng nó, nhưng mà có quá nhiều magic.

Liệu bạn có tự đặt ra những câu hỏi thế này hay không?

  • Không biết cách lưu trữ dữ liệu của nó như thế nào?

  • Làm sao mà cái file trong kia integrate được với Active Storage?

  • Vì sao lại có thể custom lại những attachment bằng template?

  • vân vân mây mây...

Ta sẽ bắt tay vào tìm hiểu nó.

Hãy bắt đầu với has_rich_text

Macro has_rich_text

# https://github.com/rails/rails/blob/6a5c8b91998c56e50b5cc934d968947cd319f735/actiontext/lib/action_text/attribute.rb#L26
def has_rich_text(name)
class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name}
rich_text_#{name} || build_rich_text_#{name}
end

def #{name}=(body)
self.#{name}.body = body
end
CODE

has_one :"rich_text_#{name}", -> { where(name: name) },
class_name: "ActionText::RichText", as: :record, inverse_of: :record, autosave: true, dependent: :destroy
# ... codes
end

Dễ dàng thấy rằng đây là 1 macro giúp định nghĩa getter, setter, và cả association has_one :rich_text_... với ActionText::RichText.

Vậy thì khi ta gọi @post.content, thực chất là ta đang gọi tới @post.rich_text_content.

Và nó cũng chỉ ra 1 điều nữa là Rails dùng 1 bảng riêng để lưu lại đống rich text của ta.

Hãy dành chút thời gian nhìn vào thư mục db/migrate, ta sẽ thấy file sau:

# db/migrate/20190718085159_create_action_text_tables.action_text.rb
# This migration comes from action_text (originally 20180528164100)
class CreateActionTextTables < ActiveRecord::Migration[6.0]
def change
create_table :action_text_rich_texts do |t|
t.string :name, null: false
t.text :body, size: :long
t.references :record, null: false, polymorphic: true, index: false

t.timestamps

t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
end
end
end

Quả thật là vậy, body chính là nơi lưu trữ đống text của ta, còn name, record_id, và record_type là những attribute cho polymorphic association, khá quen thuộc nếu bạn đã dùng Active storage

Ta sẽ đi đến bước tiếp theo rich_text_area

Rails xử lý, lưu trữ rich text như thế nào?

# https://github.com/rails/rails/blob/cc1a5d5620c4cd952b27f6c1bbd16d8780a34d0e/actiontext/app/helpers/action_text/tag_helper.rb#L20
def rich_text_area_tag(name, value = nil, options = {})
options = options.symbolize_keys

options[:input] ||= "trix_input_#{ActionText::TagHelper.id += 1}"
options[:class] ||= "trix-content"

options[:data] ||= {}
options[:data][:direct_upload_url] = main_app.rails_direct_uploads_url
options[:data][:blob_url_template] = main_app.rails_service_blob_url(":signed_id", ":filename")

editor_tag = content_tag("trix-editor", "", options)
input_tag = hidden_field_tag(name, value, id: options[:input])

input_tag + editor_tag
end

Có thể thấy đơn giản nó chỉ init cái trix editor cho ta thôi.

Việc kéo thả file, upload hoàn toàn được handle bởi trix editor, ta không custom được template sau khi file upload thành công.

Tuy nhiên Action Text có thêm 1 callback sau khi file được upload thành công:

// https://github.com/rails/rails/blob/cc1a5d5620c4cd952b27f6c1bbd16d8780a34d0e/actiontext/app/javascript/actiontext/attachment_upload.js#L26
this.attachment.setAttributes({
sgid: attributes.attachable_sgid,
url: this.createBlobUrl(attributes.signed_id, attributes.filename),
});

đây là 1 step rất quan trọng, nhưng ta sẽ chưa cần chú ý tới nó vội. Attachment của ta sau khi upload thành công sẽ trông như sau:

<figure
contenteditable="false"
data-trix-attachment='{"contentType":"application/zip","filename":"monaco.ttf-master.zip","filesize":88996,"sgid":"BAh7CEkiCGdpZAY6BkVUSSIvZ2lkOi8vYXBwL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvOD9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--79d2aa5a0af367233a5420a4f0ae02657d3910ab","url":"http://localhost:60100/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBEUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--e79bebbfa0f10319d319411c129291d12e752d22/monaco.ttf-master.zip"}'
data-trix-content-type="application/zip"
data-trix-id="1108"
class="attachment attachment--file attachment--zip"
>
<figcaption class="attachment__caption">
<span class="attachment__name">monaco.ttf-master.zip</span>
<span class="attachment__size">86.91 KB</span>
</figcaption>
</figure>

Tiếp đến là submit dữ liệu, vậy Rails sẽ gửi gì lên?

Đơn giản là Rails sẽ gửi tất cả những gì bên trong <trix-editor></trix-editor>

Inspect thử params, ta sẽ thấy:

post_params[:content]
# => "<div><a href=\"http://localhost:60100/posts/new\">http://localhost:60100/posts/new</a></div><div><strong>Hello world<br></strong><figure data-trix-attachment=\"{&quot;content&quot;:&quot;<figure class=\\&quot;attachment attachment--file attachment--zip\\&quot;>\\n\\n <figcaption class=\\&quot;attachment__caption\\&quot;>\\n <span class=\\&quot;attachment__name\\&quot;>monaco.ttf-master.zip</span>\\n <span class=\\&quot;attachment__size\\&quot;>86.9 KB</span>\\n </figcaption>\\n</figure>\\n&quot;,&quot;contentType&quot;:&quot;application/zip&quot;,&quot;filename&quot;:&quot;monaco.ttf-master.zip&quot;,&quot;filesize&quot;:88996,&quot;sgid&quot;:&quot;BAh7CEkiCGdpZAY6BkVUSSIvZ2lkOi8vYXBwL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvNz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--97b885c24fd3d87464525f34a7b6ea117e4c72e6&quot;,&quot;url&quot;:&quot;http://localhost:60100/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBEQT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940badfc5aee704ef3f98085f87f909baf870660/monaco.ttf-master.zip&quot;}\" data-trix-content-type=\"application/zip\" class=\"attachment attachment--content attachment--zip\"><figure class=\"attachment attachment--file attachment--zip\">\r\n\r\n <figcaption class=\"attachment__caption\">\r\n <span class=\"attachment__name\">monaco.ttf-master.zip</span>\r\n <span class=\"attachment__size\">86.9 KB</span>\r\n </figcaption>\r\n</figure>\r\n<figcaption class=\"attachment__caption\"></figcaption></figure></div>"

Làm đẹp nó chút

<div>
<a href="http://localhost:60100/posts/new"
>http://localhost:60100/posts/new</a
>
</div>
<div>
<strong>Hello world<br /></strong>
<figure
data-trix-attachment="..."
data-trix-content-type="application/zip"
class="attachment attachment--content attachment--zip"
>
<figure class="attachment attachment--file attachment--zip">
<figcaption class="attachment__caption">
<span class="attachment__name">monaco.ttf-master.zip</span>
<span class="attachment__size">86.9 KB</span>
</figcaption>
</figure>
<figcaption class="attachment__caption"></figcaption>
</figure>
</div>

Có thể thấy nó khá giống với content của <trix-editor></trix-editor> đúng không nào?

Ta sẽ save đống params này lại, và xem DB ta chứa gì?

app_development=# SELECT body FROM action_text_rich_texts WHERE id = 3;
<div>
<a href="http://localhost:60100/posts/new"
>http://localhost:60100/posts/new</a
>
</div>
<div>
<strong>Hello world<br /></strong>
<action-text-attachment
sgid="BAh7CEkiCGdpZAY6BkVUSSIvZ2lkOi8vYXBwL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvNz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--97b885c24fd3d87464525f34a7b6ea117e4c72e6"
content-type="application/zip"
url="http://localhost:60100/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBEQT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940badfc5aee704ef3f98085f87f909baf870660/monaco.ttf-master.zip"
filename="monaco.ttf-master.zip"
filesize="88996"
></action-text-attachment>
</div>

Các thẻ khác thì vẫn vậy, tuy nhiên attachments của ta lại có một chút khác biệt. Ở đây đống figure đã được minify lại thành action-text-attachment để cho gọn hơn.

Hãy cùng điều tra xem tại sao lại như vậy? Trước tiên ta vào xem ActionText::RichText của ta chứa gì ma giáo

# https://github.com/rails/rails/blob/027085a5972a798cfea60f829a9edabbd67a2818/actiontext/app/models/action_text/rich_text.rb#L11
class ActionText::RichText < ActiveRecord::Base
serialize :body, ActionText::Content
end

Vậy là body của ta đang được serialize bởi ActionText::Content, mò đến đó tiếp thôi.

# https://github.com/rails/rails/blob/df8ee09ce71338cdf9816225df1bdebc707f3560/actiontext/lib/action_text/content.rb#L15
class ActionText::Content
class << self
def fragment_by_canonicalizing_content(content)
fragment = ActionText::Attachment.fragment_by_canonicalizing_attachments(content)
fragment = ActionText::AttachmentGallery.fragment_by_canonicalizing_attachment_galleries(fragment)
fragment
end
end

def initialize(content = nil, options = {})
options.with_defaults! canonicalize: true

if options[:canonicalize]
@fragment = self.class.fragment_by_canonicalizing_content(content)
else
@fragment = ActionText::Fragment.wrap(content)
end
end
end

Ta sẽ quan tâm tới hàm initialize trước, khi serialize, mặc định là ta đang không dùng tham số, vậy là options sử dụng sẽ là canonicalize: true, xem tiếp fragment_by_canonicalizing_content nào. Có vẻ lại phải mò fragment_by_canonicalizing_attachments tiếp @@

# https://github.com/rails/rails/blob/df8ee09ce7/actiontext/lib/action_text/attachment.rb#L11
class ActionText::Attachment
TAG_NAME = "action-text-attachment"
SELECTOR = TAG_NAME

class << self
def fragment_by_canonicalizing_attachments(content)
fragment_by_minifying_attachments(
fragment_by_converting_trix_attachments(content)
)
end
end
end

fragment_by_minifying_attachments, bạn có thấy từ minify không? Có vẻ ta đang đi đúng hướng. Hãy tiếp tục, nhưng hãy bắt đầu bằng fragment_by_converting_trix_attachments

# https://github.com/rails/rails/blob/df8ee09ce71338cdf9816225df1bdebc707f3560/actiontext/lib/action_text/attachments/trix_conversion.rb#L9
module ActionText::Attachments::TrixConversion
class_methods do
def fragment_by_converting_trix_attachments(content)
Fragment.wrap(content).replace(TrixAttachment::SELECTOR) do |node|
from_trix_attachment(TrixAttachment.new(node))
end
end
end
end

Nôm na là đoạn này sẽ replace toàn bộ ActionText::TrixAttachment::SELECTOR = "[data-trix-attachment] bằng thẻ ActionText::Attachment::SELECTOR = "action-text-attachment".

module ActionText::Attachments::Minification
class_methods do
def fragment_by_minifying_attachments(content)
Fragment.wrap(content).replace(ActionText::Attachment::SELECTOR) do |node|
node.tap { |n| n.inner_html = "" }
end
end
end
end

Hàm fragment_by_minifying_attachments của chúng ta sẽ remove toàn bộ inner content của thẻ action-text-attachment.

Sau một hồi lòng vòng, cuối cùng ta đã biết được tại sao đống figure rối rắm kia trở thành action-text-attachment gọn gàng hơn rất nhiều.

Hiển thị dữ liệu

Hãy vào màn hình detail, và thử inspect element, ta sẽ thấy attachment của ta lại trở về dạng đầy đủ:

<%= @post.content =>

Đây là những gì ta thu được.

<div class="trix-content">
<div>
<a href="http://localhost:60100/posts/new"
>http://localhost:60100/posts/new</a
>
</div>
<div>
<strong>Hello world<br /></strong>
<action-text-attachment
sgid="BAh7CEkiCGdpZAY6BkVUSSIvZ2lkOi8vYXBwL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvNz9leHBpcmVzX2luBjsAVEkiDHB1cnBvc2UGOwBUSSIPYXR0YWNoYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--97b885c24fd3d87464525f34a7b6ea117e4c72e6"
content-type="application/zip"
url="http://localhost:60100/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBEQT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940badfc5aee704ef3f98085f87f909baf870660/monaco.ttf-master.zip"
filename="monaco.ttf-master.zip"
filesize="88996"
>
<figure class="attachment attachment--file attachment--zip">
<figcaption class="attachment__caption">
<span class="attachment__name">monaco.ttf-master.zip</span>
<span class="attachment__size">86.9 KB</span>
</figcaption>
</figure>
</action-text-attachment>
</div>
</div>

Bạn còn nhớ mình ghi ở trên, khi ta viết @post.content, thực chất ta đang gọi @post.content.to_s chứ?

Thử đào sâu vào chút:

# https://github.com/rails/rails/blob/df8ee09ce7/actiontext/lib/action_text/content.rb#L90
def to_rendered_html_with_layout
renderer.render(partial: "action_text/content/layout", locals: { content: self })
end

def to_s
to_rendered_html_with_layout
end
<%# https://github.com/rails/rails/blob/df8ee09ce7/actiontext/app/views/action_text/content/_layout.html.erb %>
<div class="trix-content">
<%= render_action_text_content(content) %>
</div>
# https://github.com/rails/rails/blob/0ec2a907545e47f816993b9fd8cabb552454b1a2/actiontext/app/helpers/action_text/content_helper.rb#L12
def render_action_text_content(content)
sanitize_action_text_content(render_action_text_attachments(content))
end

def sanitize_action_text_content(content)
sanitizer.sanitize(content.to_html, tags: allowed_tags, attributes: allowed_attributes, scrubber: scrubber).html_safe
end

def render_action_text_attachments(content)
content.render_attachments do |attachment|
unless attachment.in?(content.gallery_attachments)
attachment.node.tap do |node|
node.inner_html = render(attachment, in_gallery: false).chomp
end
end
end.render_attachment_galleries do |attachment_gallery|
render(layout: attachment_gallery, object: attachment_gallery) do
attachment_gallery.attachments.map do |attachment|
attachment.node.inner_html = render(attachment, in_gallery: true).chomp
attachment.to_html
end.join("").html_safe
end.chomp
end
end

Đại khái là Rails sẽ sanitize đống rich text của ta để tránh XSS attack, và render template đối với những attachment.

Integrate với Active Storage

Hãy quay trở lại với model ActionText::RichText

# https://github.com/rails/rails/blob/df8ee09ce7/actiontext/app/models/action_text/rich_text.rb#L14
class ActionText::RichText < ActiveRecord::Base
has_many_attached :embeds

before_save do
self.embeds = body.attachments.map(&:attachable) if body.present?
end
end

Ta dễ dàng hiểu 1 cách tổng quan là Rails sẽ tiến hành extract toàn bộ attachment trong body, gán vào embeds. Sau đó sẽ save đống association này lại.

Thử xem Rails sẽ extract attachments ra làm sao.

# https://github.com/rails/rails/blob/027085a5972a798cfea60f829a9edabbd67a2818/actiontext/lib/action_text/content.rb#L53
def attachables
@attachables ||= attachment_nodes.map do |node|
ActionText::Attachable.from_node(node)
end
end

# https://github.com/rails/rails/blob/027085a5972a798cfea60f829a9edabbd67a2818/actiontext/lib/action_text/content.rb#L113
def attachment_nodes
@attachment_nodes ||= fragment.find_all(ActionText::Attachment::SELECTOR)
end

Rails sẽ tìm kiếm tất cả thẻ action-text-attachment, extract những thông tin cần thiết để init được ActiveStorage::Blob object.

Ta cần nghía qua hàm Attachable.from_node nữa

# https://github.com/rails/rails/blob/027085a5972a798cfea60f829a9edabbd67a2818/actiontext/lib/action_text/attachable.rb#L10
def from_node(node)
if attachable = attachable_from_sgid(node["sgid"])
attachable
elsif attachable = ActionText::Attachables::ContentAttachment.from_node(node)
attachable
elsif attachable = ActionText::Attachables::RemoteImage.from_node(node)
attachable
else
ActionText::Attachables::MissingAttachable
end
end

Trong trường hợp này, Rails sẽ luôn extract theo strategy là sgid, các bạn không cần quan tâm tới 3 nhánh dưới làm gì cho mất công.

Còn nhớ phần trên mình đã đề cập tới sgid chứ?

//github.com/rails/rails/blob/cc1a5d5620c4cd952b27f6c1bbd16d8780a34d0e/actiontext/app/javascript/actiontext/attachment_upload.js#L26
https: this.attachment.setAttributes({
sgid: attributes.attachable_sgid,
url: this.createBlobUrl(attributes.signed_id, attributes.filename),
});

Callback này được gọi sau khi file được upload thành công. Vậy sgid là cái gì?

sigd được encrypt từ id của attachment sau khi upload. Khi gửi lên, server sẽ decrypt nó để lấy lại id, gán attachment vào record.

Tại sao lại cần encrypt? Các bạn cứ tưởng tượng, nếu dùng thẳng id, do ta upload ảnh trước khi tạo record, nên khi gửi params từ client lên, chắc chắn sẽ phải đưa attachment id vào để server lưu đúng association. Client nó gửi đúng thì không sao, nhưng nếu nó gửi id attachment của người khác thì sao? Attachment đó sẽ chuyển chủ ngay lập tức :v Còn nếu encrypt thì user đố mà mò được mã hash của attachment người khác.

attachable_from_sgid(node["sgid"]) sẽ trả về ActiveStorage::Blob object

# https://github.com/rails/rails/blob/f1b8bb4e1f16e4029ddf05515db0c01942521116/actiontext/lib/action_text/attachable.rb#L22
def from_attachable_sgid(sgid, options = {})
method = sgid.is_a?(Array) ? :locate_many_signed : :locate_signed
record = GlobalID::Locator.public_send(method, sgid, options.merge(for: LOCATOR_NAME))
record || raise(ActiveRecord::RecordNotFound)
end

Nếu không tin, bạn hãy thử rails c và gọi hàm:

GlobalID::Locator.locate_signed(sgid, { for: "attachable" })

Kết luận

Action Text được thiết kế khá hay, code gọn, tất cả method đều rất ngắn, dễ hiểu.

Do là built-in nên ta cũng chả cần cài cắm gì thêm mệt người. Nếu đã upgrade lên Rails 6, bạn hãy thử trải nghiệm nó xem.

Còn đối với phiên bản thấp hơn, ta cũng có thể áp dụng flow của Action Text để build một package hỗ trợ richtext.

· 8 min read
Lê Sĩ Bích

Khi mới bắt đầu code, có thể chúng ta rất ngại handle những exception mà ngay cả 1 hàm đơn giản nhất cũng có thể tung ra. Tuy nhiên, thực tế khi xây dựng hệ thống, ta phải đặc biệt chú ý đến những exception, không thì ứng dụng của ta có thể thăng bất cứ lúc nào :v

Vậy thì làm sao để handle chúng 1 cách thật clean. Có khá nhiều hướng tiếp cận, tuy nhiên ta hãy cùng thử trải nghiệm theo hướng Railway Oriented xem nó có gì hay.

Bài giới thiệu gốc được viết sử dụng ngôn ngữ F#, nhưng ta hoàn toàn có thể áp dụng vào các ngôn ngữ khác như Javascript, Ruby, ...

Railway Oriented Programming phù hợp với style lập trình hàm (Functional Programming - FP)

Tên gọi là Railway cũng bởi trông nó khá giống với đường ray tàu hỏa.

Vấn đề

Hãy bắt đầu với 1 usecase rất cơ bản

Người dùng muốn cập nhật profile của họ.

Sau khi người dùng submit thông tin ở web, ta hay cùng phân tích những xử lý ở phía server

Tình huống đẹp nhất xảy ra là từ bước 1-5, ứng dụng ta sẽ chạy trơn tru, không có bất cứ 1 lỗi nào.

Nhưng mà ...

Đời không như mơ, tình không như thơ.

Từ bước 1-4 đều có thể có lỗi xảy ra, và tất nhiên người dùng sẽ không hề muốn nhìn thấy cảnh này

Vậy thì những gì ta cần làm là xử lý những lỗi này, không thì người dùng một đi không trở lại luôn.

Và ta sẽ viết code cho nó:

function updateUser(user) {
const request = receiveRequest();
if (!isValidRequest(request)) {
return "Request is forbidden";
}
if (!isValidUser(request)) {
return "User information is invalid";
}
try {
saveToDatabase(request);
} catch (error) {
return "DB error";
}
try {
sendEmail(request);
} catch (error) {
return "Mailer error";
}
return "OK";
}

Code trên được viết theo style Imperative, vì thế nên ta có thể return bất cứ lúc nào ta muốn. Điều đó làm hàm của ta có thể return theo rất nhiều cách (nhiều kiểu response khác nhau).

Ta sẽ design lại flow theo style FP.

Hàm của ta giờ đây:

  • Chỉ có 2 kiểu trả về: Success hoặc Failure.

    • Success:

      // @flow
      type Success = { data: object };
    • Failure:

      // @flow
      type Failure = { error: string };
  • Use case được xây dựng từ 1 series các hàm con tương ứng với mỗi step.

Ta sẽ áp dụng Railway Oriented Programming để giải quyết vấn đề này.

Railway Oriented Programming

Monad

Trong lập trình hàm, có một cách để handle lỗi là dùng monad.

Monad thường đi kèm với Applicative và Functor nữa, bọn này khá là xoắn não nên mình sẽ không đề cập ở đây.

Ta sẽ chỉ quan tâm tới: Either monad.

// @flow
type Either<A, B> = A | B;
type FunctionReturnMonad<A, B> = () => Either<A, B>;

Hiểu một cách nôm na sẽ là: hàm của ta sẽ luôn trả về A hoặc B, nhưng không bao giờ trả về cả 2.

Switch

Hãy bắt đầu với 1 function có thể gây ra lỗi:

function validateNameNotBlank(user) {
if (user.name === "") {
return { error: "Name is blank" };
}
return { data: user };
}

Đây chính là 1 switch, nó rẽ nhánh luồng xử lý của ta thành SuccessFailure.

Kết nối nhiều switch

Đây là đường ray hoàn chỉnh mà ta cần xây dựng từ những switch riêng lẻ trên.

Có thể nhận thấy một vài điểm quan trọng ở railway này:

  • Khi một step bị lỗi, nó sẽ không return function ngay lập tức, mà sẽ tiếp tục chạy vào các function tiếp theo cho tới khi kết thúc flow.

    Tuy nhiên kết quả cuối cùng nhận được chỉ là lỗi đầu tiên phát sinh.

  • Những hàm của từng step đều phải xử lý cả trường hợp có dữ liệu và trường hợp hàm trước trả về lỗi

Bây giờ vấn đề là làm thế nào để nối những switch lại với nhau?

Câu trả lời là compose. Tuy nhiên ta không thể compose theo cách thông thường được.

Ví dụ như:

Nếu đầu vào và đầu ra của ta cùng interface

const mul2 = (num) => num * 2;
const add1 = (num) => num + 1;
mul2(add1(1)); // 4

Trở lại hàm validateBlank ở trên, nếu ta có nhiều hàm validate khác tương tự

Ta có thể thấy đầu vào chỉ có 1, mà lại có những 2 đầu ra.

Để có thể compose, ta sẽ phải biến chúng thành những function có thể handle cả trường hợp Success lẫn Failure.

Có thể sử dụng HOC

const transformToTwoTrackInput =
(func) =>
({ data, error }) => {
return data ? func(data) : { error };
};

const twoTrackValidateNameNotBlank =
transformToTwoTrackInput(validateNameNotBlank);
const twoTrackValidateName50 = transformToTwoTrackInput(validateName50);
const twoTrackValidateEmailNotBlank = transformToTwoTrackInput(
validateEmailNotBlank
);

const user = { email: "", name: "" };
twoTrackValidateEmailNotBlank(
twoTrackValidateName50(twoTrackValidateNameNotBlank({ data: user }))
); // { error: 'name is blank' }

Như vậy là ta đã kết nối được những mảnh ghép trên với nhau.

Một vài kiểu function thường gặp

Single track function

Nếu một function không gây ra lỗi (chỉ có 1 đầu vào và 1 đầu ra) thì nó sẽ không thể nào compose được vào railway của chúng ta.

Khi đó ta phải wrap nó bởi 2-track function, giống với HOC ở trên.

const trimEmail(user) {
return {
...user,
email: user.email.trim()
}
}

const transformSingleTrackToTwoTrackInput = func => ({ data, error }) => {
return data ? func(data) : { error }
}

const user = { email: ' sample@email.com' }
transformSingleTrackToTwoTrackInput(trimEmail)({ data: user })
Dead-end function

Dead-end function là những hàm void, không có giá trị trả về

Màu tím chính là nơi ta sẽ đặt dead-end function vào

const transformDeadEndFunction =
(func) =>
({ data, error }) => {
if (data) {
func();
return { data };
} else {
return { error };
}
};
Function throw Exception

Ta có thể đặt try-catch để trả về Failure

const transformExceptionFunction =
(func) =>
({ data, error }) => {
if (data) {
try {
func();
} catch (error) {
return { error: error.message || error };
}
return { data };
} else {
return { error };
}
};

Note: Convert tất cả Exception thành Failure

Kết quả

Ta không thể trả về dữ liệu kiểu two-track cho client được, vì vậy ta sẽ có thêm 1 bước cuối để trả về thông tin cho client.

function returnMessage({ data, error }) {
return data ? JSON.stringify(data) : error.toString();
}

ROP trong một số ngôn ngữ khác

Ruby (framework Rails)

Nếu các bạn sử dụng Rails, hãy thử trải nghiệm qua gem trailblazer. Logic của ứng dụng giờ sẽ tập trung chủ yếu trong các Operation thay vì controller như xưa nữa, mà những operation này được viết theo style ROP.

class Song::Create < Trailblazer::Operation
step Model(Song, :new) # init model
step :assign_current_user!
step Contract::Build(constant: SongForm) # create form object
step Contract::Validate() # validate form object
failure :log_error!
step Contract::Persist() # save song to db

def log_error!(options)
logger.debug "Errors occurred while creating song."
end

def assign_current_user!(options)
options["model"].created_by = options["current_user"]
end
end

Flow của Operation không hoàn toàn giống với ROP như ta đã thấy ở trên, một khi bạn đã vào nhánh Failure, nó sẽ trigger toàn bộ những step handle error phía sau.

Không liên quan lắm nhưng trong trailblazer ecosystem có khá nhiều gem thú vị như: reform (form object pattern), và cells (view components).

Javascript

Nếu ta để ý, Promise của Javascript cũng khá giống với ROP.

const user = { username: "sample", email: "sample" };

const validatePresenceOfUsername = (data) => {
if (!!data.username) {
return user;
}
throw "Username is blank";
};

const validateFormatOfEmail = (data) => {
if (data.email && data.email.match(/@/g)) {
return user;
}
throw "Email is in invalid format";
};

const logError = (error) => console.error(error);

Promise.resolve(user)
.then(validatePresenceOfUsername)
.then(validateFormatOfEmail)
.catch(logError); // => 'Email is in invalid format

Tham khảo

https://fsharpforfunandprofit.com/rop/

https://fsharpforfunandprofit.com/posts/function-composition/

https://dorp.io/posts/railway-oriented-programming/

https://github.com/trailblazer/trailblazer

http://trailblazer.to/gems/operation/2.0/api.html

· 11 min read
Lê Sĩ Bích

Ngày nay, công nghệ ngày càng phát triển, và thế giới Web cũng vậy. Đa số web hiện đại đều đã nói lời tạm biệt với những dòng Ruby, PHP, hay Java được nhúng trong HTML/JS. Phần giao diện người dùng được chính browser phía client xử lý, thay vì server ôm hết như xưa. Các thư viện/framework như React, Angular, Vue, ... đều đã trở nên quá quen thuộc.

Nếu bạn có ý định học hay đã chuyển sang React thì mong bài viết có thể giúp các bạn có những mindset tốt hơn khi làm việc với nó.

Đây đều là những thứ mình cho rằng cần quan tâm sau 1 thời gian làm việc với React.

Bài viết này không nói về việc hướng dẫn code React.

Component

React is all about components

React được design theo hướng Component, mỗi phần tử nhỏ nhất như thẻ <span>, hay <button> đều có thể wrap thành một component để tái sử dụng 1 cách tối đa.

// path/to/button.jsx
export function Button({ children, type, onClick }) {
return (
<button type="button" className={`btn btn-${type}`} onClick={onClick}>
{children}
</button>
);
}

Và ở đâu cần component này, ta sẽ import nó vào để sử dụng.

import { Button } from "./path/to/button";

function Screen() {
return (
<Button type="primary" onClick={() => console.log("Clicked")}>
Click me
</Button>
);
}

Với những ngôn ngữ server side truyền thống, ta sẽ viết như sau (slim ruby):

/ path/to/a.slim
button.btn.btn-primary
| Button A
/ path/to/b.slim
button.btn.btn-primary
| Button B

Có thể thấy là code của ta trông sáng sủa hơn rất nhiều.

Trên thực tế, ta cũng nên viết những component nhỏ nhất có thể như vậy, nó sẽ giúp giao diện app ta đồng bộ, và debug cũng trở nên dễ hơn.

Hay thử với một component phức tạp hơn hơn xíu:

function List({ items, renderItem, emptyText }) {
if (items.length === 0) {
return (
<div>
<span>{emptyText}</span>
</div>
);
}

return <div>{items.map((item) => renderItem(item))}</div>;
}

Cấu trúc thư mục

Đối với những dự án lớn, có một cấu trúc thư mục rõ ràng sẽ khiến việc code trở nên dễ chịu hơn nhiều.

Lại quay lại các ngôn ngữ server side MVC cũ - Rails. Đây là một cấu trúc khó mà có thể thay đổi trong tất cả các dự án:

Dù với sự hỗ trợ của các Editor/IDE hiện đại trong việc tìm kiếm, mở file, thì cũng khá mất thời gian để có thể tìm được những file liên quan.

Với React thì flexible hơn, bạn có thể tổ chức theo kiểu trên (VD cho 1 project dùng Redux)

Nhưng với mình thì mình thích cách tổ chức theo component/màn hình hơn.

Mình học được cấu trúc trên từ một dự án trong list awesome react native - Gitpoint, dù nó được viết bằng React Native nhưng bản chất vẫn là React nên không có vấn đề gì hết.

Tuy nhiên cấu trúc này gặp 1 nhược điểm ở chỗ nếu 2 màn hình mà share nhau chung 1 state (redux) thì sẽ gây bối rối, hoặc là lặp code. Với mình, mình chọn giải pháp tạo 1 thư mục shared, rồi cũng tổ chức file giống cấu trúc của thư mục screens. Còn không thì chấp nhận việc code bị lặp 1 chút.

Functional Programming (FP)

Nếu như Ruby, Java là các ngôn ngữ lập trình hướng đối tượng (OOP) thì Javascript lại mang phong cách lập trình hàm, mặc dù vẫn có class, object nếu bạn muốn dùng (Prototype)

Với OOP, ta thường xuyên thay đổi trạng thái (các biến instance) của object, nhưng FP lại không thích điều này, mà thường hướng tới tính bất biến (Immutability) và pure function. Ta hãy cũng tìm hiểu thêm về chúng

Pure Functions

Lấy ví dụ với việc cộng 2 số tự nhiên

const z = 10;
const add = (x, y) => x + y;
add(1, 2); // 3
add(1, 3); // 4

Hàm add nhận 2 tham số xy, nó chỉ sử dụng 2 tham số đầu vào này để thực hiện phép tính, mà không sử dụng/làm thay đổi các biến ngoài phạm vi hàm.

Hơn nữa nó luôn trả về cùng 1 giá trị nếu như tham số đầu vào không thay đổi.

Còn xem hàm dưới đây

const bonus = 5;
const add = (x, y) => x + y + bonus;
add(1, 2); // 3
add(1, 2); // 3

Dù luôn trả về một giá trị với cùng tham số, tuy nhiên hàm lại phụ thuộc vào biến bonus (external variable) nên đây được xem là 1 impure function.

Ngoài ra impure function cũng gây ra các side effects như các hàm sau:

  • Gọi HTTP request
  • Ghi dữ liệu
  • Math.random(), new Date()
  • ....

Giá trị trả về của chúng không phải lúc nào cũng giống nhau, điều đó khiến kết quả của hàm khó mà lường trước được, khi test ta thường phải mock 1 giá trị cố định.

Pure function sẽ giúp app chúng ta ít lỗi hơn, ngoài ra cũng dễ dàng test hơn.

Trong React, nói đến pure function ta thường nghĩ đến stateless component - Functional Component, nó sẽ chỉ sử dụng props để tính toán và render dựa trên những props đó. Tuy nhiên những update gần đây đã thêm Hook, nó làm cho Functional Component cũng có thể có state. Cá nhân mình không thích cái Hook này lắm.

Nhưng app của ta không chỉ toàn pure function được, sẽ phải có những hàm gọi API, thay đổi state, lấy state để tính toán, lưu dữ liệu vào localStorage, ... nhưng hãy cố gắng giảm thiểu số lượng side effects xuống thấp nhất có thể.

Immutability

Một điều quan trọng nữa là tính bất biến.

Functional programming nói chung và React nói riêng, ta thường tránh việc sửa trực tiếp 1 biến nào đó, hay state. Thay vào đó, ta sẽ tạo 1 biến mới.

const person = {
age: 12,
};
person.name = "Name";

let array = [1, 2, 3];
array.push(4);

Nếu trong dự án của bạn có những dòng code như trên, thì code bạn đã mất tính bất biến. Thay vì trực tiếp chỉnh sửa 1 biến như vậy. Ta nên tạo 1 biến mới với những giá trị cũ, sau đó thêm giá trị mới vào.

const updatedPerson = {
age: person.age,
name: "Name",
};
// hay
const updatedPerson = {
...person,
name: "Name",
};

Dễ dàng nhận thấy updatedPerson.age vẫn tham chiếu đến giá trị cũ của person.name, tuy nhiên ta sử dụng biến updatedPerson này thế nào đi nữa, thì cũng sẽ không ảnh hưởng đến giá trị của person

Nhưng tại sao lại phải làm vậy? Thử xét 1 vòng lặp đơn giản như:

let s = 0;
let index = 0;
for (index = 0; index < data.length; index++) {
s += data[index];
}

Giả sử đang trong quá trình lặp, thi data[n] bị thay đổi, chẳng phải s += data[index] sẽ sai luôn hay sao.

Còn trong React, ta có React.PureComponent nó sẽ shallow compare props và state để quyết định xem có update component hay không.

export class SampleComponent extends React.PureComponent {
state = {
filter: {
name: "A name",
age: 21,
},
};

// shouldComponentUpdate(nextProps, nextState) {
// return shallowCompare(this, nextProps, nextState)
// }

handleClick = () => (this.state.filter.name = "New name");

render() {
return (
<div>
<h3>{this.state.filter.name}</h3>
<button onClick={this.handleClick}>Click me</button>
</div>
);
}
}

React.PureComponent đã định nghĩa sẵn cho ta phương thức shouldComponentUpdate như phần mình comment.

Hàm shallowCompare chỉ đơn thuần lặp tất cả các key có trong props và state, so sánh với state/props cũ.

Nó sẽ thực hiện việc so sánh currentState.filter === nextState.filter (so sánh bằng tham chiếu).

Việc này có lợi hơn rất nhiều so với việc deep compare tất cả các giá trị trong filter (cả về thời gian lẫn độ phức tạp).

Tuy nhiên, khi chúng ta trực tiếp chỉnh sửa this.state.filter.name = 'New name', sẽ khiến filter không hề thay đổi về tham chiếu. Khi đó:

currentState.filter === nextState.filter; // true
currentState.filter.name === nextState.filter.name; // false

SampleComponent của ta cũng sẽ không re-render, cho dù bạn có click đến sang năm.

Thay vào đó ta sẽ làm như sau

handleClick = () => {
this.setState({ filter: { ...this.state.filter, name: "New name" } });
};

Ta copy toàn bộ tham chiếu các attribute của state.filter cũ sang 1 object mới, và sau đó thay đổi biến name của object mới này, ta không hề làm thay đổi state.filter cũ.

Khi đó currentState.filter === nextState.filter sẽ trả về false. SampleComponent của ta được re-render lại. Và nếu có action nào không làm thay đổi state, component của ta cũng sẽ không re-render. Vậy là cả React và ta đều happy - một người khỏe, 2 người vui :v

Higher-Order Functions (HOF)

HOF nhận tham số là hàm, hoặc trả về một hàm khác, hoặc là cả 2.

VD 1 hàm nhận tham số hàm:

const name = "React";
const phoneNumber = "0969696969";
const numberValidator = (str) => str.match(/\d+/g);
const validate = (value, validator) => !!validator(value);

validate(name, numberValidator); // false
validate(phoneNumber, numberValidator); // false

Hay 1 hàm trả về 1 hàm:

function makeAdder(constantValue) {
return function adder(value) {
return constantValue + value;
};
}

ta gọi hàm này như sau: makeAdder(10)(20)

Thoạt nhìn có vẻ sợ, nhưng nếu viết minh bạch ra

const add10 = makeAdder(10);
add10(20); // 30

Mọi thứ trở nên rõ ràng hơn nhiều, makerAdder(10) trả về một hàm, sau đó ta gọi hàm này với tham số là 20

const add10 = makeAdder(10);
// add10 = function adder(value) {
// return 10 + value
// }
add10(20);

Trong React, ta cũng thường xuyên áp dụng kỹ thuật này, tuy nhiên ngoài HOF, thì ta còn có Higher-Order Component (HOC), nhằm chỉ những hàm nhận vào tham số là 1 Component và trả về 1 Component khác.

VD:

function protectRoute(Component, requiredRole) {
return function ProtectedRoute(props) {
const currentRole = localStorage.getItem("role");
if (currentRole < requiredRole) {
return <Redirect to="/sign_in" />;
}
return <Component {...props} />;
};
}

Declarative Programming

Ta có 2 thiên hướng lập trình đối lập nhau, đó là:

  • Imperative Programming: mô tả control flow của việc cần làm
  • Declarative Programming: chỉ viết ra những gì cần làm mà không mô tả rõ control flow của từng bước.

VD tiêu biểu đó là 1 vòng lặp cơ bản:

Style Imperative:

const arr = [1, 2];
const newArr = [];
for (let i = 0; i < arr.length; i += 1) {
newArr.push(arr[i] + 1);
}
console.log(newArr); // [2, 3]

Với style này, bạn nêu lần lượt từng bước code bạn thực hiện như thế nào, ta phải đi sâu vào implementation hơn.

Trong khi đó với Declarative:

const arr = [1, 2];
const newArr = arr.map((element) => element + 1); // [2, 3]

Ta sẽ chỉ nói rằng ta cần 1 mảng mới từ arr, mà mỗi phần tử của nó tăng lên 1. Code của ta vừa dễ hiểu và vừa đẹp hơn.

Một số ngôn ngữ tiêu biểu:

  • Imperative: C/C++, Java, ...
  • Declarative: SQL, HTML, ...
  • Tạp phế lù: Javascript =)), ...

Ở trên là những gì mình search được trên google khi bắt đầu tìm hiểu về Declarative Programming. Tuy nhiên, vẫn khá là khó hiểu, và khó phân biệt giữa 2 thằng Imperative và Declarative

Tuy nhiên khi đọc được comment này, mọi thứ đã trở nên sáng sủa hơn nhiều. Mình coi nó là 1 lớp abstract, mà khi gọi hàm đó, ta chỉ cần quan tâm đến what to do mà không phải nghĩ xem how to do.

Hãy sử dụng các hàm có sẵn của javascript như: .map, .reduce, .filter, ... và bạn sẽ nhận thấy được vẻ đẹp của Declarative Programming.

Quay trở lại với React, vậy thì tính Declarative của nó ở đâu?

Chính bản thân các component React đã rất 'declarative' rồi.

Hãy đọc phần description của React trên Github

A declarative, efficient, and flexible JavaScript library for building user interfaces.

Thử so sánh 2 đoạn mã sau:

// jQuery
$(".like").click(function () {
const $btn = $(this);
if ($btn.hasClass("liked")) {
$btn.removeClass("liked");
} else {
$btn.addClass("liked");
}
});
// React
class LikeButton extends React.Component {
// ...codes
render() {
if (this.state.liked) {
return <HighlightButton>Like</HighlightButton>;
}
return <Button>Like</Button>;
}
}

Với jQuery, ta phải mô tả chính xác code ta cần làm gì.

Trong khi đó với React: Tiểu nhị, like rồi thì cho cái HightlightButton ra đây, không thì Button thường thôi.

Ta sẽ quan tâm đến việc component cần hiển thị cái gì tương ứng với mỗi (props/state) của nó.

Link tham khảo

https://github.com/facebook/react

https://github.com/jondot/awesome-react-native

https://github.com/gitpoint/git-point

https://medium.freecodecamp.org/all-the-fundamental-react-js-concepts-jammed-into-this-single-medium-article-c83f9b53eac2?gi=7fb0830efbd4

https://medium.com/@cscalfani/so-you-want-to-be-a-functional-programmer-part-1-1f15e387e536

https://www.miles.no/blogg/tema/teknisk/why-care-about-functional-programming-part-1-immutability

https://blog.logrocket.com/immutability-in-react-ebe55253a1cc

https://stackoverflow.com/questions/1784664/what-is-the-difference-between-declarative-and-imperative-programming#comment29365241_1784702

· 9 min read
Lê Sĩ Bích

Ở kì trước, ta đã cùng nhau tìm hiểu về Pixel của CSS, đó là 1 đơn vị độc lập, không bị phụ thuộc vào độ phân giải hay các thông số khác của màn hình. Đối với một loại màn hình (xét theo khoảng cách từ mắt tới màn hình), nó luôn là 1 hằng số không đổi. Điều đó đã giúp cho lập trình viên Web dễ thở hơn rất nhiều.

Ở Android cũng có một đơn vị giống như vậy, Google đặt tên cho nó là DP (hay DIP - Density Independent Pixel). Ngoài ra đơn vị này còn dùng trong React Native [link]

2. Một số khái niệm cần nhắc lại

Pixel thiết bị:

Là 1 điểm ảnh, nó phụ thuộc vào kích thước màn hình, và độ phân giải màn hình. Thông thường, 1px thiết bị trên các màn hình đều có giá trị khác nhau.

Pixel này là pixel vật lý của thiết bị khác hoàn toàn so với đơn vị px mà ta hay sử dụng trong CSS.

Độ phân giải thiết bị - Resolution:

Các bạn thường thấy màn hình có độ phân giải 4k, HD, FullHD, kèm theo nó là 2 con số có dạng P1xP2 phải không nào?

Những con số này thể hiện số pixel có trên màn hình, P1 là số pixel phân bố theo chiều dài, còn P2 là theo chiều rộng.

(ảnh chỉ mang tính chất minh họa, mình k phải seeder đâu nhá =)))

TV trên có chiều dài 52.4 inch, có 3840px theo chiều ngang. 1px sẽ có giá trị

1px = 52.4 / 3840 = 0.01365 (inch)

DPI, PPI:

DPI - Dots Per Inch. PPI - Points Per Inch

Chúng đều là số pixel/điểm ảnh có trên 1 inch độ dài vật lý của thiết bị, biểu thị mật độ pixel trên màn hình.

DPI thấp, 1px có kích thước lớn, hình ảnh có thể sẽ bị răng cưa, không sắc nét bằng DPI cao

Lấy lại VD màn hình TV trên:

DPI = 3840 / 52.4 = 73.28 (pixels/inch)

3. Density Independent Pixels

3.1 Đặt vấn đề

Các điện thoại Android có nhiều loại kích thước (phone, tablet, TV, ...), hơn nữa chúng lại còn có độ phân giải khác nhau, dẫn đến kích thước pixel luôn thay đổi, gây khó khăn cho nhà phát triển khi dựng giao diện.

Lấy ví dụ đơn giản nhất về 2 điện thoại có cùng kích thước đường chéo 5inch, nhưng 1 chiếc FullHD 1080x1920, 1 chiếc 4K 2160 x 3840.

Nếu bạn là dev, bạn sẽ muốn 1 button trong app của mình hiển thị trên 2 điện thoại này giống nhau phải không nào?

Thế nhưng nếu làm việc với px thiết bị, ta sẽ gặp 1 chút rắc rối vì với cùng 1 kích thước, điện thoại 4K luôn cần gấp đôi số px so với FullHD.

2 cái đã mệt, trên thực tế, ta có ty tỷ loại màn hình, vậy thì style sao cho xuể, chưa kể còn đến tối ưu các file ảnh cho app.

Chính vì vậy, dp ra đời.

3.2. DP

Google định nghĩa nó như sau:

One dp is a virtual pixel unit that's roughly equal to one pixel on a medium-density screen (160dpi; the "baseline" density). Android translates this value to the appropriate number of real pixels for each other density.

Nếu bạn đã đọc bài trước của mình về CSS pixel, thì có thể thấy nó khá quen thuộc phải không nào, đây cũng là 1 virtual unit.

1dp tương đương 1 px trên màn hình 160dpi. Có nghĩa là 1dp = 1 / 160 = 0.00625 (inch) = 1px (160dpi)

Để quy đổi dp sang px trên các loại màn hình khác, ta nhân dp với 1 số, gọi là scale factor (tỉ lệ DPI của màn hình so với chuẩn 160dpi).

Ví dụ máy 320dpi. 1dp = 1 * 320 / 160 = 2px

Android sẽ tự động convert sang pixel thiết bị cho ta, nên ta chỉ cần dùng dp là trên mọi thiết bị, phần tử của ta sẽ đều có kích thước bằng nhau (ngoại lệ sẽ đề cập sau).

Khi ta dựng UI, nên tận dụng tối đa dp, tránh sử dụng px.

Bên cạnh đó, Google cũng khuyên ta không nên sử dụng dp cho text, mà hãy dùng sp. Đây cũng là một loại density-independent, nhưng nó có thể bị ảnh hưởng bởi tùy chọn của người dùng. Người dùng chọn cỡ chữ to, thì 1sp cũng lớn lên theo. Vì vậy, cũng tránh dùng sp khi thiết kế layout.

3.3 Density Bucket

dp ra đời, nhưng cũng chưa giải quyết được hết các vấn đề. Đó chính là về resource hình ảnh. Style cho ảnh thì có thể sử dụng dp được (ảnh sẽ bị kéo to, hoặc co lại cho đủ size cần thiết), nhưng resource thì đâu có thể to nhỏ tùy ý được, thông số của nó luôn là cố định.

Lấy ví dụ về 1 hình ảnh 500x500px (resolution), với 2 màn hình có kích thước bằng nhau, nhưng khác độ phân giải (khác DPI), 1 chiếc là 360x640, 1 chiếc là 720x1280.

Hình ảnh trên nếu hiển thị trên màn hình 360x640 thì chẳng phải sẽ dư thừa sao? Vì chiều rộng màn hình chỉ có 360px. Còn trên màn hình 720x1280, thì nó chả đáng là bao. Vì vậy ta cần sử dụng hình ảnh có độ phân giải thấp hơn cho màn hình 360x640.

Nhưng không lẽ cứ mỗi độ phân giải lại tạo 1 hình ảnh tương ứng? Vậy thì app size của ta sẽ bùng nổ luôn.

Vì lẽ đó mà ra đời density bucket.

Để cho đơn giản, các loại màn hình có DPI gần bằng nhau sẽ được làm tròn rồi gom vào 1 nhóm. Ví dụ như máy 293dpi, Android sẽ coi như máy đó là 320dpi, khi đó 1dp = 2px. Còn nhiều loại khác nữa, nhưng mình chỉ nêu ra 4 bucket đáng chú ý, best practice là support cả 4 bucket này.

Bucket nameDensity bucketDPIScale Factor
Mediummdpi1601.00
Highhdpi2401.50
Extra Highxhdpi3202.00
Extra Extra Highxxhdpi4803.00

Những gì bạn cần làm là ném hết hình ảnh của các bucket vào thư mục tương ứng. Android sẽ tự detect và chọn hình ảnh tối ưu nhất cho bạn (lấy từ DPI cao nhất về).

Cũng vì việc làm tròn DPI này, mà 1dp của ta trên các thiết bị có thể không bằng nhau, nhưng luôn xấp xỉ 1/160 = 0.00625 inch

DensityBucketScale factorPhysical Size (inch)
293dpixhdpi - 320dpi21 * 2 / 293 = 0.00686
320dpixhdpi21 * 2 / 320 = 0.00625
330 dpixhdpi21 * 2 / 330 = 0.00606

Điều này cũng đem đến cho ta 1 lợi ích, đó là với các số máy density khác nhau, nhưng cùng độ phân giải, kích thước bề ngang của ta tính bằng dp đều bằng nhau. Lấy ví dụ 3 máy 720x1280, 1 máy 293 dpi, 1 máy 320 dpi, 1 máy 330 dpi.

DensityWidth (without Density Bucket)Width (with density bucket)
293dpi720 / (293 / 160) = 393dp360dp
320dpi720 / (320 / 160) = 360dp360dp
330dpi720 / (330 / 160) = 349dp360dp

4. Ví dụ

Sau khi nhồi 1 đống lý thuyết vào đầu, ta hãy thử làm 1 bài tập nho nhỏ, để áp dụng chúng. Hãy thử tự tính toán thông số cho điện thoại của mình xem.

Điện thoại mình 5inch, độ phân giải 720x1280.

Ta dễ dàng tính toán được chiều dài và chiều rộng của máy, dựa vào định lý Pytago:

5**2 = (1280 * x)**2 + (720 * x)**2
width = 720 * x = 2.4513 (inch)
height = 1280 * x = 4.3579 (inch)

Từ đó ta có thể tính tất cả các thông số khác:

Thông sốGiá trị
Device density720 / 2.4513 = 294 dpi
Density bucketxhdpi - 320dpi
Scale factor320 / 160 = 2.00
Width720 / 2 = 360dp
Height1280 / 2 = 640dp
DIP1dp = 2px = 0.0068 (inch)

5. Kết luận

Sự ra đời của dpdensity bucket quả thực đã giúp ta tránh được không ít rắc rối trong khi phát triển ứng dụng Android, tối ưu hình ảnh cho app. Bài viết không tránh khỏi sai sót, nếu có sạn, hãy cứ góp ý, mình sẽ tiếp thu và chỉnh sửa cho phù hợp.

Đây có lẽ là bài cuối của mình trong series về những thứ bé nhỏ mà ít ai để ý trong khi thiết kế giao diện Web, mobile. Cám ơn mọi người đã đọc bài! - nếu các bạn đọc đến tận dòng này =))

6. Link tham khảo

  1. https://developer.android.com/training/multiscreen/screendensities
  2. https://www.highgroundgaming.com/tas/gaming-mouse-guide/low-vs-high-dpi-example/
  3. https://facebook.github.io/react-native/docs/height-and-width.html#fixed-dimensions
  4. https://www.avconcepts.com/2015/09/24/4k-ultra-hd-vs-hd-projection/
  5. https://www.captechconsulting.com/blogs/understanding-density-independence-in-android
  6. https://medium.com/@sashaserg/a-mysterious-density-independent-pixel-a-quick-introduction-to-android-design-111d68be7cf5