首頁 > web前端 > js教程 > 主體

將使用者管理從內部轉移到產品:我們為什麼這樣做以及我們學到了什麼

PHPz
發布: 2024-08-13 06:40:09
原創
414 人瀏覽過

目錄

  • TL;DR
  • 我們的主要目標
  • 研究與評估
  • 實作
  • JWT 策略與 Clerk 策略
  • Clerk 與 Novu 之間同步
    • 同步使用者和組織
    • Clerk 與 Novu 中儲存的內容
  • 注入企業版供應商
    • AuthService 與 AuthModule - 動態注入
    • 儲存庫 - 使用者、組織、成員
    • 控制器
    • 此方法的問題
  • 端點修改
  • 需要考慮與避免的要點
  • 團隊焦點
  • 總結
  • 事後獎勵積分

這篇文章是 Novu 帶給您的

Moving User Management from In-House to a Product: Why We Did It and What We Learned 新總部 / 新

開源通知平台。可嵌入的通知中心、電子郵件、推播和 Slack 整合。

Moving User Management from In-House to a Product: Why We Did It and What We Learned

Moving User Management from In-House to a Product: Why We Did It and What We Learned Moving User Management from In-House to a Product: Why We Did It and What We Learned Moving User Management from In-House to a Product: Why We Did It and What We Learned

面向開發人員的開源通知基礎架構

使用單一 API 管理多通路通知的終極服務


探索文件 »

報告錯誤 · 請求功能 · 加入我們的不和諧 · 路線圖 · X · 通知目錄

⭐️ 為什麼選擇 Novu?

Novu 提供了統一的 API,可以輕鬆地透過多種管道發送通知,包括應用程式內、推播、電子郵件、簡訊和聊天。 借助 Novu,您可以建立自訂工作流程並為每個管道定義條件,確保以最有效的方式傳遞您的通知。

✨ 特點

  • ?適用於所有訊息傳遞提供者的單一 API(應用程式內、電子郵件、簡訊、推播、聊天)
  • ?完全託管的 GitOps 流程,從 CI 部署
  • ?使用 Zod 或 JSON Schema 定義工作流程和步驟驗證
  • ? React Email/Maizzle/MJML 整合
  • ?配備CMS,用於高階佈局和設計管理
  • ?在單一儀表板中調試和分析多通道訊息
  • ?可嵌入的通知中心...
在 GitHub 上查看

長話短說

Novu 將Clerk 實作為使用者管理解決方案(驗證基礎架構),為提供SAML 單一登入(SSO) 功能、Google 和GitHub 作為OAuth 提供者、多重身分驗證、基於角色的帳戶控制(RBAC)奠定了基礎),等等。

一位名叫 Adam 的開發人員在平台工程師 Denis 的大力協助下實現了它。


Moving User Management from In-House to a Product: Why We Did It and What We Learned

像大多數專案一樣,這個專案是從積壓工作開始的。在此之前,已有數十名客戶提出要求,並在我們的路線圖中大力支持了該請求。

作為通知基礎設施解決方案,我們的應用程式和架構的一部分涉及透過註冊、登入和會話日誌來管理用戶,以使用戶能夠邀請團隊成員加入組織並管理每個角色的存取權限。

這一切都是關於優先事項的。 Novu 的核心重點是解決與通知和訊息管理相關的所有問題,這樣我們的用戶就不必這樣做。因此,我們將整個週期花在為建立和管理通知工作流程提供最佳體驗、幫助開發人員保護他們的時間以及平滑產品和行銷團隊之間的協作。
有效的使用者管理不屬於我們致力於的「核心價值」。

就像我們希望您將工程通知的負擔交給我們的專業知識一樣,我們也將工程有效用戶管理的負擔交給了文員的專業知識。

不用說,我們的團隊從第一天起就基於定制和精心設計的架構在內部構建了出色的身份驗證和授權基礎設施。

隨著我們的升級,我們更加專注於完善通知開發體驗。

我們希望開發人員和工程師避免重新發明輪子並讓Novu 處理通知,就像我們選擇在產品的其他方面利用經過驗證、測試和領先的解決方案一樣:用於資料庫的MongoDB、用於支付的Stripe,以及現在文員進行使用者管理。我們言行一致。


我們的首要目標

為我們的使用者創造安全且易於使用的體驗。

在概述該專案的初稿時,它可能顯得簡短而直接,甚至可能給人一種可以在周末完成的印象。

初步草稿清單:

  • OAuth 提供者(GitHub、Google)
  • SAML SSO
  • 安全會話管理
  • RBAC
  • 魔法連結驗證

請注意,如果初稿沒有更改,則該項目尚未收到足夠的回饋和輸入。自然,名單就更長了。

實際清單:

  • 使用使用者憑證註冊
  • 與 OAuth 提供者(Github、Google)註冊
  • 使用使用者憑證登入
  • 使用 OAuth 提供者(Github、Google)登入
  • 使用 SSO (SAML) 登入
  • 從 Novu CLI 登入
  • 從 Vercel Marketplace 登入/註冊
  • 建立組織
  • 組織管理
  • 使用者管理(更新使用者資訊、憑證等......)
  • MFA/2FA(透過簡訊/電子郵件的 OTP、TOTP、金鑰、生物辨識等)
  • 邀請函
  • RBAC:兩位角色管理員和編輯者
    • admin = 管理員可以存取網路平台上的任何頁面並與之互動(因此,包括團隊成員和設定)
    • 編輯=編輯角色仍是「主要內容經理」(又稱產品經理或行銷經理)

研究與評估

確定專案範圍後,下一步是進行研究並評估實現預期結果所需的資源。

這個過程包括:

  • 對產品的當前狀態和每一層都有非常清晰的了解:

    • 依賴關係
    • 端點
    • 建築
    • 客戶端層元件與表示(前端)
    • 測試

    還有更多。

  • 概述遷移規範(保留在內部且應被阻止的內容)

  • 向後相容性

  • 嘗試從以前的同事那裡找到類似項目的參考,並從他們的流程和建議中學習

  • 嘗試尋找開源解決方案

  • 尋找是否有任何供應商(第三方解決方案)並進行比較。

還有更多。

在另一篇部落格文章中,我們將探討如何評估和比較作為服務/基礎設施即服務公司的第三方解決方案(或產品)。

研究不足或評估不準確通常會導致技術債務和未來的資源損失,例如添加附加功能和維護時的工程時間,這需要重構整個事物。因此,尋找每個選項的隱藏成本。

經驗豐富的團隊領導知道如何評估每個選項的投資報酬率 (ROI),這有助於他們做出最佳的業務決策。

這正是我們最終得到 Clerk 的原因。他們的解決方案涵蓋了我們的大多數用例,從業務角度來看,實施它們來管理使用者和組織層的投資報酬率是有意義的。


執行

Novu 服務包含許多微服務和麵向,例如:

  • 通知管道(簡訊、電子郵件、應用程式內、推播、聊天等..),
  • 通知編排(跨裝置同步、摘要引擎、延遲、時區感知等..)
  • 通知可觀察性(調試、見解等)
  • 通知內容管理(編輯器、品牌、版面、翻譯、變數管理等..)
  • 最終使用者管理(使用者首選項、訂閱者、主題、細分、訂閱管理等..)
  • 帳戶管理(SSO、角色為基礎的存取控制、多租戶、計費等...)

下圖展示了 Novu 的 API 結構的簡化版本,僅關注在實現 Clerk 之前對 Novu 使用者和組織的身份驗證和授權。

Moving User Management from In-House to a Product: Why We Did It and What We Learned

我們使用 MongoDB 來儲存 Novu 所需的所有數據,每個使用者、組織、租戶、訂閱者、主題……簡而言之,一切。

因為Clerk有自己的資料庫來管理用戶,所以我們需要非常仔細且精確地處理資料庫之間的遷移和同步。


JWT 策略與 Clerk 策略

我們需要確保的主要事情之一是 UserSessionData 物件不會更改,以便在使用 Novu 時不會中斷使用者的會話。它應該保持兼容。

在這裡您可以看到 jwt.stratgy.ts 檔案範例:

//jwt.stratgy.ts
import type http from 'http';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ApiAuthSchemeEnum, HttpRequestHeaderKeysEnum, UserSessionData } from '@novu/shared';
import { AuthService, Instrument } from '@novu/application-generic';
import { EnvironmentRepository } from '@novu/dal';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService, private environmentRepository: EnvironmentRepository) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_SECRET,
      passReqToCallback: true,
    });
  }
  @Instrument()
  async validate(req: http.IncomingMessage, session: UserSessionData) {
    // Set the scheme to Bearer, meaning the user is authenticated via a JWT coming from Dashboard
    session.scheme = ApiAuthSchemeEnum.BEARER;

    const user = await this.authService.validateUser(session);
    if (!user) {
      throw new UnauthorizedException();
    }

    await this.resolveEnvironmentId(req, session);

    return session;
  }

  @Instrument()
  async resolveEnvironmentId(req: http.IncomingMessage, session: UserSessionData) {
    // Fetch the environmentId from the request header
    const environmentIdFromHeader =
      (req.headers[HttpRequestHeaderKeysEnum.NOVU_ENVIRONMENT_ID.toLowerCase()] as string) || '';

    /*
     * Ensure backwards compatibility with existing JWTs that contain environmentId
     * or cached SPA versions of Dashboard as there is no guarantee all current users
     * will have environmentId in localStorage instantly after the deployment.
     */
    const environmentIdFromLegacyAuthToken = session.environmentId;

    let currentEnvironmentId = '';

    if (environmentIdFromLegacyAuthToken) {
      currentEnvironmentId = environmentIdFromLegacyAuthToken;
    } else {
      const environments = await this.environmentRepository.findOrganizationEnvironments(session.organizationId);
      const environmentIds = environments.map((env) => env._id);
      const developmentEnvironmentId = environments.find((env) => env.name === 'Development')?._id || '';

      currentEnvironmentId = developmentEnvironmentId;

      if (environmentIds.includes(environmentIdFromHeader)) {
        currentEnvironmentId = environmentIdFromHeader;
      }
    }

    session.environmentId = currentEnvironmentId;
  }
}
登入後複製

Moving User Management from In-House to a Product: Why We Did It and What We Learned

為了保持與應用程式其餘部分的兼容性,我們需要將 JWT 有效負載從 Clerk 轉換為先前存在的 JWT 格式。

我們就是這樣做的:

async validate(payload: ClerkJwtPayload): Promise<IJwtClaims> {
  const jwtClaims: IJwtClaims = {
    // first time its clerk_id, after sync its novu internal id
    _id: payload.externalId || payload._id,
    firstName: payload.firstName,
    lastName: payload.lastName,
    email: payload.email,
    profilePicture: payload.profilePicture,
    // first time its clerk id, after sync its novu internal id
    organizationId: payload.externalOrgId || payload.org_id,
    environmentId: payload.environmentId,
    roles: payload.org_role ? [payload.org_role.replace('org:', '')] : [],
    exp: payload.exp,
  };

  return jwtClaims;
}
登入後複製

在這裡您可以看到 clerk.strategy.ts 檔案範例:

import type http from 'http';
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import {
  ApiAuthSchemeEnum,
  ClerkJwtPayload,
  HttpRequestHeaderKeysEnum,
  PassportStrategyEnum,
  UserSessionData,
} from '@novu/shared';
import { EnvironmentRepository, EnvironmentEntity } from '@novu/dal';
import { LinkEntitiesService } from '../services/link-entities.service';

@Injectable()
export class ClerkStrategy extends PassportStrategy(Strategy, PassportStrategyEnum.JWT_CLERK) {
  constructor(private environmentRepository: EnvironmentRepository, private linkEntitiesService: LinkEntitiesService) {
    super({
      // ...configuration details
    });
  }

  async validate(req: http.IncomingMessage, payload: ClerkJwtPayload) {
    const { internalUserId, internalOrgId } = await this.linkEntitiesService.linkInternalExternalEntities(req, payload);

    const session: UserSessionData = {
      _id: internalUserId,
      firstName: payload.firstName,
      lastName: payload.lastName,
      email: payload.email,
      profilePicture: payload.profilePicture,
      organizationId: internalOrgId,
      roles: payload.org_role ? [payload.org_role.replace('org:', '')] : [],
      exp: payload.exp,
      iss: payload.iss,
      scheme: ApiAuthSchemeEnum.BEARER,
      environmentId: undefined,
    };

    await this.resolveEnvironmentId(req, session);

    return session;
  }

  // Other functions...
}

登入後複製

Moving User Management from In-House to a Product: Why We Did It and What We Learned


Clerk 與 Novu 之間同步

雖然目標是理想情況下僅使用Clerk 來創建和檢索用戶、組織等,但不幸的是,由於需要以高性能的方式存儲和查詢有關用戶和組織的一些元數據,因此這並不完全可能。

以下是 Novu 組織儲存庫中的方法範例:

  async findPartnerConfigurationDetails(organizationId: string, userId: string, configurationId: string) {
    const organizationIds = await this.getUsersMembersOrganizationIds(userId);

    return await this.find(
      {
        _id: { $in: organizationIds },
        'partnerConfigurations.configurationId': configurationId,
      },
      { 'partnerConfigurations.$': 1 }
    );
  }
登入後複製

此方法使用各種 MongoDB 特定結構來過濾文件 - 使用 Clerk 無法以高效能方式重現,因為這不是用於此類查詢的資料庫。

我們能做的就是將這些關於組織的元資料儲存在 MongoDB 組織集合中,並使用 externalId 將集合與 Clerk 資料庫連結/同步。

Moving User Management from In-House to a Product: Why We Did It and What We Learned

現在我們可以根據需要結合 Clerk 和 MongoDB 來查詢元資料。

async findPartnerConfigurationDetails(
  organizationId: string,
  userId: string,
  configurationId: string
): Promise<OrganizationEntity[]> {
  const clerkOrganizations = await this.getUsersMembersOrganizations(userId);

  return await this.communityOrganizationRepository.find(
    {
      _id: { $in: clerkOrganizations.map((org) => org.id) },
      'partnerConfigurations.configurationId': configurationId,
    },
    { 'partnerConfigurations.$': 1 }
  );
}

private async getUsersMembersOrganizations(userId: string): Promise<Organization[]> {
  const userOrgMemberships = await this.clerkClient.users.getOrganizationMembershipList({
    userId,
  });

  return userOrgMemberships.data.map((membership) => membership.organization);
}
登入後複製

透過呼叫 getUsersMembersOrganizations,findPartnerConfigurationDetails 取得必要的組織數據,以在communityOrganizationRepository 上執行過濾搜索,確保僅傳回相關配置。

我們只需要在 Clerk 和 Novu 之間同步使用者和組織,組織成員不需要同步。


同步使用者和組織

資料庫 ID 同步有兩種方法:

  • middleware - any endpoint in API will sync the IDs if it detects that JWT doesn’t yet contain an internal ID.
  • webhook - as soon as the user/org is registered in Clerk, Clerk calls Novu’s API webhook, and we sync it.

Moving User Management from In-House to a Product: Why We Did It and What We Learned

Here is the flow we had in mind:

  1. A user creates a new account via frontend using the Clerk component
  2. Gets a new JWT containing Clerk user-id
  3. Any request that hits the API triggers the syncing process (given it hasn’t yet happened)
  4. A new user is created in Novu’s MongoDB containing the Clerk’s externalId
  5. Clerk user object gets updated with Novu internal object id (saved as externalId in Clerk)
  6. The new token returned from Clerk now contains an externalId that is equal to Novu's internal user ID.
  7. In the Clerk strategy in validate() function on API - we set _id to equal to externalId so it is compatible with the rest of the app.

Note
In the application, we always expect Novu’s internal id on input and we always return internal id on output - its important for the application to work as is without major changes to the rest of the code.
API expects internal _id everywhere and it needs to be MongoDB ObjectID type, because it parses this user id back to ObjectID e.g. when creating new environment or any other entity which needs reference to user.

The same logic applies to organizations; just the endpoint is different.


What is stored in Clerk vs Novu

Users

For the users, we store everything in Clerk. All the properties are mostly just simple key/value pairs and we don’t need any advanced filtering on them, therefore they can be retrieved and updated directly in Clerk.

In internal MongoDB, we store just the user internal and external ids.

The original Novu user properties are stored in Clerk’s publicMetadata :

export type UserPublicMetadata = {
  profilePicture?: string | null;
  showOnBoardingTour?: number;
};
登入後複製

There are also many other attributes coming from Clerk which can be set on the user.

Organizations

For the organizations, we store everything in Clerk except for apiServiceLevel, partnerConfigurations, and branding since they are “native” to Clerk and we update those attributes directly there via frontend components and so we don’t need to sync with our internal DB after we change organization name or logo via Clerk component.

Moving User Management from In-House to a Product: Why We Did It and What We Learned


Injection of Enterprise Edition providers

The goal here was to replace the community (open source) implementation with Clerk while being minimally invasive to the application and to keep the Clerk implementation in a separate package.

This means we need to keep the changed providers (OrganizationRepository, AuthService…) on the same place with the same name so we don’t break the imports all over the place, but we need to change their body to be different based on feature flag.

The other option would be to change all of these providers in the 100+ of files and then import the EE(enterprise edition) package everywhere, which is probably not a good idea.

This turned out to be quite challenging due to the fact that users, organization and members are relatively deeply integrated to the application itself, referenced in a lot of places and they’re also tied to MongoDB specifics such as ObjectID or queries (create, update, findOne …).

The idea is to provide different implementation using NestJS dynamic custom providers where we are able to inject different class/service on compile time based on the enterprise feature flag.

This is the most promising solution we found while keeping the rest of the app mostly untouched, there are some drawbacks explained later.


AuthService & AuthModule - dynamic injection

Moving User Management from In-House to a Product: Why We Did It and What We Learned

We have two implementations of AuthService - community and enterprise one (in private package), we inject one of those as AUTH_SERVICE provider.

We need to however have a common interface for both IAuthService

Since we also need to change the AuthModule, we initialize two different modules based on the feature flag like this:

function getModuleConfig(): ModuleMetadata {
  if (process.env.NOVU_ENTERPRISE === 'true') {
    return getEEModuleConfig();
  } else {
    return getCommunityAuthModuleConfig();
  }
}

@Global()
@Module(getModuleConfig())
export class AuthModule {
  public configure(consumer: MiddlewareConsumer) {
    if (process.env.NOVU_ENTERPRISE !== 'true') {
      configure(consumer);
    }
  }
}

登入後複製

The reason why the EEModule can be a standalone module in the @novu/ee-auth package which we would just import instead of the original AuthModule and instead we are initializing one module conditionally inside API, is that we are reusing some original providers in the EE one - e.g. ApiKeyStrategy , RolesGuard, EnvironmentGuard etc which resides directly in API.

We would need to import them in the @novu/ee-auth package which would require to export these things somewhere (probably in some shared package) and it introduces other issues like circular deps etc - it can be however refactored later.

Repositories - users, organizations, members

Same logic applies for the repositories. No module is being initialized here, they’re just directly injected to the original repository classes.

Moving User Management from In-House to a Product: Why We Did It and What We Learned


Controllers

The controllers are being conditionally imported from inside @novu/api . The reason for that is the same as in the auth module, there are too many imports that the controllers uses, that we would either need to move to @novu/ee-auth or move them to a separate shared package - which would then trigger a much bigger change to the other unrelated parts of the app, which would increase the scope of this change.

function getControllers() {
  if (process.env.NOVU_ENTERPRISE === 'true') {
    return [EEOrganizationController];
  }

  return [OrganizationController];
}

@Module({
  controllers: [...getControllers()],
})
export class OrganizationModule implements NestModule {
    ...
}

登入後複製

Issues with this approach

The main issue here is the need for common interface for both of the classes - community and enterprise. You want to remain compatible in both community and enterprise versions, so when there is a this.organizationService.getOrganizations() method being called in 50 places in the app - you need an enterprise equivalent with the same name otherwise you need to change 50 places to call something else.

This results in not-so-strict typing and methods without implementation

Moving User Management from In-House to a Product: Why We Did It and What We Learned

We need to have a common interface for both, however the community one relies on MongoDB methods and needs different method arguments as the enterprise one which causes a use of any to forcefully fit both classes etc.
In some cases we don’t need the method at all, so we need to throw Not Implemented .


Endpoints modification

We modified the endpoints as follows:

  • AuthController: Removed and replaced by frontend calls to Clerk.
  • UserController: Removed, added a sync endpoint for Clerk users with MongoDB.
  • OrganizationController: Removed several endpoints, which can be migrated later.
  • InvitesController: Completely removed.
  • StorageModule: Completely removed.

Key points to consider and avoid

  1. Avoid Storing Frequently Changing Properties in JWT
    • Example: environmentID
    • It can be cumbersome to update these properties.
  2. Simplify Stored Data Structures
    • Avoid storing complex structures in user, organization, or member records.
    • Clerk performs optimally with simple key:value pairs, not arrays of objects.
  3. Implement a User/Organization Replication Mechanism
    • This helps bridge the gap during the migration period before Clerk is fully enabled.
    • Use MongoDB triggers to replicate newly created users and organizations to both Clerk and your internal database.
  4. Store Original Emails
    • Do not sanitize emails as Clerk uses the original email as a unique identifier.

Team Spotlight

Lead Engineer: Adam Chmara

Platform Team Lead: Denis Kralj


Summary

Our implementation approach comes to the fact that we offloaded the Users, Organizations and Members management to Clerk.

The data property injection to Novu’s Controllers (endpoints) layer, Business layer and data layer happens based on “Enterprise” feature flag validation.

We are leveraging pre-built Clerk components on the frontend and reducing the need to build and maintain our own custom implementation on the backend.

You can also observe below the diagram of the current state after implementing Clerk.

Moving User Management from In-House to a Product: Why We Did It and What We Learned


事後諸葛亮獎勵積分

當我們決定實施 Clerk 進行使用者管理時,我們也選擇了擴展 Clerk 未來將支援和提供的功能和特性的長期利益。

以下是我們在不久的將來可能考慮支持的一些例子:

  • 細粒度存取控制(FGAC)
    • 基於屬性:FGAC 通常使用基於屬性的存取控制 (ABAC) 來實現,其中存取決策基於使用者、資源和環境的各種屬性。屬性可以包括使用者角色、部門、資源類型、一天中的時間等。
    • 靈活性:FGAC 透過允許詳細的、基於條件的存取控制來提供更大的靈活性和粒度。這意味著權限可以根據非常具體的場景進行微調。
    • 動態:FGAC 可以動態適應環境的變化,例如時間敏感的存取或基於位置的限制。
    • 詳細權限:FGAC 中的權限更具體,可以根據個人操作、使用者或情況進行自訂。

為 Novu 提供這種程度的詳細靈活性,可能超出了範圍,甚至由於實施的潛在複雜性而被刮掉。

  • 使用者冒充

    我們的客戶成功或支援團隊可以使用它來解決問題、提供支援或從模擬使用者的角度測試功能,而無需知道他們的密碼或其他身份驗證詳細資訊。

    • 減少診斷和解決使用者問題所涉及的時間和複雜性。
    • 確保所採取的支援或管理操作準確無誤,因為支援人員可以像使用者一樣查看系統並與之互動。
    • 透過提供更快、更有效的支援來提高使用者滿意度。

簡而言之,鑑於現在身份驗證基礎設施已被佔用,我們將能夠輕鬆改善 Novu 用戶的體驗。


如果您想建議涉及 AuthN(或任何其他)的其他功能,請訪問我們的路線圖來審核和投票請求,或提交您的想法。


喜歡您所讀的內容嗎?點擊關注以獲取更多更新,並在下面發表評論。我❤️想聽聽你的?

Moving User Management from In-House to a Product: Why We Did It and What We Learned

埃米爾·皮爾斯

我在咖啡店寫程式碼和文字。

以上是將使用者管理從內部轉移到產品:我們為什麼這樣做以及我們學到了什麼的詳細內容。更多資訊請關注PHP中文網其他相關文章!

來源:dev.to
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板
關於我們 免責聲明 Sitemap
PHP中文網:公益線上PHP培訓,幫助PHP學習者快速成長!