社内コードベース向けAI(RAG)を最速で構築する手順

目的

社内アプリのソースコードを対象としたAI(RAG)を最速(30分以内)で構築します。
  • 質問→ベクトル検索→LLM→回答(+シーケンス図作成)

前提条件

  • VM(Ubuntu 24.04)
  • Docker が動作
  • 環境変数 OPENAI_API_KEY.env に設定
echo OPENAI_API_KEY=sk-… > ~/.env

1. Python 仮想環境&ライブラリ整備

python3 -m venv venv && source venv/bin/activate && pip install chromadb==0.6.3 langchain-chroma==0.2.3 langchain-openai==0.3.14 fastapi uvicorn python-dotenv streamlit requests

2. ベクトルインデックス生成

ソースコードリポジトリをファイル走査して 1,000~1,200文字チャンクに分割し、OpenAI で埋め込み→Chroma に保存。
対象が数千ファイルであれば、実行時間は十数分、コストは$1.5程度でした。
実行例:python create_index.py --repo ~/repo1 --persist ~/chroma-data --col repo1
#!/usr/bin/env python3
"""
create_index.py — リポジトリを Chroma インデックス化
"""
import argparse,os,pathlib,shutil,sys
from dotenv import load_dotenv
import openai
from langchain.text_splitter import RecursiveCharacterTextSplitter,Language
from langchain.docstore.document import Document
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

p=argparse.ArgumentParser()
p.add_argument('--repo',required=True)
p.add_argument('--persist',default='~/chroma-data')
p.add_argument('--col',default='repo1')
p.add_argument('--chunk',type=int,default=1200)
p.add_argument('--overlap',type=int,default=100)
args=p.parse_args()

ROOT=pathlib.Path(args.repo).expanduser().resolve()
PERSIST=pathlib.Path(args.persist).expanduser().resolve()/args.col
load_dotenv()
openai.api_key=os.getenv('OPENAI_API_KEY') or sys.exit('Missing API key')

EXTS={'.php':Language.PHP,'.ts':Language.TS,'.js':Language.JS,
      '.vue':Language.JS,'.twig':Language.HTML,
      '.scss':Language.CPP,'.css':Language.CPP}
files=[f for f in ROOT.rglob('*') if f.suffix in EXTS]
docs=[]
for f in files:
    try: txt=f.read_text(errors='ignore')
    except: continue
    docs.append(Document(page_content=txt,metadata={'path':str(f.relative_to(ROOT))}))
splits=[]
for d in docs:
    lang=EXTS[pathlib.Path(d.metadata['path']).suffix]
    splits+=RecursiveCharacterTextSplitter.from_language(
        language=lang,chunk_size=args.chunk,chunk_overlap=args.overlap
    ).split_documents([d])

if PERSIST.exists(): shutil.rmtree(PERSIST)
emb=OpenAIEmbeddings(model='text-embedding-3-large')
Chroma.from_documents(documents=splits,embedding=emb,
    persist_directory=str(PERSIST),collection_name=args.col)
print('Indexed at',PERSIST)

3. 質問応答 API 整備

FastAPI で /ask(QA)と /render_mermaid(Mermaid→PNG)を提供。
クエリパラメータでリポジトリ(repo1/repo2)とモデル(gpt-4.1/gpt-4.1-mini/gpt-4o-mini/o4-mini)を切替可能です。
起動例:uvicorn qa_api:app --host 127.0.0.1 --port 8600 --log-level debug
#!/usr/bin/env python3
"""
qa_api.py — QA(+Mermaid→PNG) API(repo/model切替可)
"""
import pathlib,uuid,subprocess,uvicorn
from fastapi import FastAPI,HTTPException,Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings,ChatOpenAI
from langchain_chroma import Chroma
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

PERSIST_BASE=pathlib.Path.home()/'chroma-data'
REPOS=['repo1','repo2']
MODELS=['gpt-4.1','gpt-4.1-mini','gpt-4o-mini','o4-mini']
TMP=pathlib.Path('/tmp/mermaid_pngs');TMP.mkdir(mode=0o777,parents=True,exist_ok=True)
DOCKER_IMG='minlag/mermaid-cli';SCALE='3'
EMB='text-embedding-3-large';TOP_K=8
load_dotenv()

emb=OpenAIEmbeddings(model=EMB)
RETRIEVERS={r:Chroma(persist_directory=str(PERSIST_BASE/r),
    collection_name=r,embedding_function=emb
).as_retriever(search_kwargs={'k':TOP_K}) for r in REPOS}

prompt=PromptTemplate(input_variables=['context','question'],template="""
You are a code expert. Context:
{context}

Question:
{question}

1. Answer concisely.
2. If sequence diagram is requested, include a Mermaid sequenceDiagram block.
3. Otherwise, no Mermaid.
""")

app=FastAPI()
class QAReq(BaseModel):question:str

@app.post('/ask')
def ask(req:QAReq,
        repo:str=Query('repo1',enum=REPOS),
        model:str=Query('gpt-4.1',enum=MODELS)):
    retr=RETRIEVERS.get(repo)
    if not retr: raise HTTPException(400,'Unknown repo')
    if model in ['gpt-4.1','gpt-4.1-mini','o4-mini']:
        llm=ChatOpenAI(model_name=model)
    else:
        llm=ChatOpenAI(model_name=model,temperature=0.0)
    chain=RetrievalQA.from_chain_type(llm=llm,chain_type='stuff',
        retriever=retr,chain_type_kwargs={'prompt':prompt},
        return_source_documents=True)
    res=chain(req.question)
    return {'answer':res['result'],
            'sources':[d.metadata['path'] for d in res['source_documents']]}

class RenderReq(BaseModel):mermaid:str

@app.post('/render_mermaid')
def render(req:RenderReq):
    k=uuid.uuid4().hex
    m=TMP/f'{k}.mmd';p=TMP/f'{k}.png'
    m.write_text(req.mermaid,encoding='utf-8')
    try:
        subprocess.run(['docker','run','--rm','-v',
            f'{TMP.absolute()}:/data',DOCKER_IMG,
            '-i',f'/data/{k}.mmd','-o',f'/data/{k}.png',
            '-s',SCALE],check=True,stdout=subprocess.PIPE,
            stderr=subprocess.PIPE)
    except subprocess.CalledProcessError as e:
        err=(e.stderr or e.stdout).decode().strip()
        raise HTTPException(500,detail=f'Mermaid error: {err}')
    return FileResponse(str(p),media_type='image/png')

@app.get('/health')
def health(): return {'status':'ok'}

if __name__=='__main__':
    uvicorn.run(app,host='0.0.0.0',port=8600)

4. Web UI 整備

起動例:streamlit run ui.py --server.port 8501
#!/usr/bin/env python3
"""
ui.py — Streamlit チャット UI(repo/model選択+PNG表示)
"""
import os,re,requests,streamlit as st
from dotenv import load_dotenv

st.set_page_config(page_title='コードアシスタント',layout='wide')
load_dotenv()
API_ASK=os.getenv('API_ASK','http://localhost:8600/ask')
API_RDR=os.getenv('API_RENDER','http://localhost:8600/render_mermaid')

repo=st.sidebar.selectbox('リポジトリ',['repo1','repo2'],index=0)
model=st.sidebar.selectbox('モデル',['gpt-4.1','gpt-4.1-mini','gpt-4o-mini','o4-mini'],index=0)
st.sidebar.markdown(f'ask: `{API_ASK}`')
st.sidebar.markdown(f'render: `{API_RDR}`')

MERMAID_RE=re.compile(r'```mermaid\n(.*?)```',re.S)
st.title('🛠️ コード QA')

if 'chat' not in st.session_state: st.session_state.chat=[]

for r,m in st.session_state.chat: st.chat_message(r).write(m)
q=st.chat_input('質問を入力...')
if q:
    st.session_state.chat.append(('user',q));st.chat_message('user').write(q)
    with st.spinner('回答中…'):
        res=requests.post(f"{API_ASK}?repo={repo}&model={model}",
                          json={'question':q}).json()
        ans,sources=res['answer'],res['sources']
        st.session_state.chat.append(('assistant',ans))
        with st.chat_message('assistant'):
            parts=MERMAID_RE.split(ans)
            for i,p in enumerate(parts):
                if i%2==0 and p.strip(): st.markdown(p)
                if i%2==1:
                    img=requests.post(API_RDR,json={'mermaid':p}).content
                    st.image(img,use_container_width=True)
                    with st.expander('Mermaid ソース'): st.code(p)
            if sources:
                with st.expander(f'{len(sources)} 件の参照コード'):
                    for s in sources: st.markdown(f'- `{s}`')

5. Nginx 設定(抜粋)

location /api/ {
    proxy_pass http://127.0.0.1:8600/;
}
location / {
    proxy_pass http://127.0.0.1:8501;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

6. ブラウザで動作確認

ブラウザで / を開き、「ログイン処理の概要やシーケンス図を生成して」など質問すると、回答+シーケンス図が得られます。

今後の予定

自動コードレビューや、エディタ内でのコード補完など。

SPF・DKIM・DMARCの簡単な解説

こんにちは岡野です。
時々分からなくなるためSPF・DKIM・DMARCについてまとめます。

前提知識

メールのFromアドレスには2種類ある。
エンベロープFrom:SMTP通信で使用するもの。メールヘッダ内ではReturn-Pathとして確認できる。
メールヘッダFrom:メール送信者が本文などとともに指定するもの。メールクライアントではこちらが表示されるため、なりすましを防ぐにはこちらが正しいことが重要。

SPF認証

エンベロープFromのドメインでDNSからSPF用IPアドレスリストを取得し、送信サーバのIPアドレスが存在することを確認する。

DKIM認証

メール署名時のドメインでDNSから公開鍵を取得し、ハッシュが一致することを確認する。

DMARC

SPF認証&SPFアライメント、もしくは、DKIM認証&DKIMアライメント、のいずれかが成り立つことを確認する。
SPFアライメント:エンベロープFromのドメインとメールヘッダFromのドメインが一致すること。
DKIMアライメント:DKIM署名のドメインとメールヘッダFromのドメインが一致すること。

結論

SPF・DKIM・DMARCすべて対応するのが望ましい。

OS Loginを有効にしたGCEにPuttyやWinSCPで接続する方法

こんにちは岡野です。
GCPのCompute EngineへPuttyやWinSCPで接続したいという要望があり検索したのですが、なかなか情報が無かったためここで共有します。

・GCEの状態

外部IPアドレス未設定
OS Login有効

・接続方法

SSHトンネルを利用する。

1. 以下のコマンドをWSL上などで実行。

gcloud compute ssh {{ gce name }} --ssh-flag="-L 10001:localhost:22 -N -f"
    1. PuttyやWinSCPなどでは以下の情報で接続する。
    ユーザ名:GCPで使用しているアカウント(通常はメアドの記号を_へ置換したもの、例:name_example_com)
    ホスト名:localhost
    ポート:10001
    秘密鍵:GCP用の鍵

    ImageMagickでのPNGファイル生成の高速化

     
    こんにちは岡野です。
    弊社サービスではImageMagickでPNGファイルを生成することが多いのですが、高速化するための設定を調査したので共有します。
     

    環境

    ImageMagick 6.9.12-84 Q8
    Ubuntu 22.04
     

    調査内容

    PNGファイル出力時の圧縮設定を変更した際の出力時間を調査しました。なおこの解説記事を参考にしています。

    圧縮方法を設定する-quality引数を0~99で変えながら実行した際の時間を数回計測しました。

    使用したコマンドは
    $ convert logo: -resize 1024×1024! logo.png
    $ rm log ; for q in `seq 0 1 99` ; do /usr/bin/time -f %e:$q -a -o log convert logo.png -quality $q png32:logo-$q.png ; done
    $ sort log

    で、結果は以下です。
    11,12,13,14,21,22,23,24,31,32,33,34は0.05秒
    41,42,43,44,51,52,54は0.06秒
    以降、省略

    ファイルサイズは以下の通りでした。
    $ ls -l logo-[123][1234].png logo-75.png
    384337  3月 24 17:31 logo-11.png
    384337  3月 24 17:31 logo-12.png
    384337  3月 24 17:31 logo-13.png
    384337  3月 24 17:31 logo-14.png
    378112  3月 24 17:31 logo-21.png
    378112  3月 24 17:31 logo-22.png
    378112  3月 24 17:31 logo-23.png
    378112  3月 24 17:31 logo-24.png
    374669  3月 24 17:31 logo-31.png
    374669  3月 24 17:31 logo-32.png
    374669  3月 24 17:31 logo-33.png
    374669  3月 24 17:31 logo-34.png
    295753  3月 24 21:00 logo-75.png (デフォルト設定)

    11,12,13,14,21,22,23,24,31,32,33,34のいずれかの設定が良さそうでした。ただしpng:compression-filterの仕様を見ると画像内容によって上記の結果も結構変わりそうです。
     

    結論

    今後も検討を続けますが一旦は-quality 11を使用することにしました。ただしファイルサイズがデフォルト設定より増えるため(295,753->384,337)、ユーザアクセス内容によっては弊害も大きく注意が必要です。
     

    サーバ上のエラー発生行をブラウザからPhpStormで開く方法

    こんにちは岡野です。
    今回はPHPでの開発が楽になる方法を紹介します。最初の設定は面倒ですがメリットは大きいため、PhpStorm(もしくはIntelliJのPHPプラグイン)を使っている方はぜひ試してください。
       
    • 環境
      Windows
      IntelliJ IDEA 2023.2 + PHP Plugin
      Symfony(おそらく他のフレームワークでも対応可能)
      PhpStorm Protocol (最終編集2023-08-17)
    • インストール
    1. https://github.com/aik099/PhpStormProtocol のCodeボタンからzipをダウンロードする。  
    2. zip内の「PhpStorm Protocol (Win)」 を C:\Program Files\ へコピーする。  
    3. C:\Program Files\PhpStorm Protocol (Win)\run_editor.reg をダブルクリック。OK押下。  
    4. run_editor.jsを編集。上記PhpStormProtocol説明ページに載っている方法でうまくいかない場合は、editor変数を直接変更する。  
       
      editor = ‘”C:\\Users\\ユーザ名\\AppData\\Local\\Programs\\IntelliJ IDEA Ultimate\\bin\\idea64.exe”‘;
       
    5. Symfonyの設定を行う。 例は、サーバ:/opt/app/、ローカル:c:\git\app\symfony/というマッピングの場合。
    (Symfony6.1以上の場合)
    .env.localに以下を記述。
    SYMFONY_IDE=phpstorm://open?file=%f&line=%l&/opt/app/>c:/git/app/symfony/
     
    (Symfony6.0以下の場合)
    framework.yaml内のframeworkにide要素を追加。
    framework:
      ide: 'phpstorm://open?file=%%f&line=%%l&/opt/app/>c:/git/app/symfony/'
     
    ・実行
    エラー発生時にファイルパスのリンクをクリックすると、該当箇所がIntelliJで開く。

    ImageMagick脆弱性対応(パッチ)

    こんにちは岡野です。
    先日行った、画像処理ライブラリImageMagickの脆弱性対応を共有します。当社ではオリジナルプリントやMEET MY GOODSといったサービスで処理高速化のためリコンパイルしたImageMagickを使用しています。

    脆弱性の概要

    脆弱性(Metabase Qの報告
    CVE-2022-44267:DoS攻撃
    CVE-2022-44268:任意のファイル参照
    ですが、不正なテキスト情報を埋め込んだPNGファイルをImageMagickが処理する際に発生する物でした。

    脆弱性の対応案

    ・ImageMagickのバージョンアップ
    ImageMagickを最新版へバージョンアップすれば解決するとのことでしたが、テストに時間が掛かるため他の方法を検討しました。

    ・画像データの修正
    脆弱性の原因は前述の通り不正なテキスト情報ですので、ImageMagickへ画像ファイルを渡す前に不正なテキスト情報を除去する案です。PNG最適化ツールpngcrushで除去する方法をネットで見つけ試してみましたが除去処理に時間が掛かるためあきらめました(バージョンにより違うのかもしれませんが大きめの画像では10秒以上掛かることがありました)。

    ・ImageMagickのパッチ適用
    脆弱性を修正するパッチをImageMagickのソースコードへ適用する案です。今回はこの案で対応しました。

    パッチ適用の手順

    今回はUbuntu用のパッチを流用しました。
    https://launchpad.net/ubuntu/+source/imagemagick/8:6.9.7.4+dfsg-16ubuntu6.15
    の「Available diffs」の箇所です。
    但しとあるツイートに存在したPNGファイル
    echo -en "P4\n1 1\nX" | pnmtopng -text <(echo 'fx:while(1,debug(hui=3)) 0') > payload.png
    の脆弱性を防げなかったためパッチのif文
    if ((LocaleCompare(key,"version") == 0) ||
        (LocaleCompare(key,"profile") == 0) ||
        (LocaleCompare(key,"width") == 0))
    へ「fx:」を無効化する
        (LocaleNCompare(key,"fx:",3) == 0) ||
    を追加しました(ImageMagickのバージョンによっては再現しなさそうでした、おそらくこのissue)。

    まとめ

    残念ながらImageMagickの脆弱性は今後も発見されると思いますので、バージョンアップを容易にしておく必要性を感じました。今後はバージョンアップ用の自動テストケースを準備しようと思います。

    ImageMagickで服の色を変える

    こんにちは岡野です。
    現在開発中のプロジェクトで、服の色を変更する処理を実装したので紹介します(簡易的な方法です)。

    環境

    ImageMagick 6.9.12-59 Q8

    手順

    1. 元となる画像を用意します。例:Tシャツ ホワイト。
    2. 例えばネイビーへ変更するとして変更前後の色についてRGBそれぞれの差分を計算します。
    ホワイト: rgb(255, 255, 255)
    ネイビー: rgb(32, 47, 85)
    R: 32 - 255 = -223
    G: 47 - 255 = -208
    B: 85 - 255 = -170
    3. ImageMagickのevaluate Addに差分の値を渡して色を変換します(Q16環境では各値を256倍します)。
    convert src.png \
    -channel R -evaluate Add -223 \
    -channel G -evaluate Add -208 \
    -channel B -evaluate Add -170 \
    dest.png
    

    まとめ

    ImageMagickだけでも結構良い感じになりました。将来はAIで着色したいと思います。

    その他

    最近Zabbixでハマったことです。グラフ内の文字が出力されないため悩んでいたのですがopen_basedirの設定不足でした(zabbix_server.logに気をとられNginxのログに気づくのが遅れた)。何か問題があった際は落ち着かないとダメだと感じました。

    Symfonyで2要素認証

    こんにちは、岡野です。
    最近Symfonyを使っているWebサイトで2要素認証を実現しました。その際に使用したscheb/2faバンドルは非常に使いやすく、Symfony公式マニュアルを見ながら容易に実現できました。但し一点問題があり、認証コードを入力しないままユーザが画面遷移すると中途半端な認証状態になってしまうので困りました。
    今回はその回避方法を共有します。
     

    環境

    Symfony: 4.4.20
    scheb/2fa-*: 5.13.1
     

    起きていた現象

    1. ユーザがID/PWを入力。
    2. 認証コードを尋ねるポップアップを表示。
    3. ユーザが他の画面へ遷移。

    → 2.でSymfony内部の認証状態が「Partially authenticated」になるため、3.でController#getUser()すると認証が完了していないユーザエンティティが取得できてしまう(※1)。
    また3.のページがfirewallで制限している場合は認証コードを尋ねるページが表示される。一般的な通常サイトの様にログイン画面へ遷移させたい(※2)。
     

    ※1(完全認証前のユーザが取得できてしまう)の回避方法

    EventListenerを使って、認証コードの確認が完了するまでuserをanonymousにする。
     
    class AuthAttemptListener implements EventSubscriberInterface
    {
      public static function getSubscribedEvents(): array
      {
          return [
              TwoFactorAuthenticationEvents::ATTEMPT => ['onTwoFactorAuthAttempt'],
              TwoFactorAuthenticationEvents::COMPLETE => ['onTwoFactorAuthComplete'],
              TwoFactorAuthenticationEvents::FAILURE => ['onTwoFactorAuthFailure'],
              KernelEvents::RESPONSE => ['onKernelResponse'],
              KernelEvents::TERMINATE => ['onKernelTerminate'],
          ];
        }

      /**
       * セッション内の情報が正しければ、バンドル内の2要素認証処理用にトークンへユーザを設定する。
    * ユーザは後で解除する。
       */
      public function onTwoFactorAuthAttempt(TwoFactorAuthenticationEvent $event): void
      {
          // ...
          $this->attemptingToken->setUser($user);
        }

      /**
       * 2要素認証が成功した場合、トークンを認証状態に設定する。
       */
      public function onTwoFactorAuthComplete(TwoFactorAuthenticationEvent $event): void
      {
          // ...
          $this->attemptingToken->getAuthenticatedToken()->setAuthenticated(true);
        }

      /**
       * 2要素認証失敗時、トークンのユーザを元に戻す
       */
      public function onTwoFactorAuthFailure(TwoFactorAuthenticationEvent $event): void
      {
          if ($this->attemptingToken) {
              $this->revertTokenUser();
          }
        }

      /**
       * 例外発生時、トークンのユーザを元に戻す
       */
      public function onKernelResponse(): void
      {
          if ($this->attemptingToken) {
              $this->revertTokenUser();
          }
        }

      /**
       * 安全のためSymfonyの処理が終わる際に、万が一処理中のトークンが残っていたら
    * 例外を発生させてエラー通知する。
       */
      public function onKernelTerminate(): void
      {
          if ($this->attemptingToken) {
              throw new \LogicException();
          }
        }

      private function revertTokenUser(): void
      {
          $this->attemptingToken->setUser('anon.');
          $this->attemptingToken->getAuthenticatedToken()->setAuthenticated(false);
          $this->attemptingToken = null;
      }
    }

    ※2(認証コード要求ページが表示される)の回避方法

    認証コードが要求された場合、ログイン画面へ遷移する。
     
    services.yaml
      app_2fa_required_handler:
            class: App\Security\TwoFactorAuth\AuthRequiredHandler

    class AuthRequiredHandler implements AuthenticationRequiredHandlerInterface
    {
      public function onAuthenticationRequired(Request $request, TokenInterface $token): Response
      {
          $this->tokenStorage->setToken(new AnonymousToken('', 'anon.'));
          throw new AccessDeniedException();
      }
    }
    参考になれば幸いです。

    Doctrine SQL Filterでマルチテナントを実現する

    こんにちは、岡野です。

    最近マルチテナントのシステムを開発することがあり、共通のDBをテナントごとに管理する方法を調べました。

    結論

    Doctrineの以下機能を使用するとCRUDの各SQLを改変でき、テナントを透過的に制御可能(すなわち毎回WHERE句にテナントの条件を書かなくて良い)。 但し、@OneToOneは期待通りに動作しないため要注意。

    環境

    Doctrine 2.7.4
    Symfony 4.4.16

    SELECTの調査結果

    以下の様なSQL Filterを登録。
    class TenantFilter extends SQLFilter
    {
        public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
        {
            // 100は実際には可変(後述)
            return $targetTableAlias.'.tenant_id = 100';
        }
    }

    単純なSELECT

    $em->find(Parent::class, 1);
    → SELECT ... FROM parent WHERE id = 1 AND tenant_id = 100;

    @OneToMany

    $em->find(Parent::class, 1)->getChilds();
    → SELECT ... FROM parent WHERE id = 1 AND tenant_id = 100;
    → SELECT ... FROM child WHERE parent_id = 1 AND tenant_id = 100;

    @ManyToOne

    @OneToMany同様。

    @ManyToMany

    @OneToMany同様。

    @OneToOne

    $em->find(Example1::class, 1)->getExample2();
    → SELECT ... FROM example1 e1 LEFT JOIN example2 e2 ON e2.example1_id = e1.id WHERE e1.id = 1 AND e1.tenant_id = 100;
    ⇒ example2テーブルへのtenant_id = 100が存在しない
    

    join()

    $em->createQueryBuilder('c')->join('c.parent')->where('c.id = 1');
    → SELECT ... FROM child c JOIN parent p ON c.parent_id = p.id AND p.tenant_id = 100 WHERE c.id = 1 AND c.tenant_id = 100;
    

    leftJoin()

    join()同様。



    INSERT/UPDATE/DELETEの調査結果

    以下の様なDoctrine Event Listenerを登録。
    class TenantListener
    {
        public function prePersist(LifecycleEventArgs $args): void
        {
            assert($args->getEntity()->getTenant() === null);
            // 100は実際には可変
            $args->getEntity()->setTenant($this-em->find(Tenant::class, 100));
        }
        public function preUpdate(LifecycleEventArgs $args): void
        {
            // 安全のため他テナントを操作していないかチェックする
            if ($args->getEntity()->getTenant()->getId() !== 100) {
                throw new \LogicException();
            }
        }
        public function preRemove(LifecycleEventArgs $args): void
        {
            // preUpdate()同様
        }
    }
    

    INSERT

    $em->persist(new Example());
    → INSERT INTO example (..., tenant_id) VALUES (..., 100);
    


    その他

    上記のようにDoctrine FilterのaddFilterConstraint()内でtenant_idを直接設定するとDoctrine Query CacheによりSQLが固定化されてしまい期待通りに動作しない。そのため以下の通りparameterを経由する必要があった。

    // 外部から呼ぶ(RequestListenerなどから)
    public function setTenantId(int $tenantId): void
    {
        $this->setParameter('tenant_id', $tenantId, Types::INTEGER);
    }
    
    public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
    {
        $tenantId = (int)$this->unquoteParameterValue($this->getParameter('tenant_id'));
        return $targetTableAlias.'.tenant_id = '.$tenantId;
    }
    
    private function unquoteParameterValue(string $value): string
    {
        $len = strlen($value);
        if ($value[0] !== "'" || $value[$len - 1] !== "'") {
            throw new \LogicException($value);
        }
        return str_replace(['\\"', '\\\\'], ['"', '\\'], substr($value, 1, $len - 2));
    }
    

    RectorでSymfony4への変換

    こんにちは、岡野です。

    最近、Symfony2.8/PHP7.0で実装されたサービスをSymfony4.4/PHP7.4へバージョンアップしました。その際ソースコードの自動変換に使用したRectorというソフトウェアを紹介します。
    詳しくは https://getrector.org/ を見ていただくとし、実際の変換結果を挙げていきます。    

    変換結果

    Symfony2.8 -> 2.8

    元々Symfony2.8で実装していますが念のためRectorを実行します。

    最初に設定ファイルを準備します。
    $ vi rector.php
    ...
    $parameters->set(Option::PATHS, [
        __DIR__.'/app',
        __DIR__.'/src',
    ]);
    $parameters->set(Option::EXCLUDE_PATHS, [
        __DIR__.'/app/SymfonyRequirements.php',
        __DIR__.'/app/cache/',
        __DIR__.'/src/AppBundle/Tests/',
    ]);
    $parameters->set(Option::PHP_VERSION_FEATURES, '7.0'); // 適宜変更
    $parameters->set(
        Option::SYMFONY_CONTAINER_XML_PATH_PARAMETER,
        __DIR__.'/appDevDebugProjectContainer.xml'
    );
    
    そしてRectorを実行します。
    $ vendor/bin/rector process --set symfony28
    以下変換されました(以降、変換結果の一部を抜粋します)。
    /**
     * @Route(defaults={"foo": ""}, ...)
     */
    ↓ ↓ ↓
    /**
     * @Route(defaults={"foo"= ""}, ...)
     */
     

    Symfony2.8 -> 3.0

    $ vendor/bin/rector process --set symfony30
    $form = $this->createForm(new Foo());
    ↓ ↓ ↓
    $form = $this->createForm(\AppBundle\Form\Foo::class);
    
     

    Symfony3.0 -> 3.4

    (symfony31~symfony33は差分が発生しませんでした)
    $ vendor/bin/rector process --set symfony34
    /**
     * @Route(...)
     * @Method("POST")
     */
    ↓ ↓ ↓
    /**
     * @Route(..., methods={"POST"})
     */
     

    PHP7.0 -> 7.0

    $ vendor/bin/rector process --set php70
    isset($foo[$bar]) ? $foo[$bar]: 0
    ↓ ↓ ↓
    $foo[$bar] ?? 0
    
    rand();
    ↓ ↓ ↓
    random_int(0, mt_getrandmax());
     

    PHP7.0 -> 7.1

    $ vendor/bin/rector process --set php71
    list($a, $b) = $this->foo();
    ↓ ↓ ↓
    [$a, $b] = $this->foo();
    
    count($foo)
    ↓ ↓ ↓
    is_array($foo) || $foo instanceof \Countable ? count($foo) : 0 // php73実行時に改善される
     

    Symfony3.4 -> 4.2

    (symfony40,symfony41は差分が発生しませんでした)
    $ vendor/bin/rector process --set symfony42
    use Symfony\Bundle\FrameworkBundle\Controller\Controller;
    class FooController extends Controller
    ↓ ↓ ↓
    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    class FooController extends AbstractController
     

    PHP7.1 -> 7.3

    (php72は差分が発生しませんでした)
    $ vendor/bin/rector process --set php73
    is_array($foo) || $foo instanceof \Countable ? count($foo) : 0
    ↓ ↓ ↓
    is_countable($foo) ? count($foo) : 0
    
    json_encode($foo)
    ↓ ↓ ↓
    json_encode($foo, JSON_THROW_ON_ERROR)
    
    json_decode($foo)
    ↓ ↓ ↓
    json_decode($foo, false, 512, JSON_THROW_ON_ERROR)
     

    Symfony4.2 -> 4.3

    $ vendor/bin/rector process --set symfony43
    $event->getDispatcher()->dispatch(
        FOSUserEvents::SECURITY_IMPLICIT_LOGIN,
        new UserEvent($event->getUser(), $event->getRequest()));
    ↓ ↓ ↓
    $event->getDispatcher()->dispatch(
        new UserEvent($event->getUser(), $event->getRequest()),
        FOSUserEvents::SECURITY_IMPLICIT_LOGIN);
    
    use Symfony\Component\HttpKernel\Event\GetResponseEvent;
    public function foo(GetResponseEvent $event)
    ↓ ↓ ↓
    use Symfony\Component\HttpKernel\Event\RequestEvent;
    public function foo(RequestEvent $event)
     

    Symfony4.3 -> 4.4

    $ vendor/bin/rector process --set symfony44
    // Command class
    public final function execute(InputInterface $input, OutputInterface $output)
    ↓ ↓ ↓
    public final function execute(InputInterface $input, OutputInterface $output): int
     

    PHP7.3 -> 7.4

    $ vendor/bin/rector process --set php74
    /**
     * @var integer
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
    ↓ ↓ ↓
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private int $id;
    
    /**
     * @var integer
     * @ORM\Column(name="foo", type="integer", nullable=true)
     */
    private $foo;
    ↓ ↓ ↓
    /**
     * @ORM\Column(name="foo", type="integer", nullable=true)
     */
    private ?int $foo = null;
    
    /** @var EntityManager */
    private $em;
    ↓ ↓ ↓
    private \Doctrine\ORM\EntityManager $em; // use形式への変更は手作業が必要
    
    1024
    ↓ ↓ ↓
    1_024 // この変換は微妙
     

    Twig1.x -> 2.0

    $ vendor/bin/rector process --set twig20
    new \Twig_SimpleFilter(...)
    ↓ ↓ ↓
    new \Twig_Filter(...)
     

    Twig2.0 -> 2.4.0

    $ vendor/bin/rector process --set twig240
    new \Twig_Filter(...)
    ↓ ↓ ↓
    new \Twig\TwigFilter(...)
     

    まとめ

    正規表現による置換よりもソフトウェアで一括変換した方が抜けも誤りも少ないでしょう。

    だし誤変換もごく一部あります。未定義の配列へ要素追加しているコードが $foo = (array)$foo; $foo[] = $bar; と変換されるなどです(元々未定義なことが良くないのですが)。

    って変換結果にざっと目を通すことは必要ですが、ほとんどはPHP/Symfony文法エラーのため容易に気付けます。変換後にPHPStormのInspect Code機能で確認することも有効でした。

    当然
    ながらRectorで変換できない点もありますので手動対応は必要です(src/AppBundle -> src/など)。

    その他

    ­ 上記以外にもRectorのルールセットは多数ありますので興味ありましたら以下をご覧ください。
    https://github.com/rectorphp/rector/tree/master/config/set