Expressions, Functions, and Conditionals
แนวคิดในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “แนวคิดในหนึ่งประโยค”ภาษา expression ของ Terraform มาพร้อม built-in function สำหรับแปลงค่า, REPL terraform console สำหรับเช็คว่า expression ประเมินเป็นอะไรก่อนที่มันจะเข้าใกล้ infrastructure จริงเลย, conditional expression สำหรับเลือกระหว่างสองค่า และ dynamic block สำหรับสร้าง nested block จำนวนไม่คงที่ภายใน resource เดียว
Built-in function: แปลงและรวมค่า
หัวข้อที่มีชื่อว่า “Built-in function: แปลงและรวมค่า”function library ของ Terraform จัดกลุ่มคร่าว ๆ ตามชนิดของค่าที่มันทำงานด้วย string function อย่าง join และ split ย้ายไปมาระหว่าง list กับ string ที่คั่นด้วยตัวคั่น และ format สร้าง string จาก template บวก argument collection function อย่าง lookup, merge, contains อ่านและรวม map กับ list numeric function ครอบคลุมเลขคณิตและการปัดเศษทั่วไป network function อย่าง cidrsubnet เฉพาะสำหรับแบ่ง address range ซึ่งเจอบ่อยมากเมื่อทำงานกับ Azure virtual network
variable "vnet_address_space" { type = string description = "CIDR block for the virtual network" default = "10.0.0.0/16"}
locals { # cidrsubnet(prefix, newbits, netnum) carves a /16 into /24 subnets subnet_web_cidr = cidrsubnet(var.vnet_address_space, 8, 0) # 10.0.0.0/24 subnet_app_cidr = cidrsubnet(var.vnet_address_space, 8, 1) # 10.0.1.0/24 subnet_db_cidr = cidrsubnet(var.vnet_address_space, 8, 2) # 10.0.2.0/24
# format() builds a name from a template; join() flattens a list into a string name_prefix = format("%s-%s", var.environment, var.project) dns_servers = join(",", ["10.0.0.4", "10.0.0.5"])
# merge() combines maps, later arguments winning on key conflicts vm_tags = merge( { Project = var.project, Environment = var.environment }, { Tier = "web" }, )
# lookup() reads a map key with a fallback instead of erroring on a miss region_short_name = lookup( { eastus = "eus", eastus2 = "eus2", westeurope = "weu" }, var.location, "unk", )
# contains() checks membership without writing a for expression is_allowed_region = contains(["eastus", "eastus2", "westeurope"], var.location)}
resource "azurerm_virtual_network" "main" { name = "${local.name_prefix}-vnet" address_space = [var.vnet_address_space] resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location}
resource "azurerm_subnet" "web" { name = "${local.name_prefix}-web-subnet" resource_group_name = azurerm_resource_group.main.name virtual_network_name = azurerm_virtual_network.main.name address_prefixes = [local.subnet_web_cidr]}cidrsubnet ควรเข้าใจให้ดี: มันรับ CIDR block ฐาน, จำนวนบิตเพิ่มที่จะขยาย prefix, และหมายเลข subnet แล้วคืน CIDR block ใหม่ที่ไม่ทับกัน cidrsubnet("10.0.0.0/16", 8, 0) ขยาย /16 ด้วย 8 บิตเป็น /24 แล้วเลือกบล็อกที่ศูนย์ ได้ 10.0.0.0/24; ส่ง 1 เป็น argument ที่สามได้ 10.0.1.0/24 และต่อไป การคำนวณ subnet range สามตัวแบบนี้ครั้งเดียวใน locals ทำให้ทุก azurerm_subnet resource อ้างอิง local ที่มีชื่อแทน CIDR literal ที่พิมพ์เองซึ่งต้องมานั่งเช็คว่าทับกันหรือเปล่า
terraform console: เช็ค expression ก่อนใส่ลง resource
หัวข้อที่มีชื่อว่า “terraform console: เช็ค expression ก่อนใส่ลง resource”terraform console เปิด REPL แบบ interactive กับคอนฟิกและ state ปัจจุบัน โดยไม่เปลี่ยนทั้งคู่ มันคือวิธีที่เร็วที่สุดในการตอบคำถาม “expression นี้ประเมินเป็นอะไรจริง ๆ” ก่อนจะ paste ลงใน resource argument ที่พิมพ์ผิดแล้วสังเกตได้ยากกว่ามาก
$ terraform console> join("-", ["dev", "payments", "web"])"dev-payments-web"> cidrsubnet("10.0.0.0/16", 8, 1)"10.0.1.0/24"> contains(["eastus", "eastus2", "westeurope"], "westeurope")true> var.vnet_address_space"10.0.0.0/16"> exitทุกบรรทัดที่พิมพ์ที่ prompt > ถูกประเมินครั้งเดียวแล้วพิมพ์ผลลัพธ์ เหมือนกับที่ expression ตอน plan จะ resolve เป๊ะ โดยใช้ค่าจริงที่มีอยู่แล้วใน variable และ state ไม่มีขั้นตอน apply และไม่มี side effect — exit หรือ Ctrl-D แค่ปิด session นี่คือเครื่องมือที่ควรใช้ทุกครั้งที่การเรียก cidrsubnet หรือ merge ของสอง map ดูไม่ถูกต้องใน plan diff แล้วต้องการแยก expression ที่แท้จริงออกมาก่อนแตะ resource block เอง
try และ can: expression ที่อาจ fail ได้
หัวข้อที่มีชื่อว่า “try และ can: expression ที่อาจ fail ได้”บาง expression ใช้ได้แค่บางเวลา — object attribute ที่เป็นตัวเลือกซึ่งอาจถูกตั้งหรือไม่ก็ได้ try(expr1, expr2, ...) ประเมินแต่ละ argument ตามลำดับแล้วคืนตัวแรกที่ไม่ error; can(expr) ประเมิน expression เดียวแล้วคืน true/false ว่ามันสำเร็จไหม ทิ้งค่าจริงไป ทั้งสองมีไว้เพื่อให้การอ้างอิงที่ผิดพลาดหนึ่งจุดไม่ทำให้ทั้ง plan ล้มเหลว
variable "config" { type = object({ name = string }) default = { name = "app" }}ผลลัพธ์จริงจาก terraform console — var.config ข้างบนไม่มี attribute location เลย:
> try(var.config.location, "eastus")"eastus"
> try(var.config.location)╷│ Error: Error in function call││ Call to function "try" failed: no expression succeeded:│ - Unsupported attribute│ This object does not have an attribute named "location".││ At least one expression must produce a successful result.╵
> can(var.config.location)false
> can(var.config.name)truetry() ที่มี fallback จะคืน fallback ทันทีที่ expression แรก error โดยไม่มี error โผล่ให้เห็นเลย try() ที่ไม่มีตัวไหนสำเร็จเลยจะโยน error จริงออกมา — ไม่ใช่ null แบบเงียบ ๆ can() ไม่เคยคืนค่าจริงเลย คืนแค่ว่าการประเมินนั้นจะสำเร็จไหม — มีประโยชน์ภายใน validation condition เพราะ condition ต้องประเมินเป็น boolean ธรรมดาเสมอ ห้าม raise error
templatefile: render ไฟล์ template จากภายนอก
หัวข้อที่มีชื่อว่า “templatefile: render ไฟล์ template จากภายนอก”templatefile(path, vars) อ่านไฟล์จาก disk, render เป็น Terraform template แล้วคืนผลลัพธ์เป็น string — วิธีมาตรฐานในการเก็บข้อความ template (cloud-init script, nginx config) ให้อยู่นอกไฟล์ HCL ไปเลย
server { name = "${name}" port = ${port}%{ for tag in tags ~} tag "${tag}"%{ endfor ~}}locals { rendered_config = templatefile("${path.module}/config.tftpl", { name = "web" port = 8080 tags = ["prod", "eastus"] })}ผลลัพธ์จริงจาก terraform console กับ template เดียวกันเป๊ะข้างบน:
> templatefile("${path.module}/config.tftpl", { name = "web", port = 8080, tags = ["prod", "eastus"] })<<EOTserver { name = "web" port = 8080 tag "prod" tag "eastus"}
EOTคู่ %{ for tag in tags ~} / %{ endfor ~} เป็น template directive ไม่ใช่ HCL — มันรันหนึ่งครั้งต่อ element ของ tags และ ~ ท้ายบรรทัดจะลบ newline ที่ปกติจะโผล่รอบบรรทัด directive เอง ทำให้ผลลัพธ์ที่ render แล้วไม่มีบรรทัดว่างแปลก ๆ ต้องส่งค่าให้ครบทุก variable ที่ template อ้างอิง — ถ้าขาดไปหนึ่งตัวคือ error จริงตอน plan ไม่ใช่ string ว่างเปล่าแบบเงียบ ๆ
Provider-defined function (Terraform 1.8+)
หัวข้อที่มีชื่อว่า “Provider-defined function (Terraform 1.8+)”CHANGELOG ของ Terraform 1.8 ระบุชัด: “Providers can now offer functions which can be used from within the Terraform configuration language” เรียกด้วย syntax provider::provider_name::function_name(...) ต่างจาก standard library ที่เป็น built-in ข้างบน function พวกนี้ถูก implement โดย provider เฉพาะ มาพร้อม provider นั้น และมีอยู่เพราะข้อมูลของ provider นั้นเอง (Azure resource ID) ต้องการ logic การ parse ที่ function library ทั่วไปไม่ควรรู้
provider hashicorp/azurerm (ยืนยันแล้วจากการอ่าน internal/provider/function/ ใน source ของ hashicorp/terraform-provider-azurerm) ตอนนี้มีสองตัว: parse_resource_id และ normalise_resource_id
locals { parsed_id = provider::azurerm::parse_resource_id(azurerm_linux_virtual_machine.web.id)}ผลลัพธ์จริงจาก terraform console กับรูปแบบ Azure resource ID จริง:
> provider::azurerm::parse_resource_id("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-demo/providers/Microsoft.Compute/virtualMachines/web"){ "full_resource_type" = "Microsoft.Compute/virtualMachines" "parent_resources" = tomap({}) "resource_group_name" = "rg-demo" "resource_name" = "web" "resource_provider" = "Microsoft.Compute" "resource_scope" = "" "resource_type" = "virtualMachines" "subscription_id" = "00000000-0000-0000-0000-000000000000"}
> provider::azurerm::normalise_resource_id("/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/rg-demo/providers/microsoft.compute/virtualmachines/web")"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-demo/providers/Microsoft.Compute/virtualMachines/web"parse_resource_id แปลง ARM resource ID string ยาว ๆ ที่พิมพ์ผิดง่ายซึ่งทุก azurerm_* resource คืนเป็น .id ให้เป็น object ที่มีโครงสร้าง — resource_group_name, resource_type, subscription_id โดยไม่ต้องเขียน split("/", ...) เอง normalise_resource_id แก้ปัญหาตรงข้ามที่ลึกซึ้งกว่า: Azure resource ID string สองอันที่หมายถึง resource เดียวกันเป๊ะอาจต่างกันแค่ตัวพิมพ์ใหญ่เล็ก (resourcegroups กับ resourceGroups, microsoft.compute กับ Microsoft.Compute) ขึ้นอยู่กับว่า API ไหนคืนมา ซึ่งทำให้การเทียบ string ตรง ๆ พัง; normalise_resource_id เขียนทับตัวพิมพ์ให้เป็นรูปแบบมาตรฐานของ ARM ทำให้ ID สองอันของ resource เดียวกันเทียบเท่ากันได้
Conditional expression: เลือกระหว่างสองค่า
หัวข้อที่มีชื่อว่า “Conditional expression: เลือกระหว่างสองค่า”Conditional expression มีสามส่วน: condition ที่ต้องประเมินเป็น true หรือ false, ค่าที่ใช้เมื่อมันเป็น true, และค่าที่ใช้เมื่อมันเป็น false เขียนเป็น condition ? true_val : false_val มันคือ construct การแตกสาขาระดับค่าเดียวที่ Terraform มี — ไม่มี if statement มีแค่ expression ที่ resolve เป็นหนึ่งในสองค่า
locals { vm_size = var.environment == "prod" ? "Standard_D8s_v3" : "Standard_D2s_v3"
vm_tier_tag = upper(var.environment == "prod" ? "critical" : "standard")}
resource "azurerm_linux_virtual_machine" "web" { name = "${local.name_prefix}-web" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location size = local.vm_size admin_username = "azureuser"
admin_ssh_key { username = "azureuser" public_key = file("~/.ssh/id_rsa.pub") }
os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" }
tags = { Tier = local.vm_tier_tag }}local.vm_size เลือก virtual machine ขนาดใหญ่ขึ้นเฉพาะใน prod ที่อื่นตกไปใช้ default ที่เล็กกว่า — ทดแทนสิ่งที่ปกติต้องเป็นสอง variable default ที่แทบเหมือนกัน หรือตาราง lookup สำหรับตัวเลือกแบบไบนารี ด้วยบรรทัดเดียว local.vm_tier_tag ต่อ conditional เข้ากับการเรียก function โดยตรง: conditional resolve เป็น "critical" หรือ "standard" ก่อน แล้ว upper() ก็แปลง string ที่ชนะก่อนจะถูกกำหนดให้ tag การซ้อนแบบนี้เป็นเรื่องปกติ — conditional ก็แค่ expression และปรากฏได้ทุกที่ที่ expression ใช้ได้ รวมถึงเป็น argument ของอีก function หนึ่ง
Dynamic block: nested block จำนวนไม่คงที่ภายใน resource เดียว
หัวข้อที่มีชื่อว่า “Dynamic block: nested block จำนวนไม่คงที่ภายใน resource เดียว”count และ for_each บน resource (จะพูดถึงในบทถัดไป) ตัดสินใจว่าจะสร้างสำเนาของ resource ทั้งตัวกี่ชุด dynamic block แก้ปัญหาที่เกี่ยวข้องกันแต่ต่างออกไป: สร้าง nested block ที่ซ้ำ ๆ กันจำนวนไม่คงที่ ภายใน resource เดียว ขับเคลื่อนด้วย collection azurerm_network_security_group เป็นตัวอย่างที่ดี — มันรับ security_rule block ได้ตั้งแต่ศูนย์ตัวขึ้นไป และจำนวนกฎมักมาจาก list ที่ caller ควบคุม ไม่ใช่จำนวนคงที่ที่รู้ล่วงหน้า
variable "allowed_ports" { type = list(number) description = "TCP ports allowed inbound on the network security group" default = [22, 443, 8080]}
resource "azurerm_network_security_group" "web" { name = "${local.name_prefix}-web-nsg" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location
dynamic "security_rule" { for_each = var.allowed_ports
content { name = "allow-port-${security_rule.value}" priority = 100 + security_rule.key direction = "Inbound" access = "Allow" protocol = "Tcp" source_port_range = "*" destination_port_range = tostring(security_rule.value) source_address_prefix = "*" destination_address_prefix = "*" } }}dynamic "security_rule" block วนซ้ำ var.allowed_ports แล้วปล่อย nested block security_rule หนึ่งอันต่อหนึ่ง element โดยไม่ต้องเขียน security_rule { ... } แยกสามครั้ง หรือแก้ resource ทุกครั้งที่รายการพอร์ตเปลี่ยน ภายใน content, security_rule.value คือ element ปัจจุบันของ list และ security_rule.key คือ index ของมัน ทั้งคู่ตั้งชื่อตาม label ของ block โดย default เพิ่มพอร์ตที่สี่เข้าไปใน allowed_ports แล้ว security_rule block ที่สี่จะปรากฏใน plan ถัดไป โดยไม่ต้องแก้ resource block เอง — นี่คือความสะดวกเดียวกับที่ count/for_each มอบให้ในระดับ resource ทั้งตัว แค่ประยุกต์ลึกลงไปอีกชั้น กับ block ที่อยู่ภายใน resource เดียว
flowchart LR
a["var.environment"] --> b{"conditional: environment == prod ?"}
b -->|true| c["critical"]
b -->|false| d["standard"]
c --> e["upper() function call"]
d --> e
e --> f["local.vm_tier_tag"]
f --> g["azurerm_linux_virtual_machine.web tags"] Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกอย่างในบทนี้จริง ๆ แล้วคือการ resolve ค่า ก่อน Terraform เรียก provider เลย: การเรียก function, try/can, conditional, templatefile และ provider-defined function ทั้งหมดรันตอนประเมิน expression ซึ่งเร็วกว่าการเรียก API ตอน plan เสมอ dynamic เป็นข้อยกเว้นเดียวที่ดูเหมือนพวกนี้แต่ไม่ใช่ — มันไม่ได้คำนวณค่า มันขยายเป็นโครงสร้าง HCL (nested block ที่ซ้ำ) ก่อนที่สคีมาของ resource เองจะถูกประเมินด้วยซ้ำ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
try(a, b, ...) | การอ้างอิงที่ผิดพลาดจุดเดียวไม่ทำให้ plan ทั้งหมดล้มเหลว — พิสูจน์ข้างบนด้วย fallback จริง | กลืน เหตุผล ที่ expression แรก fail แบบเงียบ ๆ ถ้าตัวถัดไปสำเร็จ |
can(expr) | boolean ที่สะอาดสำหรับ validation condition พิสูจน์ข้างบน | ทิ้งค่าจริงไปทั้งหมด |
templatefile | เก็บข้อความ template ก้อนใหญ่ไว้นอก HCL string ทั้งหมด พิสูจน์ข้างบนด้วยการ render จริง | ต้องส่งค่าให้ครบทุก variable ที่อ้างอิง — ขาดไปหนึ่งตัวคือ error จริงตอน plan |
Provider-defined function (provider::...) | parse/normalize Azure resource ID โดยไม่ต้องเขียน split() เองและไม่ต้องกังวลการเทียบ string ที่พังเพราะตัวพิมพ์ พิสูจน์ข้างบน | ผูกกับ version ของ azurerm provider ที่ติดตั้ง ไม่ใช่แค่ version ของ Terraform CLI |
dynamic block | สร้าง nested block จำนวนไม่คงที่จาก collection พิสูจน์ข้างบนด้วย security_rule | เพิ่มความอ้อม (security_rule.value/.key) ทำให้ resource อ่านยากกว่า block ตายตัวจำนวนน้อยที่เขียนตรง ๆ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
try()คืนnullแบบเงียบ ๆ ถ้าทุก expression fail” พิสูจน์ข้างบนแล้วว่ามันโยน error จริงออกมา (“no expression succeeded”) —try()จะกลืน error ก็ต่อเมื่อมี argument ไหนสำเร็จจริง ๆ เท่านั้น - “
can()คืนค่าจริงถ้า expression สำเร็จ” พิสูจน์ข้างบนแล้วว่าcan()คืน boolean ธรรมดาเสมอ ไม่เคยคืนค่าจริง - “Azure resource ID สอง string ที่ดูต่างกันต้องเป็นคนละ resource แน่นอน” ไม่จำเป็นเสมอไป — พิสูจน์ข้างบนด้วย
normalise_resource_id: ความต่างของตัวพิมพ์ (resourcegroupsกับresourceGroups) ทำให้ ID ของ resource เดียวกัน เทียบเป็น string ตรง ๆ แล้วไม่เท่ากันได้ ต้อง normalize ก่อนเทียบ - “มีแค่ AWS ที่มี provider-defined function” ขัดแย้งกับสิ่งที่พิสูจน์ข้างบนจากการอ่าน source ของทั้งสอง provider โดยตรง —
hashicorp/googleมีของตัวเองเจ็ดตัว (name_from_id,region_from_zoneและอื่น ๆ) และhashicorp/awsมีสี่ตัว - “
dynamicกับfor_eachบน resource แก้ปัญหาเดียวกัน” ไม่ใช่:for_eachบน resource สร้าง resource instance ทั้งตัวหลายอัน;dynamicสร้าง nested block หลายอัน ภายใน resource instance เดียว — พิสูจน์ข้างบนด้วยazurerm_network_security_group.webตัวเดียวที่มีsecurity_ruleblock ที่ generate มาสามอัน
อ่านโค้ดที่ AI สร้างให้
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้างให้”try(a.b.c, "default")ที่ generate มาใช้ทุกที่ที่ค่าอาจหายไป โดยไม่เช็คว่าค่าที่หายไปควรเป็น error ดัง ๆ ตอน plan แทนไหมsplit("/", azurerm_resource.thing.id)ด้วยมือเพื่อดึง resource group หรือ resource type จาก Azure resource ID ทั้งที่provider::azurerm::parse_resource_idทำแบบนั้นให้อยู่แล้ว พิสูจน์ข้างบนแล้ว — และน่าเชื่อถือกว่า เพราะตัวพิมพ์ของ ARM ID ไม่สอดคล้องกันเสมอไปข้าม API- การเทียบ string
==ตรง ๆ ระหว่าง Azure resource ID สองอันที่มาจากแหล่งต่างกัน (อันหนึ่งจาก resource attribute อีกอันจาก data source หรือ variable) โดยไม่ normalize ตัวพิมพ์ก่อน — พิสูจน์ข้างบนแล้วว่านี่คือสาเหตุจริงของผลลัพธ์ “ไม่เท่ากัน” ที่ผิดพลาด templatefile()ที่เรียกโดยขาด variable ที่ template อ้างอิงจริง — พิสูจน์ข้างบนแล้วว่านี่คือ error จริงตอน plan ไม่ใช่การแทนที่ด้วยค่าว่างdynamicblock ที่ generate มาสำหรับ nested block จำนวนคงที่และน้อยที่รู้อยู่แล้ว (สองสามกฎsecurity_ruleที่ไม่เคยเปลี่ยน) — ความอ้อมที่ไม่มีประโยชน์เมื่อชุดข้อมูลไม่ได้ไม่คงที่จริง ๆ
🐛 หา bug ในโค้ดนี้
variable "location_override" {type = stringdefault = null}
locals {# "Safely" fall back to a default locationeffective_location = try(var.location_override.name, "eastus")}เฉลย
นี่แย่กว่า error เสียอีก — มันคือ bug แบบเงียบ ๆ var.location_override ถูก type เป็น string ธรรมดา (หรือ null) ไม่ใช่ object ดังนั้นมันไม่มี attribute .name เลย — var.location_override.name fail ทุกครั้ง ไม่ว่าค่าของ var.location_override จะเป็นอะไร รวมถึง override จริงที่ caller ตั้งไว้ด้วย เพราะมัน fail เสมอ fallback "eastus" ของ try() เลย fire ทุกครั้งเช่นกัน โดยไม่มี error โผล่ให้เห็นเลย พิสูจน์ด้วยรูปแบบเดียวกันเป๊ะใน terraform console โดยตั้ง override จริง:
$ TF_VAR_location_override="westeurope" terraform console> try(var.location_override.name, "eastus")"eastus"caller ที่ตั้ง location_override = "westeurope" ยังได้ "eastus" กลับมาอยู่ดี — override ของเขาถูกทิ้งอย่างเงียบ ๆ ไม่มี error ไม่มีคำเตือน ไม่มีอะไรใน output ของ plan ที่บอกว่ามีอะไรผิดพลาด พิสูจน์ด้วย expression ดิบ (ไม่มี try()) กับ variable เดียวกัน นี่คือสิ่งที่ try() กำลังกลืนอยู่:
$ terraform console> var.location_override.name╷│ Error: Attempt to get attribute from null value││ This value is null, so it does not have any attributes.╵ทางแก้คือเลิกใช้ try() แทนการทำ type ของ variable ให้ถูกต้อง: อ้างอิง var.location_override ตรง ๆ (try(var.location_override, "eastus") ไม่มี .name) เพราะมันเป็น string ธรรมดา หรือเปลี่ยน type ของ variable เป็น object({ name = string }) ถ้า caller ตั้งใจส่งค่าที่มีโครงสร้างจริง ๆ