هر فایل به سیاست کش یکسان نیاز ندارد

کاربر در بار اول تصویر را دریافت می‌کند و در بازدید بعدی نباید دوباره همان بایت‌ها را دانلود کند. از طرف دیگر، نگه‌داشتن طولانی HTML ممکن است او را از مقاله تازه محروم کند. سیاست کش باید بر اساس نوع منبع نوشته شود: فایل نسخه‌دار، صفحه قابل تغییر یا پاسخ خصوصی. یک تنظیم عمومی برای همه مسیرها معمولاً یکی از این نیازها را قربانی می‌کند.

Cache-Control مشخص می‌کند چه کسی و تا چه مدت می‌تواند پاسخ را نگه دارد. no-cache یعنی پیش از استفاده دوباره اعتبارسنجی انجام شود؛ به معنی ممنوعیت ذخیره نیست. no-store برای پاسخ‌هایی است که نباید در کش ذخیره شوند. private ذخیره در کش مشترک را محدود می‌کند، اما به‌تنهایی جای احراز هویت یا مدیریت داده حساس را نمی‌گیرد. برای صفحات حساب کاربری، نیازهای محرمانگی را جدا از سرعت بررسی کنید.

تفکیک تازگی، اعتبارسنجی و محرمانگی

برای تصویر و CSS که نامشان با تغییر محتوا عوض می‌شود، می‌توان کش طولانی با immutable در نظر گرفت. شرط مهم همین عوض‌شدن آدرس است؛ اگر bytes را در همان URL جایگزین کنید، بعضی کاربران نسخه قدیمی را خواهند دید. در این سایت نیز می‌توان تصویر مرجع را یک‌بار دانلود و نسخه WebP را محلی سرو کرد؛ مرورگر برای دیدن مقاله به میزبان تصویر اصلی وصل نمی‌شود.

ETag یک اعتبارسنج برای نسخه منبع است. مرورگر مقدار قبلی را در If-None-Match می‌فرستد و سرور در صورت تطابق می‌تواند پاسخ 304 بدون بدنه بدهد. نمونه زیر از محتوای ثابت حافظه یک برچسب مبتنی بر SHA-256 می‌سازد. هدف فهم مسیر درخواست شرطی است، نه جایگزینی وب‌سرور production؛ نحو کامل فهرست برچسب‌ها و مقایسه ضعیف باید در سرور واقعی به پیاده‌سازی آزموده‌شده سپرده شود.

در ابزار Network مرورگر، بازدید اول و دوم را با تنظیمات یکسان مقایسه کنید. دریافت از memory cache، disk cache و پاسخ 304 سه مسیر متفاوت‌اند. 304 هنوز رفت‌وبرگشت شبکه دارد، درحالی‌که فایل تازه موجود در کش ممکن است بدون درخواست استفاده شود. در آزمایش توسعه، گزینه Disable cache نتیجه را تغییر می‌دهد؛ وضعیت آن را ثبت کنید تا گزارش قابل تکرار باشد.

نمونه کد و روش بررسی

پیش‌نیاز و خروجی مورد انتظار در توضیح مثال آمده است. ابتدا نمونه را در محیط آزمایشی اجرا کنید و سپس آن را با داده و محدودیت‌های پروژه خود تطبیق دهید.

1. سرور آموزشی Python؛ فقط روی localhost

from http.server import BaseHTTPRequestHandler, HTTPServer
from hashlib import sha256

BODY = b"Current knowledge article\n"
TAG = '"' + sha256(BODY).hexdigest() + '"'

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        matched = self.headers.get("If-None-Match") == TAG
        self.send_response(304 if matched else 200)
        self.send_header("Cache-Control", "no-cache")
        self.send_header("ETag", TAG)
        if not matched:
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(BODY)))
        self.end_headers()
        if not matched:
            self.wfile.write(BODY)

HTTPServer(("127.0.0.1", 8766), Handler).serve_forever()

سرور را اجرا کنید و آدرس http://127.0.0.1:8766 را باز کنید. ETag پاسخ را در درخواست بعدی با If-None-Match بفرستید؛ مقدار دقیق یکسان باید 304 بدهد. برای توقف Ctrl+C بزنید. این سرور فقط نمونه محلی است.

2. تفکیک HTML و فایل‌های نسخه‌دار در Nginx

ابتدا مسیرها را جدا کنید. این کانفیگ فقط پوشه‌ای را یک‌ساله کش می‌کند که نام فایل آن با محتوا عوض می‌شود؛ مثلاً app.9c1a2e.css. فایل بدون نام نسخه‌دار در مسیر دیگری می‌ماند. root را در سرور آزمایشی با محل واقعی خروجی سایت جایگزین کنید.

# Inside a test server {} block:
root /srv/liyan/public;
etag on;

location ~ \.html$ {
    add_header Cache-Control "no-cache" always;
}

# Only this folder contains content-hashed, never overwritten assets.
location /assets/versioned/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# Unversioned files must not inherit the one-year policy.
location /assets/ {
    add_header Cache-Control "public, max-age=3600";
}

پس از nginx -t و اعمال تنظیمات در محیط آزمایش، با curl -I هدر یک HTML و دو نوع asset را بررسی کنید. این قطعه جایگزین کل کانفیگ نیست؛ locationهای موجود و قواعد پراکسی ممکن است انتخاب مسیر را عوض کنند. فایل جدید را قبل از HTML جدید منتشر کنید و assetهای نسخه قبلی را تا پایان دوره گذار نگه دارید.

3. پاسخ خصوصی و انتخاب no-store یا private

برای اطلاعات حساب، سیاست را مستقل از تصاویر عمومی تعیین کنید. دو گزینه زیر دو قرارداد متفاوت‌اند: پاسخ اول ذخیره نمی‌شود؛ گزینه دوم فقط برای پاسخ کم‌حساس‌تری است که یک دقیقه بازاستفاده در مرورگر برای آن مجاز است. این دو را بی‌دلیل در یک هدر ترکیب نکنید.

# Sensitive account response: do not retain it in HTTP caches.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{"status":"available"}

# A separate, less-sensitive response may permit browser-only reuse:
# Cache-Control: private, max-age=60
# Authentication and authorization still run on the server.

این خروجی HTTP برای توضیح هدرهاست و برنامه احراز هویت نمی‌سازد. no-store اطلاعاتی را که قبلاً در کش بوده‌اند پاک نمی‌کند و سیاست تاریخچه یا حافظه برنامه را نیز به‌تنهایی مدیریت نمی‌کند. آزمون کاربر خارج‌شده و مرورگر مشترک همچنان لازم است.

4. سرور کامل‌تر: GET، HEAD، ETag و Last-Modified

برای تجربه کامل‌تر، سرور کوتاه ابتدای مقاله را متوقف کنید و این نسخه را در cache_server.py ذخیره کنید. این بار HEAD بدنه نمی‌فرستد، مسیر ناشناخته 404 می‌دهد و If-None-Match بر If-Modified-Since مقدم است. اعتبارسنج ضعیف هم برای GET/HEAD پذیرفته می‌شود.

from datetime import datetime, timezone
from email.utils import format_datetime, parsedate_to_datetime
from hashlib import sha256
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlsplit

BODY = b"Current knowledge article\n"
TAG = '"' + sha256(BODY).hexdigest() + '"'
MODIFIED = datetime.now(timezone.utc).replace(microsecond=0)

class Handler(BaseHTTPRequestHandler):
    def do_HEAD(self):
        self.respond(send_body=False)

    def do_GET(self):
        self.respond(send_body=True)

    def respond(self, send_body):
        if urlsplit(self.path).path != "/":
            self.send_error(404)
            return
        tags = self.headers.get("If-None-Match")
        unchanged = False
        if tags is not None:
            # Our server emits only quoted hexadecimal validators.
            candidates = [tag.strip().removeprefix("W/")
                          for tag in tags.split(",")]
            unchanged = "*" in candidates or TAG in candidates
        else:
            modified_since = self.headers.get("If-Modified-Since")
            if modified_since:
                try:
                    since = parsedate_to_datetime(modified_since)
                    if since.tzinfo is not None:
                        unchanged = MODIFIED <= since
                except (ValueError, TypeError, OverflowError):
                    pass
        self.send_response(304 if unchanged else 200)
        self.send_header("Cache-Control", "no-cache")
        self.send_header("ETag", TAG)
        self.send_header("Last-Modified", format_datetime(MODIFIED, usegmt=True))
        if not unchanged:
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(BODY)))
        self.end_headers()
        if send_body and not unchanged:
            self.wfile.write(BODY)

if __name__ == "__main__":
    HTTPServer(("127.0.0.1", 8766), Handler).serve_forever()

با python cache_server.py اجرا کنید. زمان MODIFIED در این مثال زمان ساخت نمایش حافظه‌ای است؛ برای فایل واقعی از زمان تغییر همان فایل استفاده کنید. این سرور آموزشی روی localhost است؛ تنظیم TLS، هم‌زمانی، فشرده‌سازی و پردازش کامل دامنه‌ها وظیفه وب‌سرور یا فریم‌ورک واقعی است.

5. بررسی دستی پاسخ و درخواست شرطی

در ویندوز می‌توانید curl.exe را به جای curl بنویسید تا فرمان با نام دیگری اشتباه نشود. دستورهای ترمینال دوم را پس از اجرای سرور بزنید و placeholder را با مقدار واقعی، همراه گیومه، جایگزین کنید.

# Terminal 1:
python cache_server.py

# Terminal 2: inspect a normal response and its validators.
curl -i http://127.0.0.1:8766/
curl -I http://127.0.0.1:8766/

# Replace the placeholder with the exact quoted ETag from the first response.
curl -i -H 'If-None-Match: "PASTE_THE_HASH_HERE"' http://127.0.0.1:8766/

# A different validator must produce a fresh 200 body.
curl -i -H 'If-None-Match: "different-version"' http://127.0.0.1:8766/

پاسخ اول 200 و دارای بدنه است؛ HEAD نیز 200 می‌شود ولی بدنه ندارد. برچسب یکسان باید 304 بدون بدنه بدهد و برچسب متفاوت 200. تفاوت این مسیرها را در Network مرورگر هم ببینید؛ Disable cache باید برای آزمایش بازاستفاده مرورگر خاموش باشد.

6. آزمون خودکار اعتبارسنجی و تقدم هدرها

این فایل را به نام check_cache.py کنار سرور ذخیره و در ترمینال دوم اجرا کنید. آزمون آخر عمداً برچسب نامنطبق و تاریخ منطبق را کنار هم می‌فرستد تا تقدم If-None-Match واقعاً بررسی شود.

# Run while cache_server.py is listening on port 8766.
import urllib.error
import urllib.request

URL = "http://127.0.0.1:8766/"

def request(method="GET", headers=None):
    req = urllib.request.Request(URL, headers=headers or {}, method=method)
    try:
        with urllib.request.urlopen(req, timeout=2) as response:
            return response.status, dict(response.headers), response.read()
    except urllib.error.HTTPError as response:
        return response.code, dict(response.headers), response.read()

status, headers, body = request()
assert status == 200 and body
etag, modified = headers["ETag"], headers["Last-Modified"]
assert request("HEAD")[2] == b""
assert request(headers={"If-None-Match": etag})[0::2] == (304, b"")
assert request(headers={"If-None-Match": "W/" + etag})[0] == 304
assert request(headers={"If-None-Match": "*"})[0] == 304
assert request(headers={"If-Modified-Since": modified})[0] == 304
assert request(headers={
    "If-None-Match": '"another-version"',
    "If-Modified-Since": modified,
})[0] == 200
print("Cache validation checks passed")

خروجی مورد انتظار Cache validation checks passed است. این آزمون قرارداد سرور آموزشی را می‌سنجد؛ برای سرور واقعی، همین سناریوها را پس از عبور از CDN یا پراکسی نیز اجرا کنید تا هدرها در مسیر تغییر نکرده باشند.

7. Vary و تفاوت نسخه‌های فشرده یا زبانی

هدر Vary فقط وقتی اضافه شود که پاسخ همان URL واقعاً با هدر درخواست تغییر می‌کند. مسیر جدا برای فارسی و انگلیسی، با مذاکره زبان روی یک آدرس یکسان فرق دارد. نمونه سرور پایتون فشرده‌سازی یا مذاکره زبان انجام نمی‌دهد و به این هدرها نیاز ندارد.

# If the same URL actually negotiates compressed representations:
Vary: Accept-Encoding

# If the same URL also negotiates its response language:
Vary: Accept-Encoding, Accept-Language

# Separate language URLs such as /article.html and /article-en.html
# do not need Accept-Language variation just because the site is bilingual.
# Emit representation-appropriate validators when response bytes differ.

اگر پراکسی نسخه gzip تولید می‌کند، اعتبارسنج و کش نسخه فشرده و عادی را با هم بررسی کنید. Vary: User-Agent می‌تواند تعداد کلیدهای کش را بی‌دلیل زیاد کند. در گزارش نهایی، نام مسیر، سیاست، مقدار ETag، نتیجه بازدید دوم و بایت منتقل‌شده را ثبت کنید.

اندازه‌گیری تجربه بارگذاری دوم

یک جدول ساده از مسیر، مالک محتوا، مدت کش و روش ابطال تهیه کنید. برای هر انتشار بررسی کنید HTML به نام فایل‌های جدید اشاره می‌کند و نسخه قبلی فایل‌ها برای صفحات باز کاربران فوراً حذف نمی‌شود. معیار موفقیت کاهش انتقال داده همراه با مشاهده محتوای درست است. عدد سرعت به‌تنهایی کافی نیست؛ صفحه‌ای که سریع اما قدیمی باز می‌شود، مسئله کاربر را حل نکرده است.

چک‌لیست اجرای عملی

  • HTML و فایل‌های نسخه‌دار سیاست جدا داشته باشند.
  • no-cache را با no-store اشتباه نگیرید.
  • پس از تغییر محتوا، آدرس فایل immutable عوض شود.
  • بازدید اول و دوم را همراه با وضعیت کش اندازه بگیرید.

منبع

تدوین و راهنمای عملی: دانشنامه لیان.