Code splitting แบบใหม่ใน Webpack 4

สำหรับคนใช้ Webpack ผมเชื่อว่าหลายๆ คนจะใช้ code splitting อยู่ และ code splitting ที่ง่ายๆ โง่ๆ ตรงไปตรงมาที่สุดที่น่าจะใช้กันก็คือ vendor code split หรือการแยกโค้ดใน node_modules ไปเป็นอีก bundle เนื่องจากว่าโค้ดในนั้นมักจะอัพเดตไม่บ่อยเท่าโค้ดเรา ก็จะช่วยให้แคชได้นานขึ้น

แต่พอถึงยุค Webpack 4 แล้ว เราสามารถทำ Vendor code split ได้มากขึ้น และเผลอๆ จะ split ได้ยิบย่อยลงไปได้อีกด้วยระบบ Chunk graph

ปัญหาของ code split

ก่อนจะไปถึง vendor code split นั้นจะขอเล่าถึง Code split แบบใหม่ใน Webpack 4 ก่อน

ใน Webpack 3 นั้นหากเราทำ async code splitting นั้นเราจะได้ Bundle ประมาณนี้

จากรูปจะเห็นปัญหาว่า หาก entrypoint ไม่ได้ใช้ lodash แต่ทั้ง Overlay และ Settings chunk ใช้ lodash จะทำให้ Webpack เอา lodash ไปใส่ทั้ง 2 bundle user ก็ต้องโหลด lodash หลายรอบ

วิธีแก้ไขในอดีตก็มักจะใช้ vendor code split เพื่อแยก node_modules ออกไป (หรือ CommonsChunkPlugin ที่แยกตามจำนวนครั้งที่ใช้)

หรือถ้าโกงหน่อยก็จะใส่ require('lodash') ไว้ใน entrypoint เพื่อบังคับให้ entrypoint มีการเรียก lodash

แต่ปัญหาก็ยังไม่จบแค่นั้น เพราะพอเราแยก node_modules ออกไปก็จะได้ chunk ใหม่ที่มีทุกมอดูลรวมกันอยู่ ก็กลายเป็นว่าพอเข้าหน้า Overlay ก็ต้องโหลด jQuery มาด้วยถึงจะไม่ได้ใช้ (ในรูปไม่ได้วาดไว้ แต่สมมุติว่ามีหน้าที่ใช้ jQuery หลายหน้า)

ในเว็บ TipMe ก็มีปัญหาแบบนี้เช่นเดียวกันเพราะเว็บเราใช้ jQuery แต่ก็จะมีบาง widget ที่ใช้ React เขียน ก็คงไม่เหมาะที่จะต้องโหลด React ทุกหน้า วิธีที่เราทำอยู่คือทำ DLL bundle 3 ชุด

  • jQuery
  • React
  • Runtime (common ระหว่าง 2 bundle ด้านบน เช่น Polyfill)

เสร็จแล้วเวลา build เราจะมี Gulp filter ที่อ่านไฟล์แล้วดูว่าไฟล์นี้มีคำสั่ง // use React หรือเปล่า ถ้ามีก็จะ link กับ jQuery + React DLL แต่ถ้าไม่มีก็จะ link กับ jQuery อย่างเดียว

วิธีนี้ก็ยังไม่ใช่วิธีที่ดีเท่าไร เพราะ

  • DLL ก็ยังมีมอดูลที่ไม่จำเป็นอยู่
  • ต้องนั่ง list module ที่ใช้ DLL เอง ซึ่งไม่รู้ว่าเราเลือกมาแล้วดีที่สุดหรือเปล่า
  • DLL ทำ Tree shaking กับโค้ดเราไม่ได้ เพราะเวลา build DLL มันไม่อ่านโค้ดเรา
  • Build tooling ซับซ้อน
  • เวลา build ก็ซับซ้อน ต้องรัน Gulp 3 รอบ คือ build runtime, build DLL แล้วถึงจะ build app
  • เวลาใช้ก็ซับซ้อน เพราะที่หน้าต้อง include DLL ให้ถูกว่าหน้านี้ใช้อะไร

All new code splitting

Webpack 4 นั้นจะเปลี่ยนวิธี split ใหม่โดยค่าเริ่มต้นนั้นจะตัด vendor chunk ได้ฉลาดมากขึ้น

นั่นคือ Webpack สามารถสร้าง chunk ที่เป็น common ระหว่างมอดูลได้แล้ว โดยเป็นค่า default เราไม่ต้องทำอะไรเพียงแค่ใช้ Webpack 4 เท่านั้น

แต่จะเห็นว่าชื่อไฟล์นั้นจะเป็น vendor~page-a~page-b ซึ่งพอเราเพิ่มหน้าเข้าไปไฟล์ก็จะเปลี่ยนชื่อ ถ้าต้องการ cache ให้ได้นานๆ นั้นแนะนำให้ตั้งค่า Webpack ดังนี้

{
    optimization: {
        splitChunks: {
            name: false,
        }
    }
}

ซึ่งจะทำให้ chunk ที่ได้นั้นใช้ชื่อเป็น chunk ID แทนที่จะระบุชื่อมอดูลภายใน

Entrypoint splitting

ถึงตรงนี้แล้ว splitting แบบใหม่ก็ยังไม่ดีเท่าของเก่า เพราะมันยังไม่แยก node_modules ออกจาก Entrypoint (หรือถ้ามีหลาย entrypoint มันก็ยังไม่แยก common ออกมา) เพราะค่าเริ่มต้นของ Webpack จะปิดเอาไว้

เหตุผลที่ปิดก็เดาได้ไม่ยากครับ ปกติถ้าเรา split มือ เราจะรู้ชื่อ chunk แล้วเราก็จะเขียน script tag ไปเรียกมา แต่ถ้า Webpack ทำให้ user ก็อาจจะไม่เข้าใจว่าต้องเรียก chunk ที่แยกออกมา ก็เลยปิดไว้เป็นหลัก

ทีนี้เราจะเปิดได้อย่างไร? วิธีการเปิดคือตั้งค่าดังนี้ใน Webpack

{
    optimization: {
        splitChunks: {
            chunks: 'all',
            maxInitialRequests: 5, // default to 2, adjust as needed
        },
        runtimeChunk: 'single', // optional, recommended
    }
}

ซึ่งค่าต่างๆ จะมีผลดังนี้

  • maxInitialRequests จะกำหนดว่าให้ split ได้สูงสุดกี่ chunk
  • runtimeChunk จะแยกส่วน manifest ไปเป็นอีก chunk ซึ่ง manifest จะเปลี่ยนทุกครั้งที่มีการแก้โค้ดไม่ว่าไฟล์ไหนอยู่แล้ว การแยกไปเลยก็จะทำให้ cache ได้ดีขึ้น
  • อื่นๆ สามารถดูได้ตาม docs

จากนั้นเราจะต้องแก้หน้าเว็บให้ใส่ script tag ให้ครบ แต่… ในเมื่อ Webpack generate ชื่อไฟล์มาให้ แล้วเราจะใส่ src ยังไง?

คำตอบคือในไฟล์ Webpack stats จะมีบอกอยู่ว่า Entrypoint นี้ต้องใช้ไฟล์อะไรบ้าง หน้าตาประมาณนี้

{
  "entrypoints": {
    "bootstrap": {
      "chunks": [
        28,
        10,
        11,
        49
      ],
      "assets": [
        "js/runtime~bootstrap.js",
        "js/runtime~bootstrap.js.map",
        "js/c_10.43ba1082.js",
        "js/10.js.map",
        "js/c_11.45f9671c.js",
        "js/11.js.map",
        "js/c_bootstrap.5f882904.js",
        "js/bootstrap.js.map"
      ]
    },
  }
}

(ถ้าทำ CSS Split ด้วย ก็จะได้ชื่อไฟล์ CSS มาใน assets ด้วยเช่นกัน)

สิ่งที่เราต้องทำคือ อ่านไฟล์ stats แล้วเอามาสร้าง HTML

ก่อนอื่นก็แก้ Webpack ให้สร้าง stats มาเสียก่อน โดยใช้ webpack-stats-plugin

{
    plugins: [
        new stats.StatsWriterPlugin({
            filename: 'stats.json',
            fields: ['entrypoints'],
        }),
    ],
}

จากนั้นเวลา build เราจะได้ไฟล์ stats.json ออกมา จากนั้นเราก็จะแก้ให้แอพเราอ่านไฟล์นี้ขึ้นมา

สำหรับ Django นั้นวิธีที่ผมใช้อยู่ก็คือสร้าง Template tag ขึ้นมา

โดยนำไฟล์ webpack.py ไว้ใน folder templatetags และไฟล์ webpack_entrypoint.html ไว้ใน templates จากนั้นเวลาใช้ก็

{% load webpack %}
{% entrypoint "bootstrap" %}

ก็จะได้ script tag ที่ครบถ้วนทันที

<script src="https://static.tipme.in.th/js/runtime~bootstrap.24e2bfeb3c73.js" crossorigin="anonymous"></script>
<script src="https://static.tipme.in.th/js/c_10.43ba1082.2b93f172c643.js" crossorigin="anonymous"></script>
<script src="https://static.tipme.in.th/js/c_11.45f9671c.d9617c7f9295.js" crossorigin="anonymous"></script>
<script src="https://static.tipme.in.th/js/c_bootstrap.5f882904.9c15205b28f3.js" crossorigin="anonymous"></script>

สรุป

Code split แบบใหม่นี้ลดความซับซ้อนไปในหลายๆ อย่างเลยครับ ในขณะที่เพิ่ม performance ให้ดีขึ้น และยิ่งถ้าเปิดให้มัน split entrypoint ได้ก็ยิ่งเพิ่มประสิทธิภาพอีก

Commit ที่ TipMe ปรับมาใช้ Webpack 4 นั้น สถิติคือ 144 additions, 324 deletions ลบ tooling ออกไปได้เยอะมาก

  • ไม่ต้อง mark แล้วว่า entrypoint นี้ใช้ React เพราะ Webpack จะรู้เองแล้วเอา React chunk ใส่มาใน list ให้อัตโนมัติ
  • ไม่ต้องมี webpack config หลายชุดแล้ว build ทีเดียวออกเลย
  • Build target เหลืออันเดียว
  • ใช้ mode: production แทนการเซตเอง

และสุดท้าย Webpack build time จาก 62550ms เหลือ 35162ms หรือลดลง 43%

(แต่ Gulp เราลดมาแค่ 20 วินาที เพราะมีไฟล์ที่ต้องอัพโหลดขึ้น Sentry มากขึ้น)

ลองเล่น Stackdriver Trace บน Django

หลังจาก implement AWS X-Ray ให้ที่บริษัทไป ผมก็เลยว่าอยากจะ implement ระบบคล้ายๆ กันให้ TipMe บ้าง แต่ TipMe อยู่บน Google Cloud ก็เลยจะต้องใช้บริการของเค้าคือ Stackdriver Trace

(Note: ถ้ายังไม่ได้อ่านตอนของ X-Ray แนะนำให้อ่านก่อนเพื่อเข้าใจ concept ของ Tracing ครับ)

Zipkin

สำหรับการใช้งาน Stackdriver Trace นั้นจะแตกต่างกับของ X-Ray ตรงที่ Trace มี API ให้เราเลือกใช้ถึง 3 แบบ คือ

  1. REST API ซึ่งจะเป็น API เฉพาะของ Trace เอง เช่นเดียวกับของ X-Ray
  2. gRPC API เป็น custom API เช่นกัน แต่ตอนนี้ยังไม่มี client library ให้ใช้
  3. Zipkin API ซึ่ง Zipkin เป็นโปรแกรมสำหรับทำ tracing แบบ open source แล้ว Google Cloud นำมาแก้ให้ใช้ gRPC API ของเค้าเป็น backend อีกทีหนึ่ง (จะเรียกว่าเป็น adapter ก็ได้)

เพื่อไม่ให้ vendor lock in เราก็จะเลือกใช้ Zipkin API ฉะนั้นอย่างแรกที่จะต้องทำคือติดตั้ง Zipkin Collector ของเค้าก่อน สำหรับบน Kubernetes ก็ใช้ Deployment ประมาณนี้

apiVersion: v1
kind: ReplicationController
metadata:
  name: zipkin
spec:
  replicas: 1
  selector:
    app: zipkin
  template:
    metadata:
      labels:
        app: zipkin
    spec:
      containers:
      - name: zipkin
        image: gcr.io/stackdriver-trace-docker/zipkin-collector
        ports:
        - containerPort: 9411
        env:
        - name: GOOGLE_APPLICATION_CREDENTIALS
          value: /secrets/service-account
        livenessProbe:
          httpGet:
            path: /health
            port: 9411
          initialDelaySeconds: 120
        readinessProbe:
          httpGet:
            path: /health
            port: 9411
        resources:
          requests:
            memory: "0Mi"
            cpu: "3m"
          limits:
            memory: "256Mi"
        volumeMounts:
        - name: secret
          mountPath: /secrets
          readOnly: true
      volumes:
      - name: secret
        secret:
          secretName: zipkin
---
apiVersion: v1
kind: Service
metadata:
  name: zipkin
  labels:
    app: zipkin
spec:
  selector:
    app: zipkin
  ports:
    - port: 9411
---
apiVersion: v1
kind: Secret
metadata:
  name: zipkin
type: Opaque
data:
  service-account: แปะ service account เป็น base64

เนื่องจากบน Kubernetes เราจะใช้ instance role ไม่ได้ (เพราะไม่ได้เซตสิทธิ์ไว้ตอนเปิด cluster) ก็เลยจะต้องใช้ Service account แทน วิธีการสร้าง Service account ก็คือ

  1. เข้าไปที่ Service Accounts
  2. กด Create service account ด้านบน
  3. กรอก Service account name ตามชอบ และเลือก Role เป็น Cloud Trace > Cloud Trace Agent
  4. ติ๊ก Furnish a new private key และเลือก JSON
  5. กด Create
  6. เมื่อได้ JSON มา ให้เอาไปเข้ารหัส Base64 แล้วแปะใน Secret ด้านบน (cat file.json | base64 -w0)

พอเสร็จแล้วก็ kubectl apply -f zipkin.yaml เป็นอันเรียบร้อย

Integrate กับ Django

ถัดมาเราจะต้องติดตั้ง Tracer ลงในโปรแกรมของเรา ซึ่งถ้าเป็น Node.js แบบที่วงในก็คงจะง่าย แต่พอเป็น Python แล้วก็พบว่าเราคงจะต้องทำเอง -_-!! ซึ่งของที่ผมออกแรงนั่งทำมาก็ปล่อยเป็น open source แล้วครับ ฟีเจอร์หลักๆ ก็คือ

  • Trace request ได้
  • Trace request ออกไปทาง urllib3 (request module) ได้ รวมทั้งจะแปะ X-B3-TraceId ส่งไปให้ด้วย
  • Trace template render time ได้
  • Trace database query ได้ (แต่จะไม่ log parameter ของ query เพื่อความปลอดภัย)

วิธีการติดตั้งก็คือ

  1. เพิ่ม django-zipkin-trace==1.0.0 ลงใน requirements.txt
  2. pip install -r requirements.txt
  3. แก้ไข settings.py ของเราในส่วนของ MIDDLEWARES เติม zipkin_trace.ZipkinMiddleware ไปเป็นอันบนสุด
  4. เพิ่ม ZIPKIN_SERVER='http://zipkin:9411' ลงใน settings.py

ถ้าอยากลองเทส ก็สามารถเทสบนเครื่องได้โดยใช้ Zipkin ของแท้ ดังนี้

  1. Start Zipkin ด้วยคำสั่ง sudo docker run -d -p 9411:9411 openzipkin/zipkin
  2. เช็คว่าสามารถเข้า Zipkin ได้ที่ http://localhost:9411 (ใครใช้ Docker for Mac/Windows อาจจะต้องเข้า IP ของ VM แทน)
  3. แก้ไข ZIPKIN_SERVERS ใน settings.py ให้ชี้ไปที่ http://localhost:9411 แทน
  4. ลองเข้าเว็บ Django ของเราแล้วรีเฟรชสองสามที
  5. ใน Zipkin กด Find Traces น่าจะปรากฏ Trace ขึ้นมา

(Note: py-zipkin จะสะสม trace ให้ครบ 100 อันก่อนส่ง ต้องเปิด DEBUG=true ถึงจะส่ง trace ทันที)

Trace ที่ได้จะหน้าตาประมาณนี้

สำหรับถ้าจะใช้บน Google Cloud Trace จะหน้าตาแบบนี้

เปรียบเทียบกับ X-Ray

จากที่ใช้งานมาทั้ง 2 platform แล้ว ผมพบว่าสิ่งที่ X-Ray จะนำเสนอคือความสัมพันธ์ระหว่าง service และ error ระหว่างทำงานมากกว่า เพราะมุมมองการดู Trace นั้นสามารถใช้งานได้สะดวกมาก และมีแผนภาพระหว่าง service ด้วย

สำหรับทางฝั่ง Stackdriver Trace นั้น จุดขายคือ Auto analysis ครับ

พอเราใช้ไปสักสัปดาห์นึงแล้ว URL ไหนที่ hit บ่อยที่สุด top 3 จะถูกนำมาเทียบกับสัปดาห์ที่แล้วโดยอัตโนมัติ เพื่อให้เราเห็นว่าที่เราทำไปในสัปดาห์นี้นั้นทำให้เว็บเร็วขึ้นหรือช้าลงหรือเปล่า และสามารถกดดูตัวอย่าง Trace ใน percentile ต่างๆ ได้ด้วย (ถ้าใช้ X-Ray จะต้องไปลากในกราฟเอาเอง)

โดยส่วนตัวผมชอบแบบ X-Ray มากกว่า เพราะอยากรู้เรื่อง error เป็นหลักมากกว่าซึ่งใน Trace มันดูไม่ได้ (เข้าใจว่าน่าจะให้ไปใช้ Stackdriver Error Reporting แทน แต่ผมใช้ Sentry อยู่แล้ว)

สุดท้าย Stackdriver Trace ฟรี 100% ครับ ไม่มีข้อจำกัดใดๆ ทั้งสิ้น ฉะนั้นที่ TipMe ตอนนี้ก็จะยิงทุก request เข้าหมดทุกอันได้เลย