在CircleCI工作流中通过Go绞杀者代理实现PHP看板应用的无缝迁移


团队接手了一个有数年历史的PHP看板(Kanban)项目。其核心问题在于,所有状态更新都依赖于前端定时发起的AJAX轮询。在看板卡片数量增多、团队协作频繁时,服务器压力巨大,且用户体验延迟明显。技术债的另一个体现是,整个系统是一个巨大的单体应用,任何微小的改动都需要完整的回归测试和部署,效率低下。彻底重写风险太高,周期也无法接受。我们最终选择的方案是“绞杀者模式(Strangler Fig Pattern)”,逐步用Go重写核心的高并发模块,并利用CircleCI保障这个混合架构的稳定交付。

第一步是引入一个代理层。这个代理将是所有流量的入口,负责将请求路由到新的Go服务或旧的PHP单体。在真实项目中,这个代理本身必须是高性能且高可用的。Go的net/http/httputil包提供了构建反向代理的完美工具。

// file: cmd/proxy/main.go

package main

import (
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"strings"
	"time"
)

// newProxy a helper function to create a new reverse proxy.
func newProxy(targetHost string) (*httputil.ReverseProxy, error) {
	url, err := url.Parse(targetHost)
	if err != nil {
		return nil, err
	}
	proxy := httputil.NewSingleHostReverseProxy(url)

	// 自定义Director,可以在这里修改请求头等
	originalDirector := proxy.Director
	proxy.Director = func(req *http.Request) {
		originalDirector(req)
		// 伪造一些请求头,让后端知道请求来自代理
		req.Header.Set("X-Proxy", "StranglerFigProxy")
		log.Printf("Routing request for %s to %s", req.URL.Path, targetHost)
	}

	// 自定义错误处理
	proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
		log.Printf("Proxy error: %v", err)
		rw.WriteHeader(http.StatusBadGateway)
	}
	
	return proxy, nil
}

// route decides which service to route the request to.
func route(r *http.Request, phpTarget, goTarget *httputil.ReverseProxy) {
	// 我们的绞杀策略:
	// 1. 新的、高性能的实时API路由到Go服务
	// 2. 所有其他旧API和页面请求路由到PHP单体
	if strings.HasPrefix(r.URL.Path, "/api/v2/realtime") || strings.HasPrefix(r.URL.Path, "/api/v2/cards") {
		log.Printf("Routing to Go service for path: %s", r.URL.Path)
		goTarget.ServeHTTP(w, r)
	} else {
		log.Printf("Routing to legacy PHP service for path: %s", r.URL.Path)
		phpTarget.ServeHTTP(w, r)
	}
}

func main() {
	// 从环境变量获取后端服务地址,这是生产实践的基础
	phpServiceHost := os.Getenv("PHP_SERVICE_HOST")
	if phpServiceHost == "" {
		phpServiceHost = "http://localhost:8000" // 本地开发默认值
	}

	goServiceHost := os.Getenv("GO_SERVICE_HOST")
	if goServiceHost == "" {
		goServiceHost = "http://localhost:8081" // 本地开发默认值
	}

	log.Printf("Proxy started. PHP Target: %s, Go Target: %s", phpServiceHost, goServiceHost)

	phpProxy, err := newProxy(phpServiceHost)
	if err != nil {
		log.Fatalf("Failed to create PHP proxy: %v", err)
	}

	goProxy, err := newProxy(goServiceHost)
	if err != nil {
		log.Fatalf("Failed to create Go proxy: %v", err)
	}

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		route(r, phpProxy, goProxy)
	})

	server := &http.Server{
		Addr:         ":8080",
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		IdleTimeout:  120 * time.Second,
	}

	if err := server.ListenAndServe(); err != nil {
		log.Fatalf("Proxy server failed to start: %v", err)
	}
}

这个代理非常基础,但包含了生产环境的关键要素:通过环境变量配置后端地址、自定义Director来修改请求、以及基础的日志和错误处理。路由逻辑目前是基于URL前缀的硬编码,这在迁移初期是可接受的。

我们的第一个目标是替换AJAX轮询。我们将在Go服务中实现一个WebSocket端点,用于实时推送看板的变更。但这里有一个核心问题:当用户通过旧的PHP API(例如,POST /api/v1/cards/1/update)修改了一张卡片,新的Go WebSocket服务如何得知这个变更并通知所有客户端?

一个常见的错误是让Go服务去轮询PHP应用的数据库。这只是把压力从客户端转移到了内部网络,没有解决根本问题。正确的做法是引入一个轻量级的消息中间件,如Redis Pub/Sub。当PHP应用完成数据库写入后,它会向一个Redis频道发布一条消息。Go服务则订阅这个频道。

这是PHP端修改后的控制器逻辑(以Laravel框架为例):

// file: app/Http/Controllers/CardController.php (Legacy PHP App)

namespace App\Http\Controllers;

use App\Models\Card;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Log;

class CardController extends Controller
{
    public function update(Request $request, $id)
    {
        // ... 输入验证逻辑 ...
        
        $card = Card::findOrFail($id);
        
        // 假设这里是复杂的业务逻辑和数据库更新
        // $card->title = $request->input('title');
        // $card->description = $request->input('description');
        // $card->save();

        try {
            // 这是关键步骤:在数据库事务成功后发布消息
            // 消息体应该包含足够的信息,让Go服务能够构建出推送内容
            $payload = json_encode([
                'event_type' => 'card_updated',
                'timestamp' => time(),
                'data' => [
                    'card_id' => $card->id,
                    'board_id' => $card->board_id,
                    'column_id' => $card->column_id,
                    // ... 其他需要实时同步的字段
                ]
            ]);

            // 'kanban_events' 是我们约定的频道名
            Redis::publish('kanban_events', $payload);

        } catch (\Exception $e) {
            // 这里的坑在于,如果Redis发布失败,前端将不会收到实时更新
            // 生产环境中需要有重试机制或备用通知方案
            Log::error('Failed to publish card update event to Redis: ' . $e->getMessage());
        }

        return response()->json($card);
    }
}

现在,Go服务需要实现WebSocket服务器并订阅Redis频道。我们使用gorilla/websocket库来处理WebSocket连接,并使用go-redis/redis库来与Redis交互。

// file: internal/realtime/hub.go (New Go Service)

package realtime

import (
	"context"
	"encoding/json"
	"log"
	"sync"

	"github.com/go-redis/redis/v8"
	"github.com/gorilla/websocket"
)

// Event represents a message received from Redis.
type Event struct {
	EventType string          `json:"event_type"`
	Timestamp int64           `json:"timestamp"`
	Data      json.RawMessage `json:"data"`
}

// Hub maintains the set of active clients and broadcasts messages to the clients.
type Hub struct {
	clients    map[*websocket.Conn]bool
	broadcast  chan []byte
	register   chan *websocket.Conn
	unregister chan *websocket.Conn
	mu         sync.Mutex
	redisSub   *redis.PubSub
	ctx        context.Context
}

func NewHub(ctx context.Context, rdb *redis.Client) *Hub {
	hub := &Hub{
		broadcast:  make(chan []byte),
		register:   make(chan *websocket.Conn),
		unregister: make(chan *websocket.Conn),
		clients:    make(map[*websocket.Conn]bool),
		redisSub:   rdb.Subscribe(ctx, "kanban_events"),
		ctx:        ctx,
	}
	// 确保在创建时就检查订阅是否成功
	if _, err := hub.redisSub.Receive(ctx); err != nil {
		log.Fatalf("Failed to subscribe to Redis channel 'kanban_events': %v", err)
	}
	return hub
}

func (h *Hub) Run() {
	go h.listenToRedis()

	for {
		select {
		case client := <-h.register:
			h.mu.Lock()
			h.clients[client] = true
			h.mu.Unlock()
			log.Println("Client registered")
		case client := <-h.unregister:
			h.mu.Lock()
			if _, ok := h.clients[client]; ok {
				delete(h.clients, client)
				client.Close()
			}
			h.mu.Unlock()
			log.Println("Client unregistered")
		case message := <-h.broadcast:
			h.mu.Lock()
			for client := range h.clients {
				err := client.WriteMessage(websocket.TextMessage, message)
				if err != nil {
					log.Printf("Error writing to client: %v", err)
					client.Close()
					delete(h.clients, client)
				}
			}
			h.mu.Unlock()
		}
	}
}

// listenToRedis listens for messages on the subscribed Redis channel.
func (h *Hub) listenToRedis() {
	ch := h.redisSub.Channel()
	for {
		select {
		case <-h.ctx.Done():
			log.Println("Context cancelled, stopping Redis listener.")
			h.redisSub.Close()
			return
		case msg := <-ch:
			log.Printf("Received message from Redis: %s", msg.Payload)
			// 直接将从Redis收到的消息转发给广播channel
			h.broadcast <- []byte(msg.Payload)
		}
	}
}

这个Hub结构体是实时通信的核心。它管理所有连接的WebSocket客户端,并有一个独立的goroutine (listenToRedis) 专门监听Redis消息,一旦收到消息就将其广播给所有客户端。这种设计将外部事件源(Redis)和客户端通信(WebSocket)解耦。前端现在可以连接到Go服务的/api/v2/realtime端点,取代旧的轮询逻辑。这就是一个典型的Headless UI架构:前端UI不关心后端是PHP还是Go,它只消费统一的API和事件流。

接下来,我们需要一个健壮的CI/CD流程来部署这个混合系统。CircleCI的Workflow功能非常适合这种多组件项目。

graph TD
    A(Start) --> B{checkout};
    B --> C[build_and_test_go];
    B --> D[build_and_test_php];
    C --> E{docker_build_push_go};
    D --> F{docker_build_push_php};
    E --> G[deploy_to_staging];
    F --> G[deploy_to_staging];
    G --> H{approve_production_deploy};
    H --> I[deploy_to_production];

这是我们的CircleCI工作流示意图。Go服务和PHP应用可以并行构建和测试,然后分别构建Docker镜像,最后统一部署。

下面是.circleci/config.yml的一个关键部分,展示了如何定义这个工作流:

# file: .circleci/config.yml

version: 2.1

orbs:
  docker: circleci/[email protected]
  aws-cli: circleci/aws-[email protected]
  # ... 其他可能用到的orbs

jobs:
  build_and_test_go:
    docker:
      - image: cimg/go:1.19
    working_directory: ~/repo/go-service
    steps:
      - checkout:
          path: ~/repo
      - run: go mod download
      - run:
          name: Run Go unit tests
          command: go test -v ./...

  build_and_test_php:
    docker:
      - image: cimg/php:8.1-browsers
      - image: cimg/redis:6.2 # Service container for tests
    working_directory: ~/repo/php-monolith
    steps:
      - checkout:
          path: ~/repo
      - run:
          name: Install PHP dependencies
          command: composer install -n --prefer-dist
      - run:
          name: Run PHPUnit tests
          command: ./vendor/bin/phpunit

  # ... Docker构建和推送的Job(此处省略) ...

  deploy:
    docker:
      - image: cimg/base:stable
    parameters:
      environment:
        type: string
        default: "staging"
    steps:
      # 这里使用一个脚本来部署,实际项目中可能是kubectl apply, aws ecs update-service等
      - run:
          name: Deploy to << parameters.environment >>
          command: |
            echo "Deploying both services to << parameters.environment >>"
            ./scripts/deploy.sh << parameters.environment >>

workflows:
  build_test_deploy:
    jobs:
      - build_and_test_go
      - build_and_test_php
      # 假设有docker_build_push_go和docker_build_push_php两个job
      # - docker_build_push_go:
      #     requires:
      #       - build_and_test_go
      # - docker_build_push_php:
      #     requires:
      #       - build_and_test_php
      - deploy:
          name: deploy_staging
          environment: "staging"
          # requires:
          #   - docker_build_push_go
          #   - docker_build_push_php
      - hold_for_prod:
          type: approval
          requires:
            - deploy_staging
      - deploy:
          name: deploy_production
          environment: "production"
          requires:
            - hold_for_prod

这个配置文件定义了并行的测试任务和一个参数化的部署任务。通过approval类型的job,我们可以在部署到生产环境前进行手动确认,这是生产发布的标准实践。

随着迁移的深入,我们将开始把写操作也迁移到Go服务。例如,移动卡片这个高频操作。前端会开始调用新的API端点 POST /api/v2/cards/{id}/move。Go服务将直接处理这个请求。

// file: internal/handlers/card_handler.go (New Go Service)

package handlers

import (
	"database/sql"
	"encoding/json"
	"log"
	"net/http"
	"time"

	"github.com/go-redis/redis/v8"
	"github.com/gorilla/mux"
)

type CardMover struct {
	DB  *sql.DB
	RDB *redis.Client
}

type MoveCardRequest struct {
	NewColumnID int `json:"new_column_id"`
	NewPosition int `json:"new_position"`
}

// MoveCard handles the request to move a card.
func (cm *CardMover) MoveCard(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	cardID := vars["id"]

	var reqBody MoveCardRequest
	if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}
	
	// 在一个事务中完成数据库更新,这是保证数据一致性的关键
	tx, err := cm.DB.Begin()
	if err != nil {
		log.Printf("Failed to begin transaction: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer tx.Rollback() // 如果后续操作失败,回滚事务

	// ...
	// 复杂的数据库操作:更新卡片的位置,可能还需要调整同列中其他卡片的位置
	// _, err = tx.ExecContext(r.Context(), "UPDATE cards SET column_id = $1, position = $2 WHERE id = $3", reqBody.NewColumnID, reqBody.NewPosition, cardID)
	// if err != nil { ... }
	// ...

	if err := tx.Commit(); err != nil {
		log.Printf("Failed to commit transaction: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// 事务成功后,发布事件到Redis
	// 现在事件的源头是Go服务,而不是PHP
	eventPayload := map[string]interface{}{
		"event_type": "card_moved",
		"timestamp":  time.Now().Unix(),
		"data": map[string]interface{}{
			"card_id":       cardID,
			"new_column_id": reqBody.NewColumnID,
			"new_position":  reqBody.NewPosition,
		},
	}
	payloadBytes, _ := json.Marshal(eventPayload)
	
	if err := cm.RDB.Publish(r.Context(), "kanban_events", payloadBytes).Err(); err != nil {
		// 这里的坑和PHP端一样:发布失败怎么办?
		// 至少需要记录严重错误日志
		log.Printf("CRITICAL: Failed to publish card move event for card %s", cardID)
	}

	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}

现在,移动卡片的整个逻辑,从API接收、数据库操作到事件发布,都由Go服务闭环完成。PHP单体完全不再参与这个过程。代理的路由规则确保了这一点。这就是绞杀者模式的威力:一次一个功能,用新的实现替换旧的实现,整个过程对用户是透明的。

当前方案引入了额外的运维复杂性:需要同时维护Go和PHP两个技术栈的运行时,并且增加了一个Redis作为消息总线。这个代理层本身也需要做到高可用,否则会成为整个系统的单点故障。数据同步的可靠性完全依赖于Redis Pub/Sub,对于金融等一致性要求极高的场景,可能需要引入更可靠的消息队列或分布式事务方案。

未来的迭代路径非常清晰:持续识别PHP单体中的性能瓶颈或业务变更频繁的模块,逐一在Go中实现并替换。当所有核心功能都被迁移到Go服务后,PHP单体就可以被彻底移除,代理层也可以根据情况简化或演变为正式的API网关。这个过程是漫长的,但每一步都为系统带来了切实的价值,并有效控制了重构风险。


  目录